@volter/world-core 3.0.64 → 3.0.66
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/capability-decision.cjs +273 -0
- package/capability-decision.d.cts +60 -0
- package/dist/capability-decision.cjs +273 -0
- package/dist/capability-decision.d.cts +60 -0
- package/dist/inject.cjs +163 -100
- package/dist/network-policy.cjs +19 -15
- package/dist/network-policy.d.cts +1 -1
- package/inject.cjs +163 -100
- package/network-policy.cjs +19 -15
- package/network-policy.d.cts +1 -1
- package/package.json +7 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { WorldNetworkPolicy } from './network-policy.cjs';
|
|
2
|
+
|
|
3
|
+
export type CapabilityName = 'network.egress' | 'network.ownLoopback' | 'network.services' | 'network.host' | 'files.persist';
|
|
4
|
+
export type RefusalSide = 'declaration' | 'grant' | 'transport' | 'twin';
|
|
5
|
+
export type DeclaredBy = { readonly capability: CapabilityName; readonly file: string; readonly line: number };
|
|
6
|
+
|
|
7
|
+
/** What a guest declares (an image's runtime binding `capabilities`, a browser program's hosts). */
|
|
8
|
+
export interface CapabilityDeclaration {
|
|
9
|
+
readonly network?: {
|
|
10
|
+
readonly egress?: '*' | readonly string[];
|
|
11
|
+
readonly ownLoopback?: boolean;
|
|
12
|
+
readonly listen?: readonly number[];
|
|
13
|
+
readonly services?: '*' | readonly string[];
|
|
14
|
+
readonly host?: boolean;
|
|
15
|
+
};
|
|
16
|
+
readonly files?: { readonly persist?: readonly string[] };
|
|
17
|
+
readonly derivedFrom?: readonly DeclaredBy[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorldGrant { readonly policy?: WorldNetworkPolicy; readonly strict?: boolean; readonly unreadable?: string }
|
|
21
|
+
export interface WorldEndpoint { readonly host: string; readonly port: number; readonly kind: 'door' | 'service'; readonly name?: string }
|
|
22
|
+
export interface CapabilityFacts {
|
|
23
|
+
readonly twin?: { readonly vendor: string; readonly origin: string } | null;
|
|
24
|
+
readonly twinnedHostVendors?: readonly string[];
|
|
25
|
+
readonly twinOrigin?: boolean;
|
|
26
|
+
readonly invalid?: boolean;
|
|
27
|
+
readonly guestProxy?: URL | null;
|
|
28
|
+
readonly endpoint?: Pick<WorldEndpoint, 'kind' | 'name'> | null;
|
|
29
|
+
readonly ownListener?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface CapabilityContext { readonly run?: { readonly guest?: boolean }; readonly declaration?: CapabilityDeclaration; readonly grant?: WorldGrant; readonly facts?: CapabilityFacts }
|
|
32
|
+
|
|
33
|
+
export type CapabilityRequest =
|
|
34
|
+
| { readonly kind: 'egress'; readonly url: string | URL | null; readonly method?: string; readonly transport?: 'http' | 'upgrade' | 'http2' | 'dispatch' | 'socket' | 'datagram'; readonly port?: number }
|
|
35
|
+
| { readonly kind: 'loopback'; readonly host: string; readonly port?: number; readonly transport?: 'tcp' | 'udp' }
|
|
36
|
+
| { readonly kind: 'persist'; readonly path: string };
|
|
37
|
+
|
|
38
|
+
export type CapabilityAnswer =
|
|
39
|
+
| { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' }
|
|
40
|
+
| { readonly outcome: 'twin'; readonly capability: CapabilityName; readonly target: string; readonly twin: { readonly vendor: string; readonly origin: string } }
|
|
41
|
+
| { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy };
|
|
42
|
+
|
|
43
|
+
export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy }
|
|
44
|
+
|
|
45
|
+
export interface CapabilitySet {
|
|
46
|
+
readonly network: { readonly egress: 'none' | 'proxy'; readonly loopback: 'none' | 'own'; readonly listen: readonly number[]; readonly endpoints: readonly WorldEndpoint[] };
|
|
47
|
+
readonly files: { readonly persist: readonly string[] };
|
|
48
|
+
readonly refusals: readonly Extract<CapabilityAnswer, { outcome: 'refuse' }>[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const OPEN_DECLARATION: CapabilityDeclaration;
|
|
52
|
+
export const EMPTY_DECLARATION: CapabilityDeclaration;
|
|
53
|
+
export const WORLD_DECLARATION_ENV: 'VOLTER_WORLD_DECLARATION';
|
|
54
|
+
export function worldDeclarationFromEnv(env: Readonly<Record<string, string | undefined>>): CapabilityDeclaration | undefined;
|
|
55
|
+
export function sandboxRefusal(target: string): Extract<CapabilityAnswer, { outcome: 'refuse' }>;
|
|
56
|
+
export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
|
|
57
|
+
export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
|
|
58
|
+
export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
|
|
59
|
+
export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
|
|
60
|
+
export function refusalSide(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): string;
|
package/dist/inject.cjs
CHANGED
|
@@ -45,7 +45,8 @@ const { Readable } = require('stream');
|
|
|
45
45
|
const Module = require('module');
|
|
46
46
|
const { urlToHttpOptions } = require('node:url');
|
|
47
47
|
const claimsRefreshChannel = require('node:diagnostics_channel').channel('volter.inject.claims-refresh');
|
|
48
|
-
const {
|
|
48
|
+
const { worldNetworkPolicyFromEnv, isWorldInternalHost } = require('./network-policy.cjs');
|
|
49
|
+
const { OPEN_DECLARATION, worldDeclarationFromEnv, decideCapability, sandboxRefusal, refusalRecord, refusalSide } = require('./capability-decision.cjs');
|
|
49
50
|
const { createPublicKey, createVerify } = require('crypto');
|
|
50
51
|
|
|
51
52
|
// the vendor host table (VENDOR_HOSTS: a hostname, and a path where two vendors share a host, to the vendor whose twin
|
|
@@ -325,17 +326,50 @@ function strictEgressEnabled() {
|
|
|
325
326
|
return process.env.VOLTER_TWIN_STRICT_EGRESS === '1' || process.env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
326
327
|
}
|
|
327
328
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
// THE WORLD'S DECISION (docs/contributing/architecture.md, "Capability decisions"): every seam below asks
|
|
330
|
+
// capability-decision.cjs and enforces its answer. This process is a host process, whose declaration is the open one; a
|
|
331
|
+
// local light guest, whose HTTP leaves through its guest listener; or a process of an image run, which carries its
|
|
332
|
+
// image's declaration (VOLTER_WORLD_DECLARATION) and is confined from the host's network. What the World grants is its
|
|
333
|
+
// published policy. The seams supply what they observe: the twin route, the vendors sharing a host, the World's doors.
|
|
334
|
+
function worldGrant() {
|
|
335
|
+
let policy; let unreadable;
|
|
336
|
+
try { policy = worldNetworkPolicyFromEnv(process.env); } catch (error) { unreadable = error.message; }
|
|
337
|
+
return { policy, strict: strictEgressEnabled(), unreadable };
|
|
338
|
+
}
|
|
339
|
+
/** This process's run: its declaration, and whether it is confined from the host's network. */
|
|
340
|
+
function worldRun() {
|
|
341
|
+
const declared = worldDeclarationFromEnv(process.env);
|
|
342
|
+
return declared ? { guest: true, declaration: declared } : { guest: !!localGuestProxy(), declaration: OPEN_DECLARATION };
|
|
343
|
+
}
|
|
344
|
+
function decide(url, transport, facts = {}, port) {
|
|
345
|
+
const { guest, declaration } = worldRun();
|
|
346
|
+
return decideCapability({ kind: 'egress', url, transport, port },
|
|
347
|
+
{ run: { guest }, declaration, grant: worldGrant(), facts: { guestProxy: localGuestProxy(), ...facts } });
|
|
348
|
+
}
|
|
349
|
+
/** What an HTTP seam observes about `url`. A local guest's request to one of the World's doors, or not over HTTP(S), is
|
|
350
|
+
* not mediated by its guest listener. */
|
|
351
|
+
function httpFacts(url, pathname) {
|
|
352
|
+
if (!url) return {};
|
|
353
|
+
const guestProxy = localGuestProxy();
|
|
354
|
+
const proxy = worldProxy();
|
|
355
|
+
return {
|
|
356
|
+
twin: resolveTwin(url.hostname, MAP, pathname === undefined ? url.pathname : pathname),
|
|
357
|
+
twinnedHostVendors: twinnedHostVendors(url.hostname, MAP),
|
|
358
|
+
...(guestProxy && !/^https?:$/.test(url.protocol) ? { guestProxy: null } : {}),
|
|
359
|
+
endpoint: worldRun().guest && (addressedToWorld(url) || explicitTwinEndpoint(url) || (proxy && url.origin === proxy.origin)) ? { kind: 'door' } : null,
|
|
360
|
+
};
|
|
333
361
|
}
|
|
334
|
-
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
362
|
+
/** A refusal as an Error: the seam's own message, the side that refused, and the World's refusal record. */
|
|
363
|
+
function refusalError(answer, message) {
|
|
364
|
+
return Object.assign(new Error(`${message} (${refusalSide(answer)})`), { worldRefusal: refusalRecord(answer) });
|
|
365
|
+
}
|
|
366
|
+
/** The World's listing refused it (sealed, unlisted, an unreadable policy). */
|
|
367
|
+
const LISTING_RULES = new Set(['sealed', 'not-granted', 'policy-unreadable']);
|
|
368
|
+
/** The message an HTTP seam (`request`, `fetch`) gives a refusal. */
|
|
369
|
+
function httpRefusalMessage(answer, url, seam) {
|
|
370
|
+
if (answer.rule === 'unclaimed-path') return unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP);
|
|
371
|
+
if (LISTING_RULES.has(answer.rule)) return `[twin-inject] blocked untwinned external ${seam} to ${url.hostname}`;
|
|
372
|
+
return `[twin-inject] ${answer.reason}`;
|
|
339
373
|
}
|
|
340
374
|
|
|
341
375
|
// --- untwinned-host TLS failure naming (local mode) -----------------------------------------
|
|
@@ -462,6 +496,10 @@ function worldBase() {
|
|
|
462
496
|
}
|
|
463
497
|
// The local Substrate agent keeps host control routing; its product commands select the
|
|
464
498
|
// existing guest listener. Route original destinations through that listener before rewriting.
|
|
499
|
+
/** The World proxy this process is pointed at, or null. */
|
|
500
|
+
function worldProxy() {
|
|
501
|
+
try { return new URL(process.env.VOLTER_WORLD_PROXY || ''); } catch { return null; }
|
|
502
|
+
}
|
|
465
503
|
function localGuestProxy() {
|
|
466
504
|
const guest = process.env.VOLTER_WORLD_GUEST_PROXY;
|
|
467
505
|
if (!guest || process.env.VOLTER_WORLD_PROXY !== guest) return null;
|
|
@@ -475,10 +513,6 @@ function explicitTwinEndpoint(url) {
|
|
|
475
513
|
} catch { return false; }
|
|
476
514
|
});
|
|
477
515
|
}
|
|
478
|
-
function mediateLocalGuest(url) {
|
|
479
|
-
const proxy = localGuestProxy();
|
|
480
|
-
return proxy && url && /^https?:$/.test(url.protocol) && !addressedToWorld(url) && !explicitTwinEndpoint(url) && url.origin !== proxy.origin ? proxy : null;
|
|
481
|
-
}
|
|
482
516
|
function addressedToWorld(url) {
|
|
483
517
|
const base = worldBase();
|
|
484
518
|
if (!base || !url || !base.path || url.origin.replace(/^ws/, 'http') !== base.origin) return false;
|
|
@@ -545,30 +579,30 @@ function keyedView(dispatcher) {
|
|
|
545
579
|
if (name === 'dispatch') {
|
|
546
580
|
return function keyedDispatch(opts, handler) {
|
|
547
581
|
const key = ORIGINAL_DEFINE_PROPERTY ? twinsKey() : null;
|
|
582
|
+
let answer = null;
|
|
548
583
|
if (ORIGINAL_DEFINE_PROPERTY && opts?.origin != null) {
|
|
549
|
-
|
|
550
|
-
let at;
|
|
584
|
+
let at = null; let invalid = false;
|
|
551
585
|
try {
|
|
552
586
|
const origin = new URL(String(opts.origin));
|
|
553
587
|
const path = String(opts.path ?? '/');
|
|
554
588
|
if (!path.startsWith('/') || /\\/.test(path)) throw new Error('invalid dispatch path');
|
|
555
589
|
at = new URL(origin.origin + path);
|
|
556
|
-
} catch {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
590
|
+
} catch { invalid = true; }
|
|
591
|
+
answer = decide(at, 'dispatch', at ? { twin: resolveTwin(at.hostname, MAP, at.pathname), twinnedHostVendors: twinnedHostVendors(at.hostname, MAP), twinOrigin: isTwinOriginHost(at.hostname) } : { invalid });
|
|
592
|
+
if (answer.outcome === 'refuse') {
|
|
593
|
+
const message = answer.rule === 'invalid' ? '[twin-inject] invalid effective undici request'
|
|
594
|
+
: answer.rule === 'unclaimed-path' ? unclaimedTwinnedHostPathMessage(at.hostname, at.pathname, MAP)
|
|
595
|
+
: '[twin-inject] unmediated undici hop cannot use inspected egress';
|
|
596
|
+
process.nextTick(() => handler.onError(refusalError(answer, message)));
|
|
562
597
|
return false;
|
|
563
598
|
}
|
|
564
599
|
}
|
|
565
600
|
// Undici's shared dispatcher is an HTTP seam even when its caller bypasses global fetch.
|
|
566
601
|
// Rewrite the hop, not its socket: the original host remains available to the twin.
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (twin && twin.vendor !== 'app') {
|
|
602
|
+
if (answer && answer.outcome === 'twin') {
|
|
603
|
+
const at = new URL(answer.target);
|
|
604
|
+
const twin = answer.twin;
|
|
605
|
+
if (twin.vendor !== 'app') {
|
|
572
606
|
const endpoint = new URL(twin.origin);
|
|
573
607
|
const headers = undiciHeaderObject(opts.headers);
|
|
574
608
|
headers['host'] = at.host;
|
|
@@ -605,6 +639,12 @@ function keyUndiciDispatches() {
|
|
|
605
639
|
};
|
|
606
640
|
for (const name of UNDICI_DISPATCHERS) if (globalThis[name]) globalThis[name] = keyedView(globalThis[name]);
|
|
607
641
|
}
|
|
642
|
+
/** The twin the World routes `url` to, or null: its other answers are the dispatcher's and the sockets' to enforce. */
|
|
643
|
+
function routedTwin(url) {
|
|
644
|
+
if (!url) return null;
|
|
645
|
+
const answer = decide(url, 'dispatch', { twin: resolveTwin(url.hostname, MAP, url.pathname) });
|
|
646
|
+
return answer.outcome === 'twin' ? answer.twin : null;
|
|
647
|
+
}
|
|
608
648
|
// Imported Undici APIs may hold a dispatcher obtained before the global view was installed.
|
|
609
649
|
// Rewrite their public HTTP doors as well; an independent connector still faces the socket guards.
|
|
610
650
|
let UNDICI_LOAD = null;
|
|
@@ -620,7 +660,7 @@ function routeImportedUndici() {
|
|
|
620
660
|
view.fetch = function routedUndiciFetch(input, init) {
|
|
621
661
|
if (!installed) return exports.fetch(input, init);
|
|
622
662
|
let url; try { url = new URL(typeof input === 'object' && input?.url ? input.url : String(input)); } catch {}
|
|
623
|
-
const twin =
|
|
663
|
+
const twin = routedTwin(url);
|
|
624
664
|
// Local control planes may use their own Request class and dispatcher. Preserve both;
|
|
625
665
|
// connections still pass the socket guards, and claimed vendor hops still enter routing.
|
|
626
666
|
if (url && isLocalOrPrivateHost(url.hostname) && (!twin || twin.vendor === 'app')) return exports.fetch(input, init);
|
|
@@ -639,7 +679,7 @@ function routeImportedUndici() {
|
|
|
639
679
|
if (!installed) return exports.request.apply(exports, arguments);
|
|
640
680
|
const opts = typeof options === 'function' ? {} : options ?? {};
|
|
641
681
|
let url; try { url = new URL(String(input)); if (opts.path !== undefined) url = new URL(url.origin + String(opts.path)); } catch {}
|
|
642
|
-
const twin =
|
|
682
|
+
const twin = routedTwin(url);
|
|
643
683
|
if (!twin || twin.vendor === 'app') return exports.request.apply(exports, arguments);
|
|
644
684
|
const headers = undiciHeaderObject(opts.headers);
|
|
645
685
|
headers.host = url.host; headers['x-volter-twin-original-host'] = url.host;
|
|
@@ -962,7 +1002,7 @@ function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
|
|
|
962
1002
|
|
|
963
1003
|
function blockedClientRequest(message) {
|
|
964
1004
|
const request = new EventEmitter();
|
|
965
|
-
const error = new Error(message);
|
|
1005
|
+
const error = message instanceof Error ? message : new Error(message);
|
|
966
1006
|
const socket = new EventEmitter();
|
|
967
1007
|
socket.connecting = false;
|
|
968
1008
|
socket.setKeepAlive = () => socket;
|
|
@@ -1154,46 +1194,53 @@ function socketTargetPort(args) {
|
|
|
1154
1194
|
return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
|
|
1155
1195
|
}
|
|
1156
1196
|
|
|
1157
|
-
|
|
1197
|
+
/** Why the World refuses a socket to `hostname`:`port` (an Error carrying the refusal record), or null. */
|
|
1198
|
+
function socketRefusal(hostname, port, isTls, datagram = false) {
|
|
1158
1199
|
const host = String(hostname || '').toLowerCase();
|
|
1200
|
+
// an addressing mistake, not a capability: the World's DNS answer for the names it routes leads nowhere
|
|
1159
1201
|
if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
|
|
1160
|
-
return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
1161
|
-
+ 'nothing listens there; connect by the name, which the World routes to its twin or its application.';
|
|
1202
|
+
return new Error(`[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
1203
|
+
+ 'nothing listens there; connect by the name, which the World routes to its twin or its application.');
|
|
1162
1204
|
}
|
|
1163
|
-
if (
|
|
1164
|
-
const endpoints = [localGuestProxy(), worldBase() && new URL(worldBase().origin), ...Object.values(MAP).map(origin => { try { return new URL(origin); } catch { return null; } })].filter(Boolean);
|
|
1165
|
-
if (!endpoints.some(endpoint => endpoint.hostname.replace(/^\[|\]$/g, '') === host.replace(/^\[|\]$/g, '') && Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)) === port)) {
|
|
1166
|
-
return `[twin-inject] local guest private socket to ${host}:${port} requires its guest proxy`;
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
if (!host || isLocalOrPrivateHost(host) || isTwinOriginHost(host)) return null;
|
|
1205
|
+
if (!host) return null;
|
|
1170
1206
|
// the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
|
|
1171
1207
|
// host:443, never another port or a plaintext connection to it
|
|
1172
1208
|
const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
|
|
1173
1209
|
const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1210
|
+
if (!url) return null;
|
|
1211
|
+
const internal = isLocalOrPrivateHost(host);
|
|
1212
|
+
const facts = internal
|
|
1213
|
+
? { endpoint: worldRun().guest && guestDoorAt(host, port) ? { kind: 'door' } : null }
|
|
1214
|
+
: { twin: resolveTwin(host, MAP), twinnedHostVendors: twinnedHostVendors(host, MAP), twinOrigin: isTwinOriginHost(host) };
|
|
1215
|
+
const answer = decide(url, datagram ? 'datagram' : 'socket', facts, port);
|
|
1216
|
+
if (answer.outcome !== 'refuse') return null;
|
|
1217
|
+
let message = `[twin-inject] ${answer.reason}`;
|
|
1218
|
+
if (answer.rule === 'guest-private') message = `[twin-inject] local guest private socket to ${host}:${port} requires its guest proxy`;
|
|
1219
|
+
else if (answer.rule === 'raw-socket-twinned') {
|
|
1220
|
+
const vendors = facts.twinnedHostVendors;
|
|
1178
1221
|
const doors = vendors.map(endpointEnvFor).filter(Boolean);
|
|
1179
|
-
|
|
1222
|
+
message = `[twin-inject] refused a raw socket to ${host}, which is twinned in this world (${vendors.join(', ')}): `
|
|
1180
1223
|
+ `the client opened its own connection instead of going through http/https or fetch, so the twin never saw it. `
|
|
1181
1224
|
+ (doors.length > 0
|
|
1182
1225
|
? `The SDK reaches the twin through ${doors.join('; ')}, which the world sets when it runs the app.`
|
|
1183
1226
|
: `This client needs an endpoint door on the pack before the world can serve it.`);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
1227
|
+
} else if (answer.rule === 'raw-socket-app') {
|
|
1228
|
+
message = `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
1187
1229
|
+ 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
|
|
1188
|
-
}
|
|
1189
|
-
if (
|
|
1190
|
-
|
|
1191
|
-
|
|
1230
|
+
} else if (answer.rule === 'inspected-transport') message = `[twin-inject] inspected egress requires a mediated HTTP request to ${authority}`;
|
|
1231
|
+
else if (LISTING_RULES.has(answer.rule)) message = `[twin-inject] blocked untwinned external socket to ${authority}`;
|
|
1232
|
+
return refusalError(answer, message);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** One of the World's doors a guest connects to directly: its proxy (a local guest's listener), the World's origin, a twin. */
|
|
1236
|
+
function guestDoorAt(host, port) {
|
|
1237
|
+
const doors = [worldProxy(), localGuestProxy(), worldBase() && new URL(worldBase().origin), ...Object.values(MAP).map(origin => { try { return new URL(origin); } catch { return null; } })].filter(Boolean);
|
|
1238
|
+
return doors.some(door => door.hostname.replace(/^\[|\]$/g, '') === host.replace(/^\[|\]$/g, '') && Number(door.port || (door.protocol === 'https:' ? 443 : 80)) === port);
|
|
1192
1239
|
}
|
|
1193
1240
|
|
|
1194
|
-
function refusedSocket(
|
|
1241
|
+
function refusedSocket(refusal) {
|
|
1195
1242
|
const socket = new net.Socket();
|
|
1196
|
-
process.nextTick(() => socket.destroy(Object.assign(
|
|
1243
|
+
process.nextTick(() => socket.destroy(Object.assign(refusal, { code: 'ECONNREFUSED' })));
|
|
1197
1244
|
return socket;
|
|
1198
1245
|
}
|
|
1199
1246
|
|
|
@@ -1226,18 +1273,39 @@ function patchedInstanceConnect(originalConnect) {
|
|
|
1226
1273
|
return function guardedInstanceConnect(...args) {
|
|
1227
1274
|
const target = Array.isArray(args[0]) ? args[0] : args;
|
|
1228
1275
|
const isTls = this instanceof tls.TLSSocket;
|
|
1229
|
-
|
|
1276
|
+
const hosts = socketTargetHosts(target, isTls);
|
|
1277
|
+
for (const host of hosts) {
|
|
1230
1278
|
const refusal = socketRefusal(host, socketTargetPort(target), isTls);
|
|
1231
1279
|
if (refusal) {
|
|
1232
1280
|
// Preserve the instance and its error listeners; never return a different socket.
|
|
1233
|
-
process.nextTick(() => this.destroy(Object.assign(
|
|
1281
|
+
process.nextTick(() => this.destroy(Object.assign(refusal, { code: 'ECONNREFUSED' })));
|
|
1234
1282
|
return this;
|
|
1235
1283
|
}
|
|
1236
1284
|
}
|
|
1285
|
+
if (hosts.length > 0) reportSandboxRefusal(this, `${hosts[0]}:${socketTargetPort(target) ?? ''}`);
|
|
1237
1286
|
return originalConnect.apply(this, args);
|
|
1238
1287
|
};
|
|
1239
1288
|
}
|
|
1240
1289
|
|
|
1290
|
+
// A process of an image run is confined by its sandbox (a native run's Seatbelt) as well as by this decision. Seatbelt
|
|
1291
|
+
// answers a connection it refuses with EPERM and, for Node, writes no kernel record (images lane, measured on Homebrew
|
|
1292
|
+
// Node 24), so a connection this decision let through and the sandbox refused is reported here, as the World's record.
|
|
1293
|
+
// The emit is wrapped rather than an 'error' listener added: a listener would make an unhandled error handled.
|
|
1294
|
+
const SANDBOX_REPORTED = Symbol('volter.inject.sandboxReported');
|
|
1295
|
+
function reportSandboxRefusal(socket, target) {
|
|
1296
|
+
if (!worldDeclarationFromEnv(process.env) || socket[SANDBOX_REPORTED] || typeof socket.emit !== 'function') return;
|
|
1297
|
+
socket[SANDBOX_REPORTED] = true;
|
|
1298
|
+
const emit = socket.emit;
|
|
1299
|
+
socket.emit = function sandboxReportingEmit(event, error, ...rest) {
|
|
1300
|
+
if (event === 'error' && error && error.code === 'EPERM' && !error.worldRefusal) {
|
|
1301
|
+
const answer = sandboxRefusal(target);
|
|
1302
|
+
error.message = `${error.message} (${answer.reason}; ${refusalSide(answer)})`;
|
|
1303
|
+
error.worldRefusal = refusalRecord(answer);
|
|
1304
|
+
}
|
|
1305
|
+
return emit.call(this, event, error, ...rest);
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1241
1309
|
function patchedDatagram(original, connecting) {
|
|
1242
1310
|
return function guardedDatagram(...args) {
|
|
1243
1311
|
let peer;
|
|
@@ -1245,9 +1313,9 @@ function patchedDatagram(original, connecting) {
|
|
|
1245
1313
|
const tail = connecting ? args : args.slice(1);
|
|
1246
1314
|
const host = peer?.address ?? tail.findLast(arg => typeof arg === 'string') ?? 'localhost';
|
|
1247
1315
|
const port = peer?.port ?? tail.findLast(arg => typeof arg === 'number');
|
|
1248
|
-
const refusal = socketRefusal(host, port, false);
|
|
1316
|
+
const refusal = socketRefusal(host, port, false, true);
|
|
1249
1317
|
if (refusal) {
|
|
1250
|
-
const error = Object.assign(
|
|
1318
|
+
const error = Object.assign(refusal, { code: 'ECONNREFUSED' });
|
|
1251
1319
|
const callback = !connecting && typeof args.at(-1) === 'function' ? args.at(-1) : null;
|
|
1252
1320
|
process.nextTick(() => callback ? callback(error) : this.emit('error', error));
|
|
1253
1321
|
return;
|
|
@@ -1289,33 +1357,27 @@ function upgradeToTwin(origin, url, method, headers, args, callback) {
|
|
|
1289
1357
|
function patchedFactory(originalRequest, defaultProtocol) {
|
|
1290
1358
|
return function patchedRequest(...args) {
|
|
1291
1359
|
const { url, method, headers, callback, options: given, invalid } = describe(args, defaultProtocol);
|
|
1292
|
-
|
|
1293
|
-
const
|
|
1294
|
-
if (
|
|
1295
|
-
|
|
1296
|
-
|
|
1360
|
+
const upgrade = isUpgrade(headers) || method === 'CONNECT';
|
|
1361
|
+
const answer = decide(url, upgrade ? 'upgrade' : 'http', { ...httpFacts(url), invalid });
|
|
1362
|
+
if (answer.outcome === 'refuse') {
|
|
1363
|
+
const message = answer.rule === 'invalid' ? '[twin-inject] request has an invalid effective authority or path'
|
|
1364
|
+
: answer.rule === 'guest-upgrade' ? '[twin-inject] local guest upgrade requires a mediated transport'
|
|
1365
|
+
: answer.rule === 'inspected-transport' ? '[twin-inject] inspected egress cannot upgrade its transport'
|
|
1366
|
+
: httpRefusalMessage(answer, url, 'request');
|
|
1367
|
+
return blockedClientRequest(refusalError(answer, message));
|
|
1297
1368
|
}
|
|
1298
|
-
|
|
1299
|
-
if (twin && twin.vendor === 'app') {
|
|
1369
|
+
if (answer.via === 'guest-proxy') return requestApp({ proxy: localGuestProxy() }, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1370
|
+
if (answer.outcome === 'twin' && answer.twin.vendor === 'app') {
|
|
1300
1371
|
const given = args.find((a) => a && typeof a === 'object' && !(a instanceof URL)) || {};
|
|
1301
|
-
return requestApp(twin, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1372
|
+
return requestApp(answer.twin, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1302
1373
|
}
|
|
1303
|
-
if (twin) {
|
|
1374
|
+
if (answer.outcome === 'twin') {
|
|
1304
1375
|
const fwdHeaders = Object.assign({}, headers);
|
|
1305
1376
|
if (!fwdHeaders.host && !fwdHeaders.Host) fwdHeaders.host = url.host;
|
|
1306
|
-
if (isUpgrade(headers)) return upgradeToTwin(twin.origin, url, method, fwdHeaders, args, callback);
|
|
1307
|
-
return fakeClientRequest(twin.origin, method, url.pathname + url.search, fwdHeaders, callback);
|
|
1308
|
-
}
|
|
1309
|
-
// No twin claims this path — but if configured twins claim the HOST, the path is an unclaimed
|
|
1310
|
-
// slice of a shared, virtualized host (the Cal.com /calendar/v3 case): refuse loudly, in every
|
|
1311
|
-
// mode, before the strict-egress check gets a chance to phrase it as an ordinary untwinned host.
|
|
1312
|
-
if (url && !publicBuildRequest(url) && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
1313
|
-
return blockedClientRequest(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP));
|
|
1377
|
+
if (isUpgrade(headers)) return upgradeToTwin(answer.twin.origin, url, method, fwdHeaders, args, callback);
|
|
1378
|
+
return fakeClientRequest(answer.twin.origin, method, url.pathname + url.search, fwdHeaders, callback);
|
|
1314
1379
|
}
|
|
1315
|
-
if (
|
|
1316
|
-
const policy = worldNetworkPolicyFromEnv(process.env);
|
|
1317
|
-
if (url && policy && worldNetworkNeedsPathInspection(policy, url)) {
|
|
1318
|
-
if (isUpgrade(headers) || method === 'CONNECT') return blockedClientRequest('[twin-inject] inspected egress cannot upgrade its transport');
|
|
1380
|
+
if (answer.via === 'inspected') {
|
|
1319
1381
|
try {
|
|
1320
1382
|
return requestApp({ proxy: pathGrantProxy() }, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1321
1383
|
} catch (error) { return blockedClientRequest(`[twin-inject] ${error.message}`); }
|
|
@@ -1345,7 +1407,7 @@ function install(mapOverride) {
|
|
|
1345
1407
|
// the claiming twins' doors are read from the start, so a process's first request finds what they claim
|
|
1346
1408
|
for (const vendor of Object.keys(CLAIMERS)) if (MAP[vendor]) claimsAt(MAP[vendor], CLAIMERS[vendor]);
|
|
1347
1409
|
const active = Object.keys(MAP);
|
|
1348
|
-
if (installed || (!active.length && !worldBase() && !MAP[APP_INSTANCE] && !strictEgressEnabled() && !worldNetworkPolicyFromEnv(process.env))) return { active };
|
|
1410
|
+
if (installed || (!active.length && !worldBase() && !MAP[APP_INSTANCE] && !strictEgressEnabled() && !worldNetworkPolicyFromEnv(process.env) && !worldDeclarationFromEnv(process.env))) return { active };
|
|
1349
1411
|
|
|
1350
1412
|
ORIGINALS = {
|
|
1351
1413
|
httpRequest: http.request.bind(http),
|
|
@@ -1384,13 +1446,17 @@ function install(mapOverride) {
|
|
|
1384
1446
|
http2.connect = function patchedHttp2Connect(authority, options, listener) {
|
|
1385
1447
|
let url = null;
|
|
1386
1448
|
try { url = new URL(typeof authority === 'string' ? authority : authority && authority.href ? authority.href : `https://${authority.hostname || authority.host}`); } catch { url = null; }
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1449
|
+
// a session names no path at connect, so a twinned host's unclaimed paths cannot be told apart here: host-level only
|
|
1450
|
+
const { twinnedHostVendors: _paths, ...facts } = httpFacts(url, '/');
|
|
1451
|
+
const answer = decide(url, 'http2', facts);
|
|
1452
|
+
if (answer.outcome !== 'twin') {
|
|
1453
|
+
// the World's listing is enforced on the session's own TLS connect, by the socket guard, with this same decision
|
|
1454
|
+
if (answer.outcome === 'refuse' && !LISTING_RULES.has(answer.rule)) {
|
|
1455
|
+
throw refusalError(answer, answer.rule === 'guest-http2' ? '[twin-inject] local guest HTTP/2 requires a mediated transport' : '[twin-inject] HTTP/2 cannot use inspected egress');
|
|
1456
|
+
}
|
|
1392
1457
|
return ORIGINALS.http2Connect.apply(this, arguments);
|
|
1393
1458
|
}
|
|
1459
|
+
const twin = answer.twin;
|
|
1394
1460
|
const cb = typeof options === 'function' ? options : listener;
|
|
1395
1461
|
const opts = typeof options === 'function' || !options ? {} : Object.fromEntries(Object.entries(options).filter(([k]) => !['secureContext', 'servername', 'ca', 'cert', 'key', 'checkServerIdentity', 'ALPNProtocols', 'createConnection', 'rejectUnauthorized'].includes(k)));
|
|
1396
1462
|
return ORIGINALS.http2Connect.call(this, new URL(twin.origin).origin, opts, cb);
|
|
@@ -1403,26 +1469,23 @@ function install(mapOverride) {
|
|
|
1403
1469
|
let urlStr;
|
|
1404
1470
|
try { urlStr = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input && input.url; } catch { urlStr = undefined; }
|
|
1405
1471
|
let url = null;
|
|
1472
|
+
let invalid = false;
|
|
1406
1473
|
if (urlStr) {
|
|
1407
|
-
|
|
1474
|
+
invalid = /\\/.test(String(urlStr).split(/[?#]/, 1)[0]);
|
|
1408
1475
|
try { url = new URL(urlStr); } catch { url = null; }
|
|
1409
1476
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
const twin =
|
|
1477
|
+
// the claims are read as they stand; a miss refreshes them once below and asks again
|
|
1478
|
+
const answer = decide(url, 'http', { ...httpFacts(url), invalid });
|
|
1479
|
+
if (answer.rule === 'invalid') return Promise.reject(refusalError(answer, '[twin-inject] backslash in fetch target'));
|
|
1480
|
+
if (answer.via === 'guest-proxy') return fetchApp({ proxy: localGuestProxy() }, url, input, init, chain);
|
|
1481
|
+
const twin = answer.outcome === 'twin' ? answer.twin : null;
|
|
1415
1482
|
if (!twin) {
|
|
1416
1483
|
// A host may have been claimed since startup (including by a child process).
|
|
1417
1484
|
// Refresh once at most: a retry goes on with the claims actually returned.
|
|
1418
1485
|
const pending = !claimsWaited && url && !isLocalOrPrivateHost(url.hostname) && !isTwinOriginHost(url.hostname) ? claimsRefreshBriefly() : null;
|
|
1419
1486
|
if (pending) return pending.then(() => patchedFetch(input, init, true, chain));
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1422
|
-
if (url && !publicBuildRequest(url) && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
1423
|
-
return Promise.reject(new Error(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP)));
|
|
1424
|
-
}
|
|
1425
|
-
if (shouldBlockUntwinned(url)) return Promise.reject(new Error(`[twin-inject] blocked untwinned external fetch to ${url.hostname}`));
|
|
1487
|
+
// an unclaimed path on a twinned host refuses loudly in every mode: never mis-routed, never leaked to the vendor
|
|
1488
|
+
if (answer.outcome === 'refuse') return Promise.reject(refusalError(answer, httpRefusalMessage(answer, url, 'fetch')));
|
|
1426
1489
|
// Each followed redirect re-enters authorization; native fetch only receives manual hops under a policy.
|
|
1427
1490
|
if (addressedToWorld(url) && twinsKey()) {
|
|
1428
1491
|
const request = typeof input === 'object' && input !== null ? input : undefined;
|
|
@@ -1437,9 +1500,9 @@ function install(mapOverride) {
|
|
|
1437
1500
|
}
|
|
1438
1501
|
return originalFetch(input, keyed);
|
|
1439
1502
|
}
|
|
1440
|
-
const policy =
|
|
1503
|
+
const policy = worldGrant().policy;
|
|
1441
1504
|
const redirect = init?.redirect ?? (typeof input === 'object' && input?.redirect) ?? 'follow';
|
|
1442
|
-
if (
|
|
1505
|
+
if (answer.via === 'inspected') {
|
|
1443
1506
|
try { return fetchApp({ proxy: pathGrantProxy() }, url, input, init, chain); }
|
|
1444
1507
|
catch (error) { return Promise.reject(error); }
|
|
1445
1508
|
}
|
package/dist/network-policy.cjs
CHANGED
|
@@ -55,12 +55,13 @@ function origins(value) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function normalizeWorldNetwork(value) {
|
|
58
|
-
const network = object(value, ['egress', 'writes', 'phase', 'passthrough', 'publicReads'], 'World network');
|
|
58
|
+
const network = object(value, ['egress', 'writes', 'phase', 'passthrough', 'publicReads', 'ownLoopback'], 'World network');
|
|
59
59
|
const egress = origins(network.egress);
|
|
60
60
|
const writes = origins(network.writes ?? []);
|
|
61
61
|
const phase = network.phase ?? 'runtime';
|
|
62
62
|
if (phase !== 'build' && phase !== 'runtime') throw new Error('World network phase must be build or runtime');
|
|
63
63
|
if (network.publicReads !== undefined && typeof network.publicReads !== 'boolean') throw new Error('World network publicReads must be a boolean');
|
|
64
|
+
if (network.ownLoopback !== undefined && typeof network.ownLoopback !== 'boolean') throw new Error('World network ownLoopback must be a boolean');
|
|
64
65
|
const publicReads = phase === 'build' && network.publicReads !== false;
|
|
65
66
|
const passthrough = origins(network.passthrough ?? []);
|
|
66
67
|
if (passthrough.length && phase !== 'build') throw new Error('World TLS passthrough requires phase: build');
|
|
@@ -69,7 +70,9 @@ function normalizeWorldNetwork(value) {
|
|
|
69
70
|
// an opaque tunnel cannot show its method, so it cannot carry a write
|
|
70
71
|
if (writes.some(grant => new URL(grant).origin === origin)) throw new Error(`TLS passthrough cannot carry a write grant: ${origin}`);
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
// a run reaches its own listeners unless the World says otherwise (architecture, "Capability decisions"); only the
|
|
74
|
+
// refusal is written, so a policy that grants it reads as it did before the field existed
|
|
75
|
+
return Object.freeze({ egress, writes, phase, passthrough, publicReads, ...(network.ownLoopback === false ? { ownLoopback: false } : {}) });
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
function createWorldNetworkPolicy(world, network) {
|
|
@@ -78,9 +81,9 @@ function createWorldNetworkPolicy(world, network) {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
function parseWorldNetworkPolicy(value) {
|
|
81
|
-
const policy = object(value, ['version', 'world', 'egress', 'writes', 'phase', 'passthrough', 'publicReads'], 'World network policy');
|
|
84
|
+
const policy = object(value, ['version', 'world', 'egress', 'writes', 'phase', 'passthrough', 'publicReads', 'ownLoopback'], 'World network policy');
|
|
82
85
|
if (policy.version !== 1) throw new Error('Unsupported World network policy version');
|
|
83
|
-
return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads });
|
|
86
|
+
return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads, ownLoopback: policy.ownLoopback });
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
function worldNetworkPolicyFromEnv(env) {
|
|
@@ -152,7 +155,8 @@ function intersectWorldNetwork(own, ceiling) {
|
|
|
152
155
|
? left.passthrough.filter(origin => right.passthrough.includes(origin) && egress.includes(origin)) : [];
|
|
153
156
|
// public reads are permission too: only a build ceiling that reads them passes them on
|
|
154
157
|
const publicReads = left.publicReads && right.publicReads;
|
|
155
|
-
|
|
158
|
+
const ownLoopback = left.ownLoopback !== false && right.ownLoopback !== false;
|
|
159
|
+
return normalizeWorldNetwork({ egress, writes, phase: left.phase, passthrough, publicReads, ownLoopback });
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
/** Loopback and private addresses stay inside the machine a World runs on: never refused. */
|
|
@@ -171,21 +175,21 @@ function isWorldInternalHost(hostname) {
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
/**
|
|
174
|
-
* Why this World refuses an outbound request to `value`, or null when it allows it
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* injector does not attach to. Internal addresses pass; strict egress refuses everything else; a
|
|
178
|
+
* Why this World refuses an outbound request to `value`, or null when it allows it: the World's capability decision
|
|
179
|
+
* (capability-decision.cjs) for a twin's own World-internal outbound calls (a queue delivering to its destination), which
|
|
180
|
+
* run in a host the injector does not attach to. Internal addresses pass; strict egress refuses everything else; a
|
|
178
181
|
* published network policy refuses what it does not list. An unreadable policy refuses.
|
|
179
182
|
*/
|
|
180
183
|
function worldEgressRefusal(value, env = process.env, method = 'GET') {
|
|
181
184
|
let url;
|
|
182
185
|
try { url = new URL(String(value)); } catch { return `unparseable destination ${String(value)}`; }
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
let policy; let unreadable;
|
|
187
|
+
try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
|
|
188
|
+
const strict = env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
189
|
+
const { OPEN_DECLARATION, decideCapability, refusalSide } = require('./capability-decision.cjs');
|
|
190
|
+
const answer = decideCapability({ kind: 'egress', url, method, transport: 'http' },
|
|
191
|
+
{ run: { guest: false }, declaration: OPEN_DECLARATION, grant: { policy, strict, unreadable }, facts: {} });
|
|
192
|
+
return answer.outcome === 'refuse' ? `${answer.reason} (${refusalSide(answer)})` : null;
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
module.exports = { PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export interface WorldNetwork { readonly egress: readonly string[]; readonly writes?: readonly string[]; readonly phase?: 'build' | 'runtime'; readonly passthrough?: readonly string[]; readonly publicReads?: boolean }
|
|
1
|
+
export interface WorldNetwork { readonly egress: readonly string[]; readonly writes?: readonly string[]; readonly phase?: 'build' | 'runtime'; readonly passthrough?: readonly string[]; readonly publicReads?: boolean; readonly ownLoopback?: boolean }
|
|
2
2
|
export interface WorldNetworkPolicy extends WorldNetwork { readonly version: 1; readonly world: string }
|
|
3
3
|
export const WORLD_NETWORK_POLICY_ENV: 'VOLTER_WORLD_NETWORK_POLICY';
|
|
4
4
|
export function normalizeWorldNetwork(value: unknown): WorldNetwork;
|