@volter/world-core 2.0.0
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/LICENSE +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Zero-edit browser injection: a dev proxy you put in front of your app so the
|
|
2
|
+
// browser's vendor SDK calls share the SAME twin the backend uses — no app edits.
|
|
3
|
+
//
|
|
4
|
+
// volter-twin proxy --target http://localhost:3000 \
|
|
5
|
+
// --map stripe=http://127.0.0.1:12111 --route stripe=/v1/ --loader-host stripe=https://api.stripe.com
|
|
6
|
+
//
|
|
7
|
+
// The browser loads your app through the proxy. Requests to a configured vendor's
|
|
8
|
+
// browser API path (its `apiPathPrefix`, e.g. Stripe's /v1/…) are forwarded to that
|
|
9
|
+
// vendor's twin; everything else passes through to your app. For vendors whose browser
|
|
10
|
+
// SDK loads from a CDN and calls an absolute host (its `loaderHost`, e.g. Stripe.js →
|
|
11
|
+
// api.stripe.com), the proxy rewrites the loader so its calls become same-origin and get
|
|
12
|
+
// forwarded too. This kernel module is VENDOR-AGNOSTIC: the per-vendor routing values are
|
|
13
|
+
// DATA supplied by the caller (sourced from each pack's `TwinPack.browserRouting`), never a
|
|
14
|
+
// hardcoded vendor table.
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
function vendorForPath(path, map) {
|
|
18
|
+
for (const [vendor, route] of Object.entries(map)) {
|
|
19
|
+
if (path.startsWith(route.apiPathPrefix))
|
|
20
|
+
return vendor;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function escapeRegExp(value) {
|
|
25
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Strip `loaderHost` (a full origin, e.g. 'https://api.stripe.com') from `text` so its
|
|
29
|
+
* calls become same-origin — but ONLY where it appears as a complete host, not as a
|
|
30
|
+
* blind substring. A plain `split(loaderHost).join('')` (the prior implementation) is
|
|
31
|
+
* NOT position-aware: it removes the substring wherever it occurs, including as a
|
|
32
|
+
* PREFIX of a longer, different host — e.g. 'https://api.stripe.com.evil.example/x'
|
|
33
|
+
* contains 'https://api.stripe.com' as a literal substring, and a naive strip corrupts
|
|
34
|
+
* it into '.evil.example/x' (the true host is stripe.com.evil.example, not stripe.com;
|
|
35
|
+
* same substring bug would also mangle a subdomain like sandbox.api.stripe.com if the
|
|
36
|
+
* scheme weren't part of the match). Position-aware fix: only strip a match that is NOT
|
|
37
|
+
* immediately followed by a domain-continuing character (letter/digit/dot/hyphen) —
|
|
38
|
+
* that character means the matched text is a prefix of a longer host, not the whole
|
|
39
|
+
* host, so it is left untouched.
|
|
40
|
+
*/
|
|
41
|
+
function stripLoaderHost(text, loaderHost) {
|
|
42
|
+
const pattern = new RegExp(`${escapeRegExp(loaderHost)}(?![A-Za-z0-9.-])`, 'g');
|
|
43
|
+
return text.replace(pattern, '');
|
|
44
|
+
}
|
|
45
|
+
function rewriteLocation(location, req, origins) {
|
|
46
|
+
if (!location || Array.isArray(location))
|
|
47
|
+
return location;
|
|
48
|
+
const host = req.headers.host;
|
|
49
|
+
if (!host)
|
|
50
|
+
return location;
|
|
51
|
+
const proxyOrigin = `http://${host}`;
|
|
52
|
+
for (const origin of origins) {
|
|
53
|
+
const normalized = origin.replace(/\/$/, '');
|
|
54
|
+
if (location === normalized)
|
|
55
|
+
return proxyOrigin;
|
|
56
|
+
if (location.startsWith(`${normalized}/`))
|
|
57
|
+
return `${proxyOrigin}${location.slice(normalized.length)}`;
|
|
58
|
+
}
|
|
59
|
+
return location;
|
|
60
|
+
}
|
|
61
|
+
function forwardTo(origin, req, res, rewrite, redirectOrigins = []) {
|
|
62
|
+
const target = new URL(req.url || '/', origin);
|
|
63
|
+
const headers = { ...req.headers, host: target.host };
|
|
64
|
+
delete headers['accept-encoding']; // so we can rewrite uncompressed bodies
|
|
65
|
+
const upstream = http.request(target, { method: req.method, headers }, (up) => {
|
|
66
|
+
if ((up.statusCode ?? 0) >= 300 && (up.statusCode ?? 0) < 400) {
|
|
67
|
+
const responseHeaders = { ...up.headers };
|
|
68
|
+
// `location` is single-valued, so rewriteLocation returns string|undefined here (never an array).
|
|
69
|
+
responseHeaders.location = rewriteLocation(responseHeaders.location, req, [origin, ...redirectOrigins]);
|
|
70
|
+
delete responseHeaders['transfer-encoding'];
|
|
71
|
+
delete responseHeaders['content-length'];
|
|
72
|
+
res.writeHead(up.statusCode || 302, responseHeaders);
|
|
73
|
+
res.end();
|
|
74
|
+
up.resume();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const chunks = [];
|
|
78
|
+
up.on('data', (c) => chunks.push(c));
|
|
79
|
+
up.on('error', () => res.destroyed || res.end());
|
|
80
|
+
up.on('end', () => {
|
|
81
|
+
const body = Buffer.concat(chunks);
|
|
82
|
+
const out = rewrite ? rewrite(body, up.headers) : body;
|
|
83
|
+
const responseHeaders = { ...up.headers };
|
|
84
|
+
delete responseHeaders['content-length'];
|
|
85
|
+
delete responseHeaders['content-encoding'];
|
|
86
|
+
delete responseHeaders['transfer-encoding'];
|
|
87
|
+
responseHeaders['content-length'] = String(Buffer.byteLength(out));
|
|
88
|
+
res.writeHead(up.statusCode || 502, responseHeaders);
|
|
89
|
+
res.end(out);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
upstream.on('error', (error) => {
|
|
93
|
+
if (res.headersSent) {
|
|
94
|
+
res.destroy(error);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
98
|
+
res.end(`twin proxy upstream error: ${error.message}`);
|
|
99
|
+
});
|
|
100
|
+
req.pipe(upstream);
|
|
101
|
+
}
|
|
102
|
+
export function createTwinProxy(opts) {
|
|
103
|
+
const map = {};
|
|
104
|
+
for (const [vendor, route] of Object.entries(opts.map))
|
|
105
|
+
map[vendor] = { ...route, origin: route.origin.replace(/\/$/, '') };
|
|
106
|
+
const redirectOrigins = (opts.redirectOrigins ?? []).map((origin) => origin.replace(/\/$/, ''));
|
|
107
|
+
const server = http.createServer((req, res) => {
|
|
108
|
+
const path = req.url || '/';
|
|
109
|
+
const vendor = vendorForPath(path, map);
|
|
110
|
+
if (vendor) {
|
|
111
|
+
forwardTo(map[vendor].origin, req, res, undefined, redirectOrigins); // browser vendor API call → the twin
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Otherwise pass through to the app, rewriting vendor loaders so their calls come back here.
|
|
115
|
+
forwardTo(opts.target, req, res, (body, headers) => {
|
|
116
|
+
const contentType = String(headers['content-type'] || '');
|
|
117
|
+
if (!contentType.includes('javascript') && !contentType.includes('html'))
|
|
118
|
+
return body;
|
|
119
|
+
let text = body.toString('utf8');
|
|
120
|
+
for (const route of Object.values(map)) {
|
|
121
|
+
if (route.loaderHost)
|
|
122
|
+
text = stripLoaderHost(text, route.loaderHost);
|
|
123
|
+
}
|
|
124
|
+
return Buffer.from(text, 'utf8');
|
|
125
|
+
}, redirectOrigins);
|
|
126
|
+
});
|
|
127
|
+
// WebSocket / HTTP upgrade passthrough — routed by the SAME vendor map as plain
|
|
128
|
+
// requests, so a vendor whose browser surface speaks WebSocket (a media SFU's
|
|
129
|
+
// signal path, a realtime API) reaches its twin instead of the app.
|
|
130
|
+
server.on('upgrade', (req, socket, head) => {
|
|
131
|
+
const upgradeVendor = vendorForPath(req.url || '/', map);
|
|
132
|
+
const target = new URL(req.url || '/', upgradeVendor ? map[upgradeVendor].origin : opts.target);
|
|
133
|
+
const upstream = net.connect(Number(target.port || 80), target.hostname, () => {
|
|
134
|
+
upstream.write(`${req.method} ${target.pathname}${target.search} HTTP/${req.httpVersion}\r\n`);
|
|
135
|
+
for (const [name, value] of Object.entries(req.headers))
|
|
136
|
+
upstream.write(`${name}: ${value}\r\n`);
|
|
137
|
+
upstream.write('\r\n');
|
|
138
|
+
upstream.write(head);
|
|
139
|
+
upstream.pipe(socket);
|
|
140
|
+
socket.pipe(upstream);
|
|
141
|
+
});
|
|
142
|
+
const killSocket = () => { if (!socket.destroyed)
|
|
143
|
+
socket.destroy(); };
|
|
144
|
+
const killUpstream = () => { if (!upstream.destroyed)
|
|
145
|
+
upstream.destroy(); };
|
|
146
|
+
upstream.on('error', killSocket);
|
|
147
|
+
socket.on('error', killUpstream);
|
|
148
|
+
socket.on('close', killUpstream);
|
|
149
|
+
upstream.on('close', killSocket);
|
|
150
|
+
});
|
|
151
|
+
server.listen(opts.port ?? 0, '127.0.0.1');
|
|
152
|
+
const address = server.address();
|
|
153
|
+
const port = address && typeof address === 'object' ? address.port : (opts.port ?? 0);
|
|
154
|
+
return { port, stop: () => server.close() };
|
|
155
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/** Shortest window a declaration may claim. A sub-second window is a counter reset, not a budget. */
|
|
2
|
+
export declare const MIN_RATE_BUDGET_WINDOW_MS = 1000;
|
|
3
|
+
/** Longest window a declaration may claim — beyond an hour the ledger stops being a rolling window. */
|
|
4
|
+
export declare const MAX_RATE_BUDGET_WINDOW_MS: number;
|
|
5
|
+
/**
|
|
6
|
+
* The most weighted units any pack may declare per window. Not a vendor fact — a blast radius cap,
|
|
7
|
+
* so a typo (`ceiling: 100000`) cannot silently disarm the guard for a whole vendor. Every real
|
|
8
|
+
* declaration is far under it.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_RATE_BUDGET_CEILING = 1000;
|
|
11
|
+
/**
|
|
12
|
+
* The sub-window every declaration's BURST is measured over, regardless of its own window length.
|
|
13
|
+
* A rolling window bounds total spend but says nothing about how fast it may be spent: a one-hour
|
|
14
|
+
* window with a 600-unit ceiling admits all 600 in a single millisecond, twenty times what the
|
|
15
|
+
* austere fallback allows in a minute, and neither `MAX_RATE_BUDGET_CEILING` nor
|
|
16
|
+
* `MAX_RATE_BUDGET_UNITS_PER_SECOND` (which evaluates to 0.167/s there) reaches it. So a declaration
|
|
17
|
+
* whose window is longer than this MUST also declare `burstCeiling`, and the kernel refuses against
|
|
18
|
+
* it independently. (§9 round 2, 2026-07-26: the property had been asserted in PROSE, by a test that
|
|
19
|
+
* accepted a declaration's own longer window as the justification for the burst that window created
|
|
20
|
+
* — circular, and it waved through a hostile 1h/1000 declaration.)
|
|
21
|
+
*/
|
|
22
|
+
export declare const RATE_BUDGET_BURST_WINDOW_MS = 60000;
|
|
23
|
+
/**
|
|
24
|
+
* The most weighted units per SECOND any declaration may imply (`ceiling / (windowMs/1000)`).
|
|
25
|
+
* The per-dimension caps above do not bound the RATE — `ceiling: 1000, windowMs: 1000` satisfies
|
|
26
|
+
* both and still buys 1000 units a second, which is not a budget. This is the bound that makes the
|
|
27
|
+
* "blast radius cap" claim true. Every real declaration is far under it — the packs that declare one
|
|
28
|
+
* today sit between roughly 0.3 and 5 units/second. (Named examples were removed here once more than
|
|
29
|
+
* two packs declared: a list in the kernel goes stale, and the kernel must not know its vendors.)
|
|
30
|
+
*/
|
|
31
|
+
export declare const MAX_RATE_BUDGET_UNITS_PER_SECOND = 20;
|
|
32
|
+
/**
|
|
33
|
+
* The cheapest a single call may be priced. NOTHING IS FREE: a zero-weight call would let an
|
|
34
|
+
* unbounded loop through, and the ledger's "entries can never outnumber the ceiling" invariant
|
|
35
|
+
* (which bounds `load()`) depends on every entry costing at least one unit.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIN_RATE_BUDGET_WEIGHT = 1;
|
|
38
|
+
/**
|
|
39
|
+
* One pricing rule. `match` is a RegExp SOURCE string tested against the *call key* — whatever
|
|
40
|
+
* string the pack's guarded client uses to name the call it is about to make (a REST path for an
|
|
41
|
+
* HTTP pack, a dotted SDK method name for an SDK-shaped pack). First matching rule wins, so order
|
|
42
|
+
* matters; a call matching nothing is priced at `defaultWeight`.
|
|
43
|
+
*
|
|
44
|
+
* `whenQueryPresent` / `whenQueryAbsent` let one path split by request shape (e.g. a cheap probe
|
|
45
|
+
* distinguished from a full fetch only by a query parameter) without needing a vendor-specific
|
|
46
|
+
* predicate FUNCTION in the declaration — declarations stay pure, inspectable data.
|
|
47
|
+
*/
|
|
48
|
+
export type RateBudgetWeightRule = {
|
|
49
|
+
/** RegExp source, tested against the call key. */
|
|
50
|
+
match: string;
|
|
51
|
+
/** Weighted cost when this rule matches. At least `MIN_RATE_BUDGET_WEIGHT`. */
|
|
52
|
+
weight: number;
|
|
53
|
+
/** Only match when EVERY named query key is present. */
|
|
54
|
+
whenQueryPresent?: string[];
|
|
55
|
+
/** Only match when EVERY named query key is absent. */
|
|
56
|
+
whenQueryAbsent?: string[];
|
|
57
|
+
};
|
|
58
|
+
/** What a pack declares. Pure data — no functions, no vendor code in the kernel. */
|
|
59
|
+
export type RateBudgetDeclaration = {
|
|
60
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
61
|
+
windowMs: number;
|
|
62
|
+
/** Weighted units allowed inside one window. */
|
|
63
|
+
ceiling: number;
|
|
64
|
+
/**
|
|
65
|
+
* Weighted units allowed inside any 60-SECOND sub-window, enforced independently of `ceiling`.
|
|
66
|
+
*
|
|
67
|
+
* REQUIRED when `windowMs` is longer than 60s, and refused as meaningless when it is not (a
|
|
68
|
+
* window that is already a minute has its ceiling as its burst). This is what stops a long window
|
|
69
|
+
* from buying an enormous instantaneous burst as a side effect of bounding a long-horizon limit —
|
|
70
|
+
* the thing github and linear legitimately need, and the thing a careless declaration would get by
|
|
71
|
+
* accident. Must be ≤ `ceiling`; like every other axis it may only ever be TIGHTENED.
|
|
72
|
+
*/
|
|
73
|
+
burstCeiling?: number;
|
|
74
|
+
/** Cost of a call no rule prices. Never zero — an unknown endpoint must never be free. */
|
|
75
|
+
defaultWeight: number;
|
|
76
|
+
/**
|
|
77
|
+
* Seconds. A `Retry-After` above this is not something to sleep off — it means the credential is
|
|
78
|
+
* in real trouble, so it FAILS LOUDLY rather than scheduling a long sleep.
|
|
79
|
+
*/
|
|
80
|
+
maxRetryAfterSeconds?: number;
|
|
81
|
+
/** Ordered pricing rules; first match wins. */
|
|
82
|
+
rules?: RateBudgetWeightRule[];
|
|
83
|
+
/**
|
|
84
|
+
* WHY these numbers — the vendor's documented (or observed) limits, and the reasoning that
|
|
85
|
+
* turned them into this ceiling. Required: a budget nobody can explain is a budget nobody can
|
|
86
|
+
* review, and the ceiling is the one number that can cost days if it is wrong.
|
|
87
|
+
*/
|
|
88
|
+
reason: string;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* A declaration bound to the vendor it governs, with every pattern already compiled. The `RegExp`s
|
|
92
|
+
* are built ONCE here rather than per call: `priceCall` runs in front of every vendor request, and
|
|
93
|
+
* recompiling a pattern per call is both wasted work and a needless amplifier for a pathological
|
|
94
|
+
* pattern. (Declarations are repo source, not user input — this is a performance fix, not a
|
|
95
|
+
* sanitizer.)
|
|
96
|
+
*/
|
|
97
|
+
export type RateBudgetPolicy = Required<Omit<RateBudgetDeclaration, 'rules'>> & {
|
|
98
|
+
vendor: string;
|
|
99
|
+
rules: RateBudgetWeightRule[];
|
|
100
|
+
/** `compiled[i]` is `rules[i].match`, compiled. Same length, same order. */
|
|
101
|
+
compiled: RegExp[];
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* The allowance a vendor gets when NOBODY declared one.
|
|
105
|
+
*
|
|
106
|
+
* 60 weighted units per 60s at a default weight of 2 — 30 calls a minute, one every two seconds.
|
|
107
|
+
* That is comfortably above any human- or agent-driven explicit pull (a few dozen calls), and far
|
|
108
|
+
* below the shape that causes lockouts (a loop emitting hundreds of calls a minute). It is
|
|
109
|
+
* deliberately NOT generous: an undeclared vendor is one nobody has thought about, and the safe
|
|
110
|
+
* assumption about an unexamined vendor is that its limits are tight.
|
|
111
|
+
*
|
|
112
|
+
* There is no opt-out. A pack that genuinely needs more says so, in its declaration, with a reason.
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ HONESTLY: this is tighter than a real declaration in CALL COUNT, but it is NOT uniformly
|
|
115
|
+
* tighter, because it prices every call the same. A vendor whose pack prices one endpoint high (a
|
|
116
|
+
* render at 20, a recursive walk at 2) gets that endpoint priced at 2 here — cheaper, not dearer.
|
|
117
|
+
* That is why `RateBudget` reads the policy LIVE rather than snapshotting it at construction, and
|
|
118
|
+
* why a pack's binding (its `XBudget` subclass, whose module declares on import) is the only
|
|
119
|
+
* construction path you should use. (§9 finding, 2026-07-26.)
|
|
120
|
+
*/
|
|
121
|
+
export declare const DEFAULT_RATE_BUDGET: RateBudgetDeclaration;
|
|
122
|
+
/**
|
|
123
|
+
* DECLARE a vendor's budget. Idempotent for an identical declaration; a REPEAT declaration that
|
|
124
|
+
* would WIDEN the allowance (higher ceiling, shorter window, cheaper default, longer tolerated
|
|
125
|
+
* back-off) is refused. Without that rule, "declare a bigger budget just before constructing the
|
|
126
|
+
* client" would be the clean way around every guarantee below.
|
|
127
|
+
*/
|
|
128
|
+
export declare function declareRateBudget(vendor: string, decl: RateBudgetDeclaration): RateBudgetPolicy;
|
|
129
|
+
/** The declared policy for a vendor, or the conservative fallback bound to that vendor. */
|
|
130
|
+
export declare function rateBudgetPolicy(vendor: string): RateBudgetPolicy;
|
|
131
|
+
/** Has anyone declared a budget for this vendor? (`false` ⇒ it runs on `DEFAULT_RATE_BUDGET`.) */
|
|
132
|
+
export declare function hasRateBudgetDeclaration(vendor: string): boolean;
|
|
133
|
+
/** Every declared policy, sorted by vendor (deterministic) — for operator/audit listings. */
|
|
134
|
+
export declare function listRateBudgets(): RateBudgetPolicy[];
|
|
135
|
+
/**
|
|
136
|
+
* Price one call against a policy. Keyed off the request the client is ABOUT to make, so an
|
|
137
|
+
* unclassified key still costs `defaultWeight` — an unknown endpoint must never be free.
|
|
138
|
+
*/
|
|
139
|
+
export declare function priceCall(policy: RateBudgetPolicy, key: string, query?: Record<string, string>): number;
|
|
140
|
+
/** Price one call for a vendor, using its declared policy (or the fallback). */
|
|
141
|
+
export declare function rateBudgetWeight(vendor: string, key: string, query?: Record<string, string>): number;
|
|
142
|
+
export type RateBudgetErrorKind =
|
|
143
|
+
/** The rolling-window ceiling would be exceeded by this call. */
|
|
144
|
+
'ceiling'
|
|
145
|
+
/** A prior response told us to back off and the cooldown has not elapsed. */
|
|
146
|
+
| 'cooldown'
|
|
147
|
+
/** The ledger could not be read/parsed — treated as a full window (see below). */
|
|
148
|
+
| 'ledger-unreadable'
|
|
149
|
+
/** The ledger could not be persisted, so spend cannot be tracked. Refuse rather than fly blind. */
|
|
150
|
+
| 'ledger-unwritable'
|
|
151
|
+
/** The injected clock is not a millisecond epoch — pruning would be nonsense, so refuse. */
|
|
152
|
+
| 'clock-invalid'
|
|
153
|
+
/** The 60-second BURST bound would be exceeded, even though the long window has room. */
|
|
154
|
+
| 'burst'
|
|
155
|
+
/** A `Retry-After` beyond the cap — fail loudly instead of sleeping it off. */
|
|
156
|
+
| 'retry-after-too-large';
|
|
157
|
+
/**
|
|
158
|
+
* Thrown INSTEAD OF calling the vendor. Never swallowed, never converted to a retry: the caller is
|
|
159
|
+
* meant to stop. Carries how long to wait so a caller can schedule rather than spin.
|
|
160
|
+
*/
|
|
161
|
+
export declare class RateBudgetError extends Error {
|
|
162
|
+
readonly kind: RateBudgetErrorKind;
|
|
163
|
+
/** The vendor whose budget refused — so a multi-vendor caller can tell which one stopped. */
|
|
164
|
+
readonly vendor: string;
|
|
165
|
+
/** Milliseconds until the budget could plausibly admit this call again. */
|
|
166
|
+
readonly retryAfterMs: number;
|
|
167
|
+
constructor(kind: RateBudgetErrorKind, vendor: string, message: string, retryAfterMs: number);
|
|
168
|
+
}
|
|
169
|
+
/** A charge already committed to the ledger by `checkBudget`, settled later by `recordCall`. */
|
|
170
|
+
export type RateBudgetReservation = {
|
|
171
|
+
id: string;
|
|
172
|
+
weight: number;
|
|
173
|
+
at: number;
|
|
174
|
+
};
|
|
175
|
+
export type RateBudgetSnapshot = {
|
|
176
|
+
vendor: string;
|
|
177
|
+
spend: number;
|
|
178
|
+
/** Weighted spend in the last 60s — the quantity `burstCeiling` bounds. */
|
|
179
|
+
burstSpend: number;
|
|
180
|
+
ceiling: number;
|
|
181
|
+
burstCeiling: number;
|
|
182
|
+
windowMs: number;
|
|
183
|
+
cooldownUntil: number;
|
|
184
|
+
entries: number;
|
|
185
|
+
path: string;
|
|
186
|
+
};
|
|
187
|
+
export type RateBudgetOptions = {
|
|
188
|
+
/** The vendor whose allowance this is. Required — the ledger is keyed by it. */
|
|
189
|
+
vendor: string;
|
|
190
|
+
/** Ledger file path. Injectable so tests never touch the real one. */
|
|
191
|
+
path?: string;
|
|
192
|
+
/**
|
|
193
|
+
* Scope the ledger to ONE project root instead of the home dir. Note that two roots sharing a
|
|
194
|
+
* credential then get two allowances — prefer the token-keyed default unless you specifically
|
|
195
|
+
* want per-world accounting.
|
|
196
|
+
*/
|
|
197
|
+
root?: string;
|
|
198
|
+
/** The credential whose quota this is. Only its HASH is used, to name the default ledger file. */
|
|
199
|
+
token?: string;
|
|
200
|
+
/** Injected clock (ms since epoch). Tests advance it by hand; production passes nothing. */
|
|
201
|
+
now?: () => number;
|
|
202
|
+
/** Window in ms. Can only be made LONGER than the declared one (shorter would widen spend). */
|
|
203
|
+
windowMs?: number;
|
|
204
|
+
/** Weighted ceiling. Can only be made LOWER than the declared one. */
|
|
205
|
+
ceiling?: number;
|
|
206
|
+
/** Seconds; a `Retry-After` above this fails loudly. Can only be made SMALLER. */
|
|
207
|
+
maxRetryAfterSeconds?: number;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Where the ledger lives. Vendors rate-limit PER CREDENTIAL, so the default is keyed by VENDOR and
|
|
211
|
+
* a hash of the token, and lives in the user's home directory — deliberately NOT under the project
|
|
212
|
+
* state root, because a cwd-scoped ledger hands the same credential a fresh allowance in every
|
|
213
|
+
* checkout, worktree and CI matrix leg. Pass `root` to opt into world-scoped accounting anyway.
|
|
214
|
+
* Only the hash is ever written to disk; the token itself never is.
|
|
215
|
+
*
|
|
216
|
+
* Two vendors never collide (the vendor is a path segment) and two credentials for one vendor never
|
|
217
|
+
* collide (the hash is the filename).
|
|
218
|
+
*/
|
|
219
|
+
export declare function rateBudgetPath(opts: {
|
|
220
|
+
vendor: string;
|
|
221
|
+
root?: string;
|
|
222
|
+
token?: string;
|
|
223
|
+
}): string;
|
|
224
|
+
/**
|
|
225
|
+
* The guard. One instance per ledger path; cheap to construct, so callers may build one per client.
|
|
226
|
+
* All state lives in the FILE, never in the instance — that is what makes a second instance (or a
|
|
227
|
+
* second process) see the first one's spend. There is no memoization anywhere, by design.
|
|
228
|
+
*/
|
|
229
|
+
/**
|
|
230
|
+
* Refuse a "budget" whose GUARD HAS BEEN REPLACED, and hand back the same object when it is intact.
|
|
231
|
+
*
|
|
232
|
+
* Every pack's guarded factory takes an optional `budget` so several clients can share one ledger,
|
|
233
|
+
* and each one used to validate it with `budget instanceof XBudget`. §9 (2026-07-26) showed that is
|
|
234
|
+
* not a check at all — the subclass is one line, and `checkBudget` is an ordinary prototype method:
|
|
235
|
+
*
|
|
236
|
+
* class Loose extends GithubBudget { checkBudget(w) { return { id: 'x', weight: w, at: 0 }; } }
|
|
237
|
+
* liveGithubExecute(pat, undefined, { budget: new Loose() }) // 5,000 requests, zero refusals
|
|
238
|
+
*
|
|
239
|
+
* `instanceof` said yes; so would a `Proxy` over a genuine budget whose `get` returns a no-op. Both
|
|
240
|
+
* are exactly the "one clean way around the guard" those factories claimed to have closed.
|
|
241
|
+
*
|
|
242
|
+
* So the check is on the METHODS, not the prototype chain: the three functions a guarded factory
|
|
243
|
+
* actually calls must be the very functions `RateBudget.prototype` defines. An own-property
|
|
244
|
+
* override, a subclass override and a `get`-trapping proxy all fail that identity comparison. The
|
|
245
|
+
* exact-prototype test additionally pins the vendor binding, so another vendor's budget subclass
|
|
246
|
+
* cannot stand in for this one's.
|
|
247
|
+
*
|
|
248
|
+
* HONEST LIMIT, since the point of this function is not over-claiming: a determined caller can still
|
|
249
|
+
* defeat it (a proxy that returns the real function to the probe and a no-op afterwards, a
|
|
250
|
+
* `Object.defineProperty` on `RateBudget.prototype` itself, an injected clock, a ledger path in a
|
|
251
|
+
* temp dir). Nothing reachable from inside this process can stop that, and the module header says so
|
|
252
|
+
* up front. What this closes is the ACCIDENT and the one-liner — the shapes that actually happen.
|
|
253
|
+
*/
|
|
254
|
+
export declare function assertBudgetGuardIntact<T extends RateBudget>(budget: unknown, expected: new (...args: never[]) => T, where: string): T;
|
|
255
|
+
export declare class RateBudget {
|
|
256
|
+
readonly vendor: string;
|
|
257
|
+
readonly path: string;
|
|
258
|
+
private readonly clock;
|
|
259
|
+
private readonly askedWindowMs;
|
|
260
|
+
private readonly askedCeiling;
|
|
261
|
+
private readonly askedMaxRetryAfterSeconds;
|
|
262
|
+
constructor(opts: RateBudgetOptions);
|
|
263
|
+
/**
|
|
264
|
+
* The effective policy, read LIVE from the declaration registry on every use rather than captured
|
|
265
|
+
* at construction.
|
|
266
|
+
*
|
|
267
|
+
* That matters because of an ordering hazard §9 found (2026-07-26): a budget built for a vendor
|
|
268
|
+
* whose declaration module has not been imported yet falls back to `DEFAULT_RATE_BUDGET`, and the
|
|
269
|
+
* fallback is NOT uniformly tighter than a pack's own numbers — its ceiling is lower, but it
|
|
270
|
+
* prices every call at `defaultWeight`, which for a vendor with an expensive endpoint (a render,
|
|
271
|
+
* a recursive walk) is far CHEAPER than the pack's declared weight for it. Capturing that
|
|
272
|
+
* snapshot would freeze the wrong prices for the instance's whole life. Reading live means the
|
|
273
|
+
* pack's real weights take effect the moment its declaration lands.
|
|
274
|
+
*
|
|
275
|
+
* A caller cannot hand in a policy object — it comes only from the registry, and the registry
|
|
276
|
+
* refuses a widening re-declaration — so "construct a client around a huge made-up budget" is
|
|
277
|
+
* still not a move.
|
|
278
|
+
*/
|
|
279
|
+
get policy(): RateBudgetPolicy;
|
|
280
|
+
/** Window in ms. The caller may only LENGTHEN it; a shorter one would widen spend. */
|
|
281
|
+
get windowMs(): number;
|
|
282
|
+
/** Weighted ceiling. The caller may only LOWER it. */
|
|
283
|
+
get ceiling(): number;
|
|
284
|
+
/**
|
|
285
|
+
* Weighted units allowed in any 60-SECOND sub-window, enforced independently of `ceiling`. For a
|
|
286
|
+
* pack whose window already IS 60s this equals the ceiling and the second check is a no-op; for a
|
|
287
|
+
* longer window it is the bound that stops the whole window being spent in an instant. Never
|
|
288
|
+
* looser than `ceiling` (a lowered ceiling lowers it too).
|
|
289
|
+
*/
|
|
290
|
+
get burstCeiling(): number;
|
|
291
|
+
/** Tolerated back-off, in seconds. The caller may only SHRINK it. */
|
|
292
|
+
get maxRetryAfterSeconds(): number;
|
|
293
|
+
/** Price a call against THIS vendor's declared rules, as they stand right now. */
|
|
294
|
+
weightFor(key: string, query?: Record<string, string>): number;
|
|
295
|
+
/**
|
|
296
|
+
* Read the clock and sanity-check it. Catches the ACCIDENT class that silently disables pruning:
|
|
297
|
+
* a clock in seconds, a `performance.now()`-style monotonic clock, or NaN. Entry timestamps
|
|
298
|
+
* written under one epoch prune instantly under another, which would void the whole budget
|
|
299
|
+
* without anyone noticing. (A deliberate fast-forwarding clock is NOT stopped by this — nothing
|
|
300
|
+
* can stop that while the seam exists; it is disclosed at the top instead of pretended away.)
|
|
301
|
+
*/
|
|
302
|
+
private now;
|
|
303
|
+
private lockPath;
|
|
304
|
+
/** Acquire the exclusive lock, run `fn`, always release. Bounded wait, dead locks broken. */
|
|
305
|
+
private withLock;
|
|
306
|
+
/** Remove a lock whose owner is provably gone. Never removes one that might still be live. */
|
|
307
|
+
private breakDeadLock;
|
|
308
|
+
/**
|
|
309
|
+
* Read the ledger. A MISSING file is the legitimate first-run case (no state ⇒ no spend). A file
|
|
310
|
+
* that exists but cannot be read or parsed is the dangerous case, and it FAILS CLOSED: we cannot
|
|
311
|
+
* know what was already spent, so the window is treated as fully consumed. A fresh ledger
|
|
312
|
+
* carrying a one-window cooldown is written in its place, so the guard self-heals after the
|
|
313
|
+
* window instead of needing a human to delete a file.
|
|
314
|
+
*/
|
|
315
|
+
private load;
|
|
316
|
+
/** Replace a corrupt ledger with a valid one that is already in cooldown, and refuse this call. */
|
|
317
|
+
private quarantine;
|
|
318
|
+
/** Atomic replace: write a temp file, then rename over the target. No partial ledger is visible. */
|
|
319
|
+
private save;
|
|
320
|
+
private prune;
|
|
321
|
+
private static spendOf;
|
|
322
|
+
/**
|
|
323
|
+
* THE GATE. Prunes the window, refuses if we are in cooldown or if `weight` would breach the
|
|
324
|
+
* ceiling, and otherwise RESERVES the weight (committing it to the ledger) before returning.
|
|
325
|
+
* When it throws, the caller MUST NOT call the vendor — that is the entire contract.
|
|
326
|
+
*/
|
|
327
|
+
checkBudget(weight: number): RateBudgetReservation;
|
|
328
|
+
/**
|
|
329
|
+
* Record the OUTCOME of a call. Settles the reservation `checkBudget` made (never charging
|
|
330
|
+
* twice); when called without one — a direct caller, or a call made outside the guarded path —
|
|
331
|
+
* it appends the charge itself, so spend is recorded either way.
|
|
332
|
+
*
|
|
333
|
+
* If the response carried `Retry-After`, a 429, or an `X-…-RateLimit-Remaining: 0` exhaustion
|
|
334
|
+
* signal, a COOLDOWN is persisted: every later call then fails fast in `checkBudget` WITHOUT
|
|
335
|
+
* touching the vendor. A `Retry-After` beyond the cap additionally throws — that is not something
|
|
336
|
+
* to sleep off.
|
|
337
|
+
*/
|
|
338
|
+
recordCall(weight: number, headers?: Record<string, string>, opts?: {
|
|
339
|
+
status?: number;
|
|
340
|
+
reservation?: RateBudgetReservation | null;
|
|
341
|
+
}): void;
|
|
342
|
+
/** Current weighted spend in the window. Reads the FILE, so it sees other processes' spend. */
|
|
343
|
+
spend(): number;
|
|
344
|
+
/** Everything an operator (or a test) wants to see, in one read. */
|
|
345
|
+
snapshot(): RateBudgetSnapshot;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* A `*-ratelimit-reset` value → seconds to wait, or `null` when it cannot be trusted.
|
|
349
|
+
*
|
|
350
|
+
* Vendors disagree about the UNIT, and getting it wrong is not a rounding error — it is a multi-day
|
|
351
|
+
* self-inflicted outage. Three shapes are seen in practice, distinguished by MAGNITUDE because none
|
|
352
|
+
* of them is labelled:
|
|
353
|
+
* • a small number → a delta, already in seconds (`x-ratelimit-reset: 45`)
|
|
354
|
+
* • ~1e9-1e10 → an absolute epoch in SECONDS (GitHub)
|
|
355
|
+
* • ~1e12-1e13 → an absolute epoch in MILLISECONDS (Linear documents exactly this)
|
|
356
|
+
*
|
|
357
|
+
* Reading a millisecond epoch as a second epoch yields a back-off of roughly fifty thousand YEARS,
|
|
358
|
+
* which the cooldown cap then clamps to a week — so ONE exhausted response would lock the credential
|
|
359
|
+
* out client-side for seven days and need a human to delete the ledger file. That is the module
|
|
360
|
+
* inflicting the very outage it exists to prevent. (§9 finding, 2026-07-26.)
|
|
361
|
+
*
|
|
362
|
+
* Anything that still resolves to more than a day is treated as UNPARSEABLE rather than obeyed:
|
|
363
|
+
* no real rate limit resets a day out, so such a value means the unit guess was wrong, and the
|
|
364
|
+
* honest answer is `null` (the caller falls back to a one-window cooldown) rather than a number
|
|
365
|
+
* nobody can justify. Non-finite and negative values are likewise `null`.
|
|
366
|
+
*/
|
|
367
|
+
export declare function resetToSeconds(reset: number, nowMs?: number): number | null;
|