@volter/world-core 3.0.67 → 3.0.69

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/app-route.cjs CHANGED
@@ -272,4 +272,49 @@ async function followRedirects(value, init, send, extraDropped = []) {
272
272
  }
273
273
  }
274
274
 
275
- module.exports = { appRouteFor, appHostMatch, appForwardHeaders, appDestination, appRequestOptions, deliverToApp, appFetch, followRedirects, sessionCa };
275
+ /** The World's services as the instance `instanceFile` records them (id to record: its url, its sides' urls, an external
276
+ * service's discovered env), read at most once a second; empty without an instance. */
277
+ function instanceServices(instanceFile) {
278
+ return readRoutes(instanceFile).services || {};
279
+ }
280
+
281
+ /** Every endpoint the World's instance `instanceFile` records, classified as the World's capability decision reads it
282
+ * (docs/contributing/architecture.md, "Capability decisions"): a twin's (its URL and each side's; the record's `type`)
283
+ * is a door, which an attached guest reaches without declaring it; any other service's (its URL, a side's, an external
284
+ * service's discovered address) is a service, by its id, which a guest declares. The one classifier: the injector, a
285
+ * World Machine's forwarders and an image run's adapter all take endpoints from it. */
286
+ function instanceEndpoints(instanceFile) {
287
+ const out = [];
288
+ for (const [id, service] of Object.entries(instanceServices(instanceFile))) {
289
+ if (!service || typeof service !== 'object') continue;
290
+ const kind = service.type === 'twin' ? 'door' : 'service';
291
+ const values = [service.url, ...Object.values(service.sides || {}).map((side) => side && side.url),
292
+ ...Object.values((service.external && service.external.discoveredEnv) || {})];
293
+ for (const value of values) {
294
+ let url;
295
+ try { url = new URL(String(value)); } catch { continue; }
296
+ if (!/^[a-z][a-z0-9+.-]*:$/.test(url.protocol) || !url.hostname) continue;
297
+ const port = Number(url.port || (url.protocol === 'https:' ? 443 : url.protocol === 'http:' ? 80 : 0));
298
+ if (port) out.push({ host: url.hostname.replace(/^\[|\]$/g, ''), port, kind, name: id, url: url.href });
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** Whether a dial to `dialed` reaches a listener recorded at `recorded`, as a resolver answers: `localhost` is
305
+ * 127.0.0.1 or ::1, either way round. 127.0.0.1 and ::1 are different listeners (a v4 bind does not answer v6). */
306
+ function sameListenerHost(dialed, recorded) {
307
+ const a = String(dialed).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
308
+ const b = String(recorded).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
309
+ if (a === b) return true;
310
+ const loopback = (h) => h === '127.0.0.1' || h === '::1';
311
+ return (a === 'localhost' && loopback(b)) || (b === 'localhost' && loopback(a));
312
+ }
313
+
314
+ /** The recorded endpoint at `host`:`port` ({ kind, name }), or null: what the instance says listens there. */
315
+ function instanceEndpointAt(instanceFile, host, port) {
316
+ const found = instanceEndpoints(instanceFile).find((e) => e.port === port && sameListenerHost(host, e.host));
317
+ return found ? { kind: found.kind, name: found.name } : null;
318
+ }
319
+
320
+ module.exports = { sameListenerHost, instanceEndpoints, instanceEndpointAt, instanceServices, appRouteFor, appHostMatch, appForwardHeaders, appDestination, appRequestOptions, deliverToApp, appFetch, followRedirects, sessionCa };
package/app-route.d.cts CHANGED
@@ -1,5 +1,10 @@
1
1
  export interface AppRoute { readonly name: string; readonly origin: string; readonly url: string | null; readonly hosts: ReadonlySet<string> }
2
2
  export interface AppAnswer { readonly status: number; readonly headers: Record<string, string>; readonly body: string }
3
+ export function instanceServices(instanceFile: string | undefined): Readonly<Record<string, { readonly type?: string; readonly url?: string; readonly sides?: Readonly<Record<string, { readonly url?: string }>>; readonly external?: { readonly discoveredEnv?: Readonly<Record<string, string>> } }>>;
4
+ export interface InstanceEndpoint { readonly host: string; readonly port: number; readonly kind: 'door' | 'service'; readonly name: string; readonly url: string }
5
+ export function sameListenerHost(dialed: string, recorded: string): boolean;
6
+ export function instanceEndpoints(instanceFile: string | undefined): InstanceEndpoint[];
7
+ export function instanceEndpointAt(instanceFile: string | undefined, host: string, port: number): { readonly kind: 'door' | 'service'; readonly name: string } | null;
3
8
  export function appRouteFor(instanceFile: string | undefined, host: string, twins?: Readonly<Record<string, string>>, claimedAppHosts?: ReadonlySet<string> | null): AppRoute | null;
4
9
  export function appHostMatch(hosts: ReadonlySet<string>, host: string): boolean;
5
10
  export function appForwardHeaders(url: URL): Record<string, string>;
@@ -35,6 +35,10 @@ const GRANT_FIELDS = Object.freeze({
35
35
  'network.services': 'services',
36
36
  });
37
37
 
38
+ /** The page that says, for each refusal, what it means and the one change that fixes it, at the refusal's own rule. */
39
+ const REFUSALS_PAGE = 'https://world.volter.ai/docs/reference/refusals';
40
+ const refusalHelp = (rule) => `${REFUSALS_PAGE}#${rule}`;
41
+
38
42
  const MISSING_WORDS = Object.freeze({
39
43
  declaration: 'the guest does not declare it',
40
44
  grant: 'the World does not grant it',
@@ -50,24 +54,26 @@ function refuse(capability, target, missing, rule, reason, declaration) {
50
54
  const declaredBy = declaration && Array.isArray(declaration.derivedFrom)
51
55
  ? declaration.derivedFrom.find(entry => entry && entry.capability === capability) : undefined;
52
56
  return Object.freeze({
53
- outcome: 'refuse', capability, target, missing, rule, reason,
54
- // the field that would grant it, named only where the World's grant is what is missing
55
- ...(missing === 'grant' && GRANT_FIELDS[capability] ? { grantField: GRANT_FIELDS[capability] } : {}),
57
+ outcome: 'refuse', capability, target, missing, rule, reason, see: refusalHelp(rule),
58
+ // the field that would grant it, named only where a grant in it is what is missing: no grant lifts a sandbox, an
59
+ // unreadable policy or an address that cannot be read
60
+ ...(missing === 'grant' && GRANT_FIELDS[capability] && !['sealed', 'policy-unreadable', 'invalid'].includes(rule) ? { grantField: GRANT_FIELDS[capability] } : {}),
56
61
  ...(declaredBy ? { declaredBy } : {}),
57
62
  });
58
63
  }
59
64
 
60
65
  /** A refusal as the one record every substrate reports. */
61
66
  function refusalRecord(answer) {
62
- const { capability, target, missing, reason, grantField, declaredBy } = answer;
63
- return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, reason,
64
- ...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}) });
67
+ const { capability, target, missing, rule, reason, grantField, declaredBy } = answer;
68
+ return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, rule, reason,
69
+ ...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}), see: answer.see ?? refusalHelp(rule) });
65
70
  }
66
71
 
67
72
  /** The side that refused, in words, for the end of an error message. */
68
73
  function refusalSide(answer) {
69
74
  const words = MISSING_WORDS[answer.missing] || answer.missing;
70
- return answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
75
+ const side = answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
76
+ return `${side}; fix: ${answer.see ?? refusalHelp(answer.rule)}`;
71
77
  }
72
78
 
73
79
  function declaresEgress(declaration, url) {
@@ -110,7 +116,8 @@ function decideLoopback(request, context) {
110
116
  if (facts.endpoint.kind !== 'service' || declaresService(declaration, facts.endpoint.name)) return pass('network.services', target, 'direct');
111
117
  return refuse('network.services', target, 'declaration', 'service-undeclared', `the guest does not declare the World's service ${facts.endpoint.name} at ${target}`, declaration);
112
118
  }
113
- if ((request.transport ?? 'tcp') === 'tcp' && declaresOwnListener(declaration, facts)) {
119
+ // own loopback is the run's own on either protocol: a container's loopback carries its datagrams as its connections
120
+ if (declaresOwnListener(declaration, facts)) {
114
121
  return grant.policy && grant.policy.ownLoopback === false
115
122
  ? refuse('network.ownLoopback', target, 'grant', 'own-loopback-ungranted', `this World does not let a run reach its own listener at ${target}`, declaration)
116
123
  : pass('network.ownLoopback', target, 'direct');
@@ -118,14 +125,14 @@ function decideLoopback(request, context) {
118
125
  // neither a door of the World nor a listener this run opened: a service of the host, which no World grants a guest
119
126
  // without a listener fact the two cannot be told apart here: a host service, or a listener of this run it did not declare
120
127
  const hint = declaration && declaration.network && declaration.network.ownLoopback === true ? '' : '; if it is this run\'s own listener, the run declares ownLoopback';
121
- return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's doors, its declared services and its own listeners, not ${target}${hint}`, declaration);
128
+ return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's endpoints, its declared services and its own listeners, not ${target}${hint}`, declaration);
122
129
  }
123
130
 
124
131
  /** The World's listing of an external destination: sealed refuses, an unreadable policy refuses, a policy refuses what
125
132
  * it does not list. null when listed, or when the World restricts nothing. */
126
133
  function unlisted(url, target, method, { declaration, grant = {} }) {
127
134
  const capability = 'network.egress';
128
- if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sealed World refuses untwinned host ${url.hostname}`, declaration);
135
+ if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sandbox refuses untwinned host ${url.hostname}`, declaration);
129
136
  if (grant.unreadable) return refuse(capability, target, 'grant', 'policy-unreadable', `the World network policy is unreadable (${grant.unreadable}); refusing ${url.hostname}`, declaration);
130
137
  if (grant.policy && !allowsWorldNetworkEgress(grant.policy, url, method ?? 'GET')) {
131
138
  return refuse(capability, target, 'grant', 'not-granted', `the World network policy does not allow ${String(method ?? 'GET').toUpperCase()} ${url.origin}`, declaration);
@@ -188,13 +195,14 @@ function decideEgress(request, context) {
188
195
 
189
196
  if (!internal && !declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
190
197
  // an explicit public build grant wins over a twin: that exact path is inspected and sent to its real origin
191
- const publicBuild = !strict && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, url);
198
+ const publicBuild = publicBuildGrant(grant, url);
192
199
  if (!publicBuild && facts.twin) return Object.freeze({ outcome: 'twin', capability, target, twin: facts.twin });
193
200
  if (!publicBuild && facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
194
201
  return refuse(capability, target, 'twin', 'unclaimed-path', `no twin in this World claims ${url.pathname} on ${url.hostname} (${facts.twinnedHostVendors.join(', ')})`, declaration);
195
202
  }
196
- // a guest whose HTTP no guest listener carries (an image run) reaches loopback as its own declaration says
197
- if (internal && run.guest && !facts.guestProxy && !facts.endpoint) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
203
+ // a guest's loopback is the loopback decision: a World service it must declare, and, where no guest listener carries
204
+ // its HTTP (an image run), its own listeners as its declaration says
205
+ if (internal && run.guest && (facts.endpoint ? facts.endpoint.kind === 'service' : !facts.guestProxy)) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
198
206
  if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
199
207
  const refused = unlisted(url, target, request.method, context);
200
208
  if (refused) return refused;
@@ -208,6 +216,11 @@ function decideEgress(request, context) {
208
216
  return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
209
217
  }
210
218
 
219
+ /** Whether the World's build-phase grant lists `url` itself, which then wins over a twin of its host. */
220
+ function publicBuildGrant(grant, url) {
221
+ return grant.strict !== true && !!grant.policy && grant.policy.phase === 'build' && allowsWorldNetworkEgress(grant.policy, url);
222
+ }
223
+
211
224
  /** A path the guest writes as its data (a `VOLUME`). Declared, it passes run-scoped: writable for the run, not outliving
212
225
  * it, as Docker gives an unmounted VOLUME an anonymous volume. Keeping it beyond the run needs a World grant, defined
213
226
  * at that need. */
@@ -270,4 +283,4 @@ function decideCapabilitySet(declaration, grant = {}, world = {}) {
270
283
  return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
271
284
  }
272
285
 
273
- module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
286
+ module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, publicBuildGrant, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
@@ -38,9 +38,9 @@ export type CapabilityRequest =
38
38
  export type CapabilityAnswer =
39
39
  | { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' }
40
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 };
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; readonly see?: string };
42
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 }
43
+ export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see: string }
44
44
 
45
45
  export interface CapabilitySet {
46
46
  readonly network: { readonly egress: 'none' | 'proxy'; readonly loopback: 'none' | 'own'; readonly listen: readonly number[]; readonly endpoints: readonly WorldEndpoint[] };
@@ -54,6 +54,7 @@ export const WORLD_DECLARATION_ENV: 'VOLTER_WORLD_DECLARATION';
54
54
  export function worldDeclarationFromEnv(env: Readonly<Record<string, string | undefined>>): CapabilityDeclaration | undefined;
55
55
  export function sandboxRefusal(target: string): Extract<CapabilityAnswer, { outcome: 'refuse' }>;
56
56
  export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
57
+ export function publicBuildGrant(grant: WorldGrant, url: URL): boolean;
57
58
  export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
58
59
  export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
59
60
  export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
@@ -272,4 +272,49 @@ async function followRedirects(value, init, send, extraDropped = []) {
272
272
  }
273
273
  }
274
274
 
275
- module.exports = { appRouteFor, appHostMatch, appForwardHeaders, appDestination, appRequestOptions, deliverToApp, appFetch, followRedirects, sessionCa };
275
+ /** The World's services as the instance `instanceFile` records them (id to record: its url, its sides' urls, an external
276
+ * service's discovered env), read at most once a second; empty without an instance. */
277
+ function instanceServices(instanceFile) {
278
+ return readRoutes(instanceFile).services || {};
279
+ }
280
+
281
+ /** Every endpoint the World's instance `instanceFile` records, classified as the World's capability decision reads it
282
+ * (docs/contributing/architecture.md, "Capability decisions"): a twin's (its URL and each side's; the record's `type`)
283
+ * is a door, which an attached guest reaches without declaring it; any other service's (its URL, a side's, an external
284
+ * service's discovered address) is a service, by its id, which a guest declares. The one classifier: the injector, a
285
+ * World Machine's forwarders and an image run's adapter all take endpoints from it. */
286
+ function instanceEndpoints(instanceFile) {
287
+ const out = [];
288
+ for (const [id, service] of Object.entries(instanceServices(instanceFile))) {
289
+ if (!service || typeof service !== 'object') continue;
290
+ const kind = service.type === 'twin' ? 'door' : 'service';
291
+ const values = [service.url, ...Object.values(service.sides || {}).map((side) => side && side.url),
292
+ ...Object.values((service.external && service.external.discoveredEnv) || {})];
293
+ for (const value of values) {
294
+ let url;
295
+ try { url = new URL(String(value)); } catch { continue; }
296
+ if (!/^[a-z][a-z0-9+.-]*:$/.test(url.protocol) || !url.hostname) continue;
297
+ const port = Number(url.port || (url.protocol === 'https:' ? 443 : url.protocol === 'http:' ? 80 : 0));
298
+ if (port) out.push({ host: url.hostname.replace(/^\[|\]$/g, ''), port, kind, name: id, url: url.href });
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** Whether a dial to `dialed` reaches a listener recorded at `recorded`, as a resolver answers: `localhost` is
305
+ * 127.0.0.1 or ::1, either way round. 127.0.0.1 and ::1 are different listeners (a v4 bind does not answer v6). */
306
+ function sameListenerHost(dialed, recorded) {
307
+ const a = String(dialed).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
308
+ const b = String(recorded).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
309
+ if (a === b) return true;
310
+ const loopback = (h) => h === '127.0.0.1' || h === '::1';
311
+ return (a === 'localhost' && loopback(b)) || (b === 'localhost' && loopback(a));
312
+ }
313
+
314
+ /** The recorded endpoint at `host`:`port` ({ kind, name }), or null: what the instance says listens there. */
315
+ function instanceEndpointAt(instanceFile, host, port) {
316
+ const found = instanceEndpoints(instanceFile).find((e) => e.port === port && sameListenerHost(host, e.host));
317
+ return found ? { kind: found.kind, name: found.name } : null;
318
+ }
319
+
320
+ module.exports = { sameListenerHost, instanceEndpoints, instanceEndpointAt, instanceServices, appRouteFor, appHostMatch, appForwardHeaders, appDestination, appRequestOptions, deliverToApp, appFetch, followRedirects, sessionCa };
@@ -1,5 +1,10 @@
1
1
  export interface AppRoute { readonly name: string; readonly origin: string; readonly url: string | null; readonly hosts: ReadonlySet<string> }
2
2
  export interface AppAnswer { readonly status: number; readonly headers: Record<string, string>; readonly body: string }
3
+ export function instanceServices(instanceFile: string | undefined): Readonly<Record<string, { readonly type?: string; readonly url?: string; readonly sides?: Readonly<Record<string, { readonly url?: string }>>; readonly external?: { readonly discoveredEnv?: Readonly<Record<string, string>> } }>>;
4
+ export interface InstanceEndpoint { readonly host: string; readonly port: number; readonly kind: 'door' | 'service'; readonly name: string; readonly url: string }
5
+ export function sameListenerHost(dialed: string, recorded: string): boolean;
6
+ export function instanceEndpoints(instanceFile: string | undefined): InstanceEndpoint[];
7
+ export function instanceEndpointAt(instanceFile: string | undefined, host: string, port: number): { readonly kind: 'door' | 'service'; readonly name: string } | null;
3
8
  export function appRouteFor(instanceFile: string | undefined, host: string, twins?: Readonly<Record<string, string>>, claimedAppHosts?: ReadonlySet<string> | null): AppRoute | null;
4
9
  export function appHostMatch(hosts: ReadonlySet<string>, host: string): boolean;
5
10
  export function appForwardHeaders(url: URL): Record<string, string>;
@@ -35,6 +35,10 @@ const GRANT_FIELDS = Object.freeze({
35
35
  'network.services': 'services',
36
36
  });
37
37
 
38
+ /** The page that says, for each refusal, what it means and the one change that fixes it, at the refusal's own rule. */
39
+ const REFUSALS_PAGE = 'https://world.volter.ai/docs/reference/refusals';
40
+ const refusalHelp = (rule) => `${REFUSALS_PAGE}#${rule}`;
41
+
38
42
  const MISSING_WORDS = Object.freeze({
39
43
  declaration: 'the guest does not declare it',
40
44
  grant: 'the World does not grant it',
@@ -50,24 +54,26 @@ function refuse(capability, target, missing, rule, reason, declaration) {
50
54
  const declaredBy = declaration && Array.isArray(declaration.derivedFrom)
51
55
  ? declaration.derivedFrom.find(entry => entry && entry.capability === capability) : undefined;
52
56
  return Object.freeze({
53
- outcome: 'refuse', capability, target, missing, rule, reason,
54
- // the field that would grant it, named only where the World's grant is what is missing
55
- ...(missing === 'grant' && GRANT_FIELDS[capability] ? { grantField: GRANT_FIELDS[capability] } : {}),
57
+ outcome: 'refuse', capability, target, missing, rule, reason, see: refusalHelp(rule),
58
+ // the field that would grant it, named only where a grant in it is what is missing: no grant lifts a sandbox, an
59
+ // unreadable policy or an address that cannot be read
60
+ ...(missing === 'grant' && GRANT_FIELDS[capability] && !['sealed', 'policy-unreadable', 'invalid'].includes(rule) ? { grantField: GRANT_FIELDS[capability] } : {}),
56
61
  ...(declaredBy ? { declaredBy } : {}),
57
62
  });
58
63
  }
59
64
 
60
65
  /** A refusal as the one record every substrate reports. */
61
66
  function refusalRecord(answer) {
62
- const { capability, target, missing, reason, grantField, declaredBy } = answer;
63
- return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, reason,
64
- ...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}) });
67
+ const { capability, target, missing, rule, reason, grantField, declaredBy } = answer;
68
+ return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, rule, reason,
69
+ ...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}), see: answer.see ?? refusalHelp(rule) });
65
70
  }
66
71
 
67
72
  /** The side that refused, in words, for the end of an error message. */
68
73
  function refusalSide(answer) {
69
74
  const words = MISSING_WORDS[answer.missing] || answer.missing;
70
- return answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
75
+ const side = answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
76
+ return `${side}; fix: ${answer.see ?? refusalHelp(answer.rule)}`;
71
77
  }
72
78
 
73
79
  function declaresEgress(declaration, url) {
@@ -110,7 +116,8 @@ function decideLoopback(request, context) {
110
116
  if (facts.endpoint.kind !== 'service' || declaresService(declaration, facts.endpoint.name)) return pass('network.services', target, 'direct');
111
117
  return refuse('network.services', target, 'declaration', 'service-undeclared', `the guest does not declare the World's service ${facts.endpoint.name} at ${target}`, declaration);
112
118
  }
113
- if ((request.transport ?? 'tcp') === 'tcp' && declaresOwnListener(declaration, facts)) {
119
+ // own loopback is the run's own on either protocol: a container's loopback carries its datagrams as its connections
120
+ if (declaresOwnListener(declaration, facts)) {
114
121
  return grant.policy && grant.policy.ownLoopback === false
115
122
  ? refuse('network.ownLoopback', target, 'grant', 'own-loopback-ungranted', `this World does not let a run reach its own listener at ${target}`, declaration)
116
123
  : pass('network.ownLoopback', target, 'direct');
@@ -118,14 +125,14 @@ function decideLoopback(request, context) {
118
125
  // neither a door of the World nor a listener this run opened: a service of the host, which no World grants a guest
119
126
  // without a listener fact the two cannot be told apart here: a host service, or a listener of this run it did not declare
120
127
  const hint = declaration && declaration.network && declaration.network.ownLoopback === true ? '' : '; if it is this run\'s own listener, the run declares ownLoopback';
121
- return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's doors, its declared services and its own listeners, not ${target}${hint}`, declaration);
128
+ return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's endpoints, its declared services and its own listeners, not ${target}${hint}`, declaration);
122
129
  }
123
130
 
124
131
  /** The World's listing of an external destination: sealed refuses, an unreadable policy refuses, a policy refuses what
125
132
  * it does not list. null when listed, or when the World restricts nothing. */
126
133
  function unlisted(url, target, method, { declaration, grant = {} }) {
127
134
  const capability = 'network.egress';
128
- if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sealed World refuses untwinned host ${url.hostname}`, declaration);
135
+ if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sandbox refuses untwinned host ${url.hostname}`, declaration);
129
136
  if (grant.unreadable) return refuse(capability, target, 'grant', 'policy-unreadable', `the World network policy is unreadable (${grant.unreadable}); refusing ${url.hostname}`, declaration);
130
137
  if (grant.policy && !allowsWorldNetworkEgress(grant.policy, url, method ?? 'GET')) {
131
138
  return refuse(capability, target, 'grant', 'not-granted', `the World network policy does not allow ${String(method ?? 'GET').toUpperCase()} ${url.origin}`, declaration);
@@ -188,13 +195,14 @@ function decideEgress(request, context) {
188
195
 
189
196
  if (!internal && !declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
190
197
  // an explicit public build grant wins over a twin: that exact path is inspected and sent to its real origin
191
- const publicBuild = !strict && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, url);
198
+ const publicBuild = publicBuildGrant(grant, url);
192
199
  if (!publicBuild && facts.twin) return Object.freeze({ outcome: 'twin', capability, target, twin: facts.twin });
193
200
  if (!publicBuild && facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
194
201
  return refuse(capability, target, 'twin', 'unclaimed-path', `no twin in this World claims ${url.pathname} on ${url.hostname} (${facts.twinnedHostVendors.join(', ')})`, declaration);
195
202
  }
196
- // a guest whose HTTP no guest listener carries (an image run) reaches loopback as its own declaration says
197
- if (internal && run.guest && !facts.guestProxy && !facts.endpoint) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
203
+ // a guest's loopback is the loopback decision: a World service it must declare, and, where no guest listener carries
204
+ // its HTTP (an image run), its own listeners as its declaration says
205
+ if (internal && run.guest && (facts.endpoint ? facts.endpoint.kind === 'service' : !facts.guestProxy)) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
198
206
  if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
199
207
  const refused = unlisted(url, target, request.method, context);
200
208
  if (refused) return refused;
@@ -208,6 +216,11 @@ function decideEgress(request, context) {
208
216
  return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
209
217
  }
210
218
 
219
+ /** Whether the World's build-phase grant lists `url` itself, which then wins over a twin of its host. */
220
+ function publicBuildGrant(grant, url) {
221
+ return grant.strict !== true && !!grant.policy && grant.policy.phase === 'build' && allowsWorldNetworkEgress(grant.policy, url);
222
+ }
223
+
211
224
  /** A path the guest writes as its data (a `VOLUME`). Declared, it passes run-scoped: writable for the run, not outliving
212
225
  * it, as Docker gives an unmounted VOLUME an anonymous volume. Keeping it beyond the run needs a World grant, defined
213
226
  * at that need. */
@@ -270,4 +283,4 @@ function decideCapabilitySet(declaration, grant = {}, world = {}) {
270
283
  return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
271
284
  }
272
285
 
273
- module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
286
+ module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, publicBuildGrant, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
@@ -38,9 +38,9 @@ export type CapabilityRequest =
38
38
  export type CapabilityAnswer =
39
39
  | { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' }
40
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 };
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; readonly see?: string };
42
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 }
43
+ export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see: string }
44
44
 
45
45
  export interface CapabilitySet {
46
46
  readonly network: { readonly egress: 'none' | 'proxy'; readonly loopback: 'none' | 'own'; readonly listen: readonly number[]; readonly endpoints: readonly WorldEndpoint[] };
@@ -54,6 +54,7 @@ export const WORLD_DECLARATION_ENV: 'VOLTER_WORLD_DECLARATION';
54
54
  export function worldDeclarationFromEnv(env: Readonly<Record<string, string | undefined>>): CapabilityDeclaration | undefined;
55
55
  export function sandboxRefusal(target: string): Extract<CapabilityAnswer, { outcome: 'refuse' }>;
56
56
  export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
57
+ export function publicBuildGrant(grant: WorldGrant, url: URL): boolean;
57
58
  export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
58
59
  export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
59
60
  export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
package/dist/inject.cjs CHANGED
@@ -45,7 +45,7 @@ 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 { worldNetworkPolicyFromEnv, isWorldInternalHost } = require('./network-policy.cjs');
48
+ const { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, isWorldInternalHost } = require('./network-policy.cjs');
49
49
  const { OPEN_DECLARATION, worldDeclarationFromEnv, decideCapability, sandboxRefusal, refusalRecord, refusalSide } = require('./capability-decision.cjs');
50
50
  const { createPublicKey, createVerify } = require('crypto');
51
51
 
@@ -246,7 +246,7 @@ function resolveTwin(hostname, map, pathname) {
246
246
  // was made with (appForwardHeaders), so an application serving several hostnames answers each as in production.
247
247
  const APP_INSTANCE = Symbol.for('volter.inject.appInstance');
248
248
  // the route itself (the record, its wildcards, the forwarded headers) has one home, shared with the twins' deliveries
249
- const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa } = require('./app-route.cjs');
249
+ const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa, instanceEndpointAt, sameListenerHost } = require('./app-route.cjs');
250
250
 
251
251
  // HOSTS A TWIN CLAIMS WHILE IT RUNS (packRegistry `hostsClaimed`): no descriptor can list a name a person makes answer
252
252
  // to a vendor, so each claiming twin lists its own at its door, read here every five seconds from the moment the
@@ -323,7 +323,7 @@ function isLocalOrPrivateHost(hostname) {
323
323
  }
324
324
 
325
325
  function strictEgressEnabled() {
326
- return process.env.VOLTER_TWIN_STRICT_EGRESS === '1' || process.env.VOLTER_TWIN_STRICT_EGRESS === 'true';
326
+ return worldStrictEgress(process.env);
327
327
  }
328
328
 
329
329
  // THE WORLD'S DECISION (docs/contributing/architecture.md, "Capability decisions"): every seam below asks
@@ -332,9 +332,7 @@ function strictEgressEnabled() {
332
332
  // image's declaration (VOLTER_WORLD_DECLARATION) and is confined from the host's network. What the World grants is its
333
333
  // published policy. The seams supply what they observe: the twin route, the vendors sharing a host, the World's doors.
334
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 };
335
+ return worldGrantFromEnv(process.env);
338
336
  }
339
337
  /** This process's run: its declaration, and whether it is confined from the host's network. */
340
338
  function worldRun() {
@@ -356,7 +354,9 @@ function httpFacts(url, pathname) {
356
354
  twin: resolveTwin(url.hostname, MAP, pathname === undefined ? url.pathname : pathname),
357
355
  twinnedHostVendors: twinnedHostVendors(url.hostname, MAP),
358
356
  ...(guestProxy && !/^https?:$/.test(url.protocol) ? { guestProxy: null } : {}),
359
- endpoint: worldRun().guest && (addressedToWorld(url) || explicitTwinEndpoint(url) || (proxy && url.origin === proxy.origin)) ? { kind: 'door' } : null,
357
+ endpoint: !worldRun().guest ? null
358
+ : addressedToWorld(url) || explicitTwinEndpoint(url) || (proxy && url.origin === proxy.origin) ? { kind: 'door' }
359
+ : isLocalOrPrivateHost(url.hostname) ? guestEndpointAt(url.hostname, Number(url.port || (url.protocol === 'https:' ? 443 : 80))) : null,
360
360
  };
361
361
  }
362
362
  /** A refusal as an Error: the seam's own message, the side that refused, and the World's refusal record. */
@@ -995,9 +995,9 @@ function twinnedHostVendors(hostname, map) {
995
995
  function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
996
996
  const vendors = twinnedHostVendors(hostname, map);
997
997
  return `[twin-inject] host ${hostname} is twinned in this world (configured twin${vendors.length === 1 ? '' : 's'}: ${vendors.join(', ')}), `
998
- + `but path ${pathname} belongs to no pack in this world — no twin serves that API yet. `
998
+ + `but path ${pathname} belongs to no twin in this world — no twin serves that API yet. `
999
999
  + `Refusing loudly rather than answering from the wrong twin or leaking to the real vendor. `
1000
- + `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new pack.`;
1000
+ + `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new twin.`;
1001
1001
  }
1002
1002
 
1003
1003
  function blockedClientRequest(message) {
@@ -1210,7 +1210,7 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
1210
1210
  if (!url) return null;
1211
1211
  const internal = isLocalOrPrivateHost(host);
1212
1212
  const facts = internal
1213
- ? { endpoint: worldRun().guest && guestDoorAt(host, port) ? { kind: 'door' } : null }
1213
+ ? { endpoint: worldRun().guest ? guestEndpointAt(host, port) : null }
1214
1214
  : { twin: resolveTwin(host, MAP), twinnedHostVendors: twinnedHostVendors(host, MAP), twinOrigin: isTwinOriginHost(host) };
1215
1215
  const answer = decide(url, datagram ? 'datagram' : 'socket', facts, port);
1216
1216
  if (answer.outcome !== 'refuse') return null;
@@ -1232,10 +1232,18 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
1232
1232
  return refusalError(answer, message);
1233
1233
  }
1234
1234
 
1235
+ /** What a guest's loopback destination is to the World: one of its doors (its proxy, its origin, a twin, a twin's side),
1236
+ * a service it records, or neither. The instance's records classify it (app-route.cjs instanceEndpointAt), never the
1237
+ * application's own environment. */
1238
+ function guestEndpointAt(host, port) {
1239
+ if (guestDoorAt(host, port)) return { kind: 'door' };
1240
+ return instanceEndpointAt(process.env.VOLTER_WORLD_INSTANCE, host, port);
1241
+ }
1242
+
1235
1243
  /** 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
1244
  function guestDoorAt(host, port) {
1237
1245
  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);
1246
+ return doors.some(door => sameListenerHost(host, door.hostname) && Number(door.port || (door.protocol === 'https:' ? 443 : 80)) === port);
1239
1247
  }
1240
1248
 
1241
1249
  function refusedSocket(refusal) {
@@ -86,6 +86,18 @@ function parseWorldNetworkPolicy(value) {
86
86
  return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads, ownLoopback: policy.ownLoopback });
87
87
  }
88
88
 
89
+ /** Whether `env` seals the World: no external egress, whatever its network policy lists. */
90
+ function worldStrictEgress(env) {
91
+ return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
92
+ }
93
+
94
+ /** What the World grants, read from `env` for the capability decision: an unreadable policy is a grant that refuses. */
95
+ function worldGrantFromEnv(env) {
96
+ let policy; let unreadable;
97
+ try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
98
+ return { policy, strict: worldStrictEgress(env), unreadable };
99
+ }
100
+
89
101
  function worldNetworkPolicyFromEnv(env) {
90
102
  const serialized = env[WORLD_NETWORK_POLICY_ENV];
91
103
  if (serialized === undefined) return undefined;
@@ -183,13 +195,10 @@ function isWorldInternalHost(hostname) {
183
195
  function worldEgressRefusal(value, env = process.env, method = 'GET') {
184
196
  let url;
185
197
  try { url = new URL(String(value)); } catch { return `unparseable destination ${String(value)}`; }
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
198
  const { OPEN_DECLARATION, decideCapability, refusalSide } = require('./capability-decision.cjs');
190
199
  const answer = decideCapability({ kind: 'egress', url, method, transport: 'http' },
191
- { run: { guest: false }, declaration: OPEN_DECLARATION, grant: { policy, strict, unreadable }, facts: {} });
200
+ { run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: {} });
192
201
  return answer.outcome === 'refuse' ? `${answer.reason} (${refusalSide(answer)})` : null;
193
202
  }
194
203
 
195
- module.exports = { PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
204
+ module.exports = { worldStrictEgress, worldGrantFromEnv, PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
@@ -4,6 +4,8 @@ export const WORLD_NETWORK_POLICY_ENV: 'VOLTER_WORLD_NETWORK_POLICY';
4
4
  export function normalizeWorldNetwork(value: unknown): WorldNetwork;
5
5
  export function createWorldNetworkPolicy(world: string, network: WorldNetwork): WorldNetworkPolicy;
6
6
  export function parseWorldNetworkPolicy(value: unknown): WorldNetworkPolicy;
7
+ export function worldStrictEgress(env: Readonly<Record<string, string | undefined>>): boolean;
8
+ export function worldGrantFromEnv(env: Readonly<Record<string, string | undefined>>): { readonly policy?: WorldNetworkPolicy; readonly strict: boolean; readonly unreadable?: string };
7
9
  export function worldNetworkPolicyFromEnv(env: Readonly<Record<string, string | undefined>>): WorldNetworkPolicy | undefined;
8
10
  export const READ_METHODS: readonly string[];
9
11
  export const PUBLIC_BUILD_READS: readonly string[];
package/inject.cjs CHANGED
@@ -45,7 +45,7 @@ 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 { worldNetworkPolicyFromEnv, isWorldInternalHost } = require('./network-policy.cjs');
48
+ const { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, isWorldInternalHost } = require('./network-policy.cjs');
49
49
  const { OPEN_DECLARATION, worldDeclarationFromEnv, decideCapability, sandboxRefusal, refusalRecord, refusalSide } = require('./capability-decision.cjs');
50
50
  const { createPublicKey, createVerify } = require('crypto');
51
51
 
@@ -246,7 +246,7 @@ function resolveTwin(hostname, map, pathname) {
246
246
  // was made with (appForwardHeaders), so an application serving several hostnames answers each as in production.
247
247
  const APP_INSTANCE = Symbol.for('volter.inject.appInstance');
248
248
  // the route itself (the record, its wildcards, the forwarded headers) has one home, shared with the twins' deliveries
249
- const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa } = require('./app-route.cjs');
249
+ const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa, instanceEndpointAt, sameListenerHost } = require('./app-route.cjs');
250
250
 
251
251
  // HOSTS A TWIN CLAIMS WHILE IT RUNS (packRegistry `hostsClaimed`): no descriptor can list a name a person makes answer
252
252
  // to a vendor, so each claiming twin lists its own at its door, read here every five seconds from the moment the
@@ -323,7 +323,7 @@ function isLocalOrPrivateHost(hostname) {
323
323
  }
324
324
 
325
325
  function strictEgressEnabled() {
326
- return process.env.VOLTER_TWIN_STRICT_EGRESS === '1' || process.env.VOLTER_TWIN_STRICT_EGRESS === 'true';
326
+ return worldStrictEgress(process.env);
327
327
  }
328
328
 
329
329
  // THE WORLD'S DECISION (docs/contributing/architecture.md, "Capability decisions"): every seam below asks
@@ -332,9 +332,7 @@ function strictEgressEnabled() {
332
332
  // image's declaration (VOLTER_WORLD_DECLARATION) and is confined from the host's network. What the World grants is its
333
333
  // published policy. The seams supply what they observe: the twin route, the vendors sharing a host, the World's doors.
334
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 };
335
+ return worldGrantFromEnv(process.env);
338
336
  }
339
337
  /** This process's run: its declaration, and whether it is confined from the host's network. */
340
338
  function worldRun() {
@@ -356,7 +354,9 @@ function httpFacts(url, pathname) {
356
354
  twin: resolveTwin(url.hostname, MAP, pathname === undefined ? url.pathname : pathname),
357
355
  twinnedHostVendors: twinnedHostVendors(url.hostname, MAP),
358
356
  ...(guestProxy && !/^https?:$/.test(url.protocol) ? { guestProxy: null } : {}),
359
- endpoint: worldRun().guest && (addressedToWorld(url) || explicitTwinEndpoint(url) || (proxy && url.origin === proxy.origin)) ? { kind: 'door' } : null,
357
+ endpoint: !worldRun().guest ? null
358
+ : addressedToWorld(url) || explicitTwinEndpoint(url) || (proxy && url.origin === proxy.origin) ? { kind: 'door' }
359
+ : isLocalOrPrivateHost(url.hostname) ? guestEndpointAt(url.hostname, Number(url.port || (url.protocol === 'https:' ? 443 : 80))) : null,
360
360
  };
361
361
  }
362
362
  /** A refusal as an Error: the seam's own message, the side that refused, and the World's refusal record. */
@@ -995,9 +995,9 @@ function twinnedHostVendors(hostname, map) {
995
995
  function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
996
996
  const vendors = twinnedHostVendors(hostname, map);
997
997
  return `[twin-inject] host ${hostname} is twinned in this world (configured twin${vendors.length === 1 ? '' : 's'}: ${vendors.join(', ')}), `
998
- + `but path ${pathname} belongs to no pack in this world — no twin serves that API yet. `
998
+ + `but path ${pathname} belongs to no twin in this world — no twin serves that API yet. `
999
999
  + `Refusing loudly rather than answering from the wrong twin or leaking to the real vendor. `
1000
- + `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new pack.`;
1000
+ + `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new twin.`;
1001
1001
  }
1002
1002
 
1003
1003
  function blockedClientRequest(message) {
@@ -1210,7 +1210,7 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
1210
1210
  if (!url) return null;
1211
1211
  const internal = isLocalOrPrivateHost(host);
1212
1212
  const facts = internal
1213
- ? { endpoint: worldRun().guest && guestDoorAt(host, port) ? { kind: 'door' } : null }
1213
+ ? { endpoint: worldRun().guest ? guestEndpointAt(host, port) : null }
1214
1214
  : { twin: resolveTwin(host, MAP), twinnedHostVendors: twinnedHostVendors(host, MAP), twinOrigin: isTwinOriginHost(host) };
1215
1215
  const answer = decide(url, datagram ? 'datagram' : 'socket', facts, port);
1216
1216
  if (answer.outcome !== 'refuse') return null;
@@ -1232,10 +1232,18 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
1232
1232
  return refusalError(answer, message);
1233
1233
  }
1234
1234
 
1235
+ /** What a guest's loopback destination is to the World: one of its doors (its proxy, its origin, a twin, a twin's side),
1236
+ * a service it records, or neither. The instance's records classify it (app-route.cjs instanceEndpointAt), never the
1237
+ * application's own environment. */
1238
+ function guestEndpointAt(host, port) {
1239
+ if (guestDoorAt(host, port)) return { kind: 'door' };
1240
+ return instanceEndpointAt(process.env.VOLTER_WORLD_INSTANCE, host, port);
1241
+ }
1242
+
1235
1243
  /** 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
1244
  function guestDoorAt(host, port) {
1237
1245
  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);
1246
+ return doors.some(door => sameListenerHost(host, door.hostname) && Number(door.port || (door.protocol === 'https:' ? 443 : 80)) === port);
1239
1247
  }
1240
1248
 
1241
1249
  function refusedSocket(refusal) {
@@ -86,6 +86,18 @@ function parseWorldNetworkPolicy(value) {
86
86
  return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads, ownLoopback: policy.ownLoopback });
87
87
  }
88
88
 
89
+ /** Whether `env` seals the World: no external egress, whatever its network policy lists. */
90
+ function worldStrictEgress(env) {
91
+ return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
92
+ }
93
+
94
+ /** What the World grants, read from `env` for the capability decision: an unreadable policy is a grant that refuses. */
95
+ function worldGrantFromEnv(env) {
96
+ let policy; let unreadable;
97
+ try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
98
+ return { policy, strict: worldStrictEgress(env), unreadable };
99
+ }
100
+
89
101
  function worldNetworkPolicyFromEnv(env) {
90
102
  const serialized = env[WORLD_NETWORK_POLICY_ENV];
91
103
  if (serialized === undefined) return undefined;
@@ -183,13 +195,10 @@ function isWorldInternalHost(hostname) {
183
195
  function worldEgressRefusal(value, env = process.env, method = 'GET') {
184
196
  let url;
185
197
  try { url = new URL(String(value)); } catch { return `unparseable destination ${String(value)}`; }
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
198
  const { OPEN_DECLARATION, decideCapability, refusalSide } = require('./capability-decision.cjs');
190
199
  const answer = decideCapability({ kind: 'egress', url, method, transport: 'http' },
191
- { run: { guest: false }, declaration: OPEN_DECLARATION, grant: { policy, strict, unreadable }, facts: {} });
200
+ { run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: {} });
192
201
  return answer.outcome === 'refuse' ? `${answer.reason} (${refusalSide(answer)})` : null;
193
202
  }
194
203
 
195
- module.exports = { PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
204
+ module.exports = { worldStrictEgress, worldGrantFromEnv, PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
@@ -4,6 +4,8 @@ export const WORLD_NETWORK_POLICY_ENV: 'VOLTER_WORLD_NETWORK_POLICY';
4
4
  export function normalizeWorldNetwork(value: unknown): WorldNetwork;
5
5
  export function createWorldNetworkPolicy(world: string, network: WorldNetwork): WorldNetworkPolicy;
6
6
  export function parseWorldNetworkPolicy(value: unknown): WorldNetworkPolicy;
7
+ export function worldStrictEgress(env: Readonly<Record<string, string | undefined>>): boolean;
8
+ export function worldGrantFromEnv(env: Readonly<Record<string, string | undefined>>): { readonly policy?: WorldNetworkPolicy; readonly strict: boolean; readonly unreadable?: string };
7
9
  export function worldNetworkPolicyFromEnv(env: Readonly<Record<string, string | undefined>>): WorldNetworkPolicy | undefined;
8
10
  export const READ_METHODS: readonly string[];
9
11
  export const PUBLIC_BUILD_READS: readonly string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.67",
3
+ "version": "3.0.69",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",