@volter/world-runtime 3.0.166 → 3.0.167
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/dist/known-external-services.json +0 -1
- package/dist/src/actions-runner.js +53 -14
- package/dist/src/attach.d.ts +9 -0
- package/dist/src/attach.js +9 -2
- package/dist/src/cli-commands.js +19 -3
- package/dist/src/console-apart.js +3 -2
- package/dist/src/credential-door.d.ts +8 -0
- package/dist/src/credential-door.js +54 -0
- package/dist/src/fixture-env.js +2 -2
- package/dist/src/forwardable-headers.d.ts +1 -0
- package/dist/src/forwardable-headers.js +19 -0
- package/dist/src/go-mysql-binding.d.ts +2 -0
- package/dist/src/go-mysql-binding.js +7 -1
- package/dist/src/guest-runner.js +8 -4
- package/dist/src/host-ready.d.ts +7 -3
- package/dist/src/host-ready.js +16 -3
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +4 -1
- package/dist/src/infra.js +10 -5
- package/dist/src/init.d.ts +5 -0
- package/dist/src/init.js +157 -23
- package/dist/src/managed-api-needs.d.ts +101 -0
- package/dist/src/managed-api-needs.js +312 -0
- package/dist/src/managed-api-source.d.ts +24 -0
- package/dist/src/managed-api-source.js +59 -0
- package/dist/src/mongodb-backing.js +33 -58
- package/dist/src/mongodb-command.d.ts +21 -0
- package/dist/src/mongodb-command.js +317 -0
- package/dist/src/pglite-host.mjs +2 -2
- package/dist/src/placeholder.d.ts +24 -1
- package/dist/src/placeholder.js +105 -13
- package/dist/src/process-signal.d.mts +2 -0
- package/dist/src/process-signal.mjs +44 -2
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +8 -1
- package/dist/src/redirect-proxy.js +4 -19
- package/dist/src/reflect.js +2 -2
- package/dist/src/runtime.d.ts +5 -1
- package/dist/src/runtime.js +309 -58
- package/dist/src/schema.d.ts +10 -0
- package/dist/src/schema.js +76 -3
- package/dist/src/serve.js +24 -4
- package/dist/src/served-world.d.ts +2 -1
- package/dist/src/served-world.js +6 -4
- package/dist/src/service-command.d.ts +7 -3
- package/dist/src/service-command.js +53 -22
- package/dist/src/service-environment.d.ts +4 -0
- package/dist/src/service-environment.js +21 -0
- package/dist/src/service-recorder.js +9 -13
- package/known-external-services.json +0 -1
- package/package.json +5 -5
- package/src/actions-runner.ts +38 -10
- package/src/attach.ts +15 -2
- package/src/cli-commands.ts +18 -3
- package/src/console-apart.ts +3 -2
- package/src/credential-door.ts +54 -0
- package/src/fixture-env.ts +2 -2
- package/src/forwardable-headers.ts +15 -0
- package/src/go-mysql-binding.ts +8 -1
- package/src/guest-runner.ts +8 -4
- package/src/host-ready.ts +12 -3
- package/src/index.ts +5 -1
- package/src/infra.ts +8 -3
- package/src/init.ts +145 -23
- package/src/managed-api-needs.ts +285 -0
- package/src/managed-api-source.ts +67 -0
- package/src/mongodb-backing.ts +32 -48
- package/src/mongodb-command.ts +238 -0
- package/src/pglite-host.mjs +2 -2
- package/src/placeholder.ts +90 -13
- package/src/process-signal.d.mts +2 -0
- package/src/process-signal.mjs +44 -2
- package/src/project-inspect.ts +9 -2
- package/src/redirect-proxy.ts +4 -15
- package/src/reflect.ts +2 -2
- package/src/runtime.ts +270 -44
- package/src/schema.ts +73 -3
- package/src/serve.ts +22 -4
- package/src/served-world.ts +7 -5
- package/src/service-command.ts +41 -19
- package/src/service-environment.ts +21 -0
- package/src/service-recorder.ts +9 -9
package/src/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { errorMessage } from './error-message.ts';
|
|
2
|
+
import { isScalarCredentialField } from './credential-door.ts';
|
|
2
3
|
import { normalizeWorldNetwork, type WorldNetwork } from '@volter/world-core/network-policy';
|
|
3
4
|
|
|
4
5
|
export type WorldServiceType = 'twin' | 'process' | 'external';
|
|
@@ -172,7 +173,7 @@ export type WorldServiceConfig = {
|
|
|
172
173
|
/** The credential this twin issues the World's applications at boot (its pack's `credentialDoor`, copied by `init`):
|
|
173
174
|
* `up` POSTs `body` to `path` on the running twin and sets each name of `fill` whose value is `$issue:<this id>` to
|
|
174
175
|
* the answer's field `fill` names for it. */
|
|
175
|
-
issues?: { path: string; body: Record<string, unknown>; fill: Record<string, string
|
|
176
|
+
issues?: { path: string; body: Record<string, unknown>; fill: Record<string, string>; required?: string[] };
|
|
176
177
|
/**
|
|
177
178
|
* The twin PACKAGE this service boots from (`@volter/twin-<vendor>`), resolved from the world
|
|
178
179
|
* root at boot by node's own `node_modules` walk (docs/concepts/worlds.md#the-config-and-the-running-world).
|
|
@@ -458,6 +459,9 @@ export type WorldInstance = {
|
|
|
458
459
|
};
|
|
459
460
|
services: Record<string, WorldServiceInstance>;
|
|
460
461
|
env: Record<string, string>;
|
|
462
|
+
/** Required owners fixed at this boot; the digest binds their actual scalar responses in env without duplicating values.
|
|
463
|
+
* Omitted digest means issuance has not finished. Absent metadata is readable for cleanup, but not a live application declaration. */
|
|
464
|
+
requiredCredentials?: { version: 1; owners: Record<string, string>; valuesSha256?: string };
|
|
461
465
|
/** Always written at boot; optional only so cleanup can read incomplete legacy records. */
|
|
462
466
|
networkCeiling?: WorldNetwork | 'unrestricted';
|
|
463
467
|
bootServices?: string[];
|
|
@@ -632,7 +636,7 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
632
636
|
if (service.branchState !== undefined && service.branchState !== 'unsupported') throw new Error(`Service ${service.id} branchState must be unsupported in ${path}`);
|
|
633
637
|
const exports = [service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}), ...Object.keys(service.cliRedirect ?? {}), ...(Array.isArray(service.external?.discover) ? service.external.discover.map(entry => entry?.as) : [])];
|
|
634
638
|
if (exports.some(name => name !== undefined && !isEnvironmentName(name))) throw new Error(`Service ${service.id} bindings must name environment variables in ${path}`);
|
|
635
|
-
if (service.issues !== undefined
|
|
639
|
+
if (service.issues !== undefined) readCredentialDoor(service.issues, service.id, path);
|
|
636
640
|
if (service.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined || exports.includes('VOLTER_WORLD_NETWORK_POLICY')) throw new Error(`Service ${service.id} cannot replace World network permission in ${path}`);
|
|
637
641
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id)) throw new Error(`Invalid service id "${service.id}" in ${path}`);
|
|
638
642
|
if (ids.has(service.id)) throw new Error(`Duplicate service id "${service.id}" in ${path}`);
|
|
@@ -762,6 +766,7 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
762
766
|
}
|
|
763
767
|
}
|
|
764
768
|
}
|
|
769
|
+
assertRequiredCredentialBindings(config as WorldConfig, path);
|
|
765
770
|
return config as WorldConfig;
|
|
766
771
|
}
|
|
767
772
|
|
|
@@ -774,6 +779,67 @@ function assertKeys(value: Record<string, unknown>, allowed: string[], what: str
|
|
|
774
779
|
if (unexpected.length) throw new Error(`${what} has unknown field(s) ${unexpected.join(', ')} in ${path}`);
|
|
775
780
|
}
|
|
776
781
|
|
|
782
|
+
/** Credential metadata is read completely before normalization or serialization can discard a field. */
|
|
783
|
+
function readCredentialDoor(value: unknown, service: string, path: string): NonNullable<WorldServiceConfig['issues']> {
|
|
784
|
+
const what = `Service ${service} issues`;
|
|
785
|
+
if (!record(value)) throw new Error(`${what} must be an object in ${path}`);
|
|
786
|
+
assertKeys(value, ['path', 'body', 'fill', 'required'], what, path);
|
|
787
|
+
if (!Object.hasOwn(value, 'path') || typeof value.path !== 'string' || value.path === ''
|
|
788
|
+
|| !Object.hasOwn(value, 'body') || !record(value.body)
|
|
789
|
+
|| !Object.hasOwn(value, 'fill') || !record(value.fill)) {
|
|
790
|
+
throw new Error(`${what} must define non-empty path, object body and string→string fill in ${path}`);
|
|
791
|
+
}
|
|
792
|
+
const fill: Array<[string, string]> = [];
|
|
793
|
+
for (const [name, field] of Object.entries(value.fill)) {
|
|
794
|
+
if (!isEnvironmentName(name) || typeof field !== 'string') throw new Error(`${what} fill must map environment names to strings in ${path}`);
|
|
795
|
+
fill.push([name, field]);
|
|
796
|
+
}
|
|
797
|
+
const required: string[] = [];
|
|
798
|
+
if (Object.hasOwn(value, 'required')) {
|
|
799
|
+
if (!Array.isArray(value.required)) throw new Error(`${what} required must be an array in ${path}`);
|
|
800
|
+
const seen = new Set<string>();
|
|
801
|
+
for (const name of value.required) {
|
|
802
|
+
if (!isEnvironmentName(name) || seen.has(name)) throw new Error(`${what} required must contain unique environment names in ${path}`);
|
|
803
|
+
const field = value.fill[name];
|
|
804
|
+
if (!Object.hasOwn(value.fill, name) || typeof field !== 'string' || !isScalarCredentialField(field)) {
|
|
805
|
+
throw new Error(`${what} required ${name} must name its direct scalar fill in ${path}`);
|
|
806
|
+
}
|
|
807
|
+
seen.add(name); required.push(name);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return { path: value.path, body: value.body, fill: Object.fromEntries(fill),
|
|
811
|
+
...(Object.hasOwn(value, 'required') ? { required } : {}) };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Required names belong to one twin's exact pending reference, with no competing output or local override. */
|
|
815
|
+
export function assertRequiredCredentialBindings(config: WorldConfig, path: string): void {
|
|
816
|
+
const owners = new Map<string, string>();
|
|
817
|
+
for (const service of config.services) {
|
|
818
|
+
if (service.issues === undefined) continue;
|
|
819
|
+
const door = readCredentialDoor(service.issues, service.id, path);
|
|
820
|
+
if (door.required !== undefined && (service.type ?? 'twin') !== 'twin') {
|
|
821
|
+
throw new Error(`Service ${service.id} required credential bindings belong to a twin in ${path}`);
|
|
822
|
+
}
|
|
823
|
+
for (const name of door.required ?? []) {
|
|
824
|
+
if (owners.has(name)) throw new Error(`Required credential ${name} has competing issuers in ${path}`);
|
|
825
|
+
if (!Object.hasOwn(config.env ?? {}, name) || config.env?.[name] !== `${ISSUE_ENV_PREFIX}${service.id}`) {
|
|
826
|
+
throw new Error(`Required credential ${name} must be exactly ${ISSUE_ENV_PREFIX}${service.id} in ${path}`);
|
|
827
|
+
}
|
|
828
|
+
owners.set(name, service.id);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
for (const service of config.services) {
|
|
832
|
+
const endpoints = new Set([service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}),
|
|
833
|
+
...Object.keys(service.cliRedirect ?? {}), ...(service.external?.discover ?? []).map(entry => entry.as)]);
|
|
834
|
+
for (const [name, owner] of owners) {
|
|
835
|
+
if (endpoints.has(name) || Object.hasOwn(service.env ?? {}, name)
|
|
836
|
+
|| (service.id !== owner && Object.hasOwn(service.issues?.fill ?? {}, name))) {
|
|
837
|
+
throw new Error(`Required credential ${name} owned by ${owner} conflicts with service ${service.id} in ${path}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
777
843
|
export const DEFAULT_WORLD_SELECTION: WorldSelection = {
|
|
778
844
|
include: [{ usage: 'application' }],
|
|
779
845
|
exclude: [],
|
|
@@ -810,7 +876,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
810
876
|
assertKeys(source, ['package', 'version'], 'service.source', path);
|
|
811
877
|
assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane', 'branchState'], 'service.execution', path);
|
|
812
878
|
assertKeys(endpoint, ['port', 'portReason', 'ready'], 'service.endpoint', path);
|
|
813
|
-
assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover'], 'service.bindings', path);
|
|
879
|
+
assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover', 'issues'], 'service.bindings', path);
|
|
814
880
|
const process = execution.process === undefined ? {} : execution.process;
|
|
815
881
|
const environment = execution.environment === undefined ? {} : execution.environment;
|
|
816
882
|
if (!record(process) || !record(environment)) throw new Error(`Service execution process/environment must be objects in ${path}`);
|
|
@@ -867,6 +933,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
867
933
|
...(bindings.injectEnv === undefined ? {} : { injectEnv: bindings.injectEnv as string }),
|
|
868
934
|
...(bindings.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: bindings.injectEnvTemplates as Record<string, string> }),
|
|
869
935
|
...(bindings.cliRedirect === undefined ? {} : { cliRedirect: bindings.cliRedirect as Record<string, string> }),
|
|
936
|
+
...(!Object.hasOwn(bindings, 'issues') ? {} : { issues: readCredentialDoor(bindings.issues, value.id, path) }),
|
|
870
937
|
...(value.root === undefined ? {} : { root: value.root as WorldServiceConfig['root'] }),
|
|
871
938
|
...(external === undefined ? {} : { external }),
|
|
872
939
|
};
|
|
@@ -984,6 +1051,7 @@ export function assertWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
984
1051
|
return normalizeV2(value, path);
|
|
985
1052
|
}
|
|
986
1053
|
const legacy = assertLegacyWorldConfig(value, path);
|
|
1054
|
+
if (legacy.services.some(service => service.issues?.required !== undefined)) throw new Error(`Required credential bindings require a format-2 World manifest in ${path}`);
|
|
987
1055
|
return { ...legacy, manifestVersion: 1 };
|
|
988
1056
|
}
|
|
989
1057
|
|
|
@@ -1021,6 +1089,7 @@ function serviceToV2(service: WorldServiceConfig): Record<string, unknown> {
|
|
|
1021
1089
|
...(service.ready === undefined ? {} : { ready: service.ready }),
|
|
1022
1090
|
};
|
|
1023
1091
|
const bindings = {
|
|
1092
|
+
...(service.issues === undefined ? {} : { issues: readCredentialDoor(service.issues, service.id, 'World serialization') }),
|
|
1024
1093
|
...(service.injectEnv === undefined ? {} : { injectEnv: service.injectEnv }),
|
|
1025
1094
|
...(service.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: service.injectEnvTemplates }),
|
|
1026
1095
|
...(service.cliRedirect === undefined ? {} : { cliRedirect: service.cliRedirect }),
|
|
@@ -1044,6 +1113,7 @@ function serviceToV2(service: WorldServiceConfig): Record<string, unknown> {
|
|
|
1044
1113
|
|
|
1045
1114
|
/** Serialize normalized intent as the committed format-2 manifest. */
|
|
1046
1115
|
export function worldConfigDocument(config: WorldConfig, selection: WorldSelection = config.selection ?? DEFAULT_WORLD_SELECTION): WorldManifestV2 & Record<string, unknown> {
|
|
1116
|
+
assertLegacyWorldConfig({ ...config, manifestVersion: 2 }, 'World serialization');
|
|
1047
1117
|
return {
|
|
1048
1118
|
schemaVersion: WORLD_SCHEMA_VERSION,
|
|
1049
1119
|
metadata: { id: config.id, ...(config.description === undefined ? {} : { description: config.description }) },
|
package/src/serve.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
10
10
|
import { deriveWorldManifest, type WorldManifest } from './attach.ts';
|
|
11
|
-
import { statusWorld } from './runtime.ts';
|
|
11
|
+
import { refuseRequiredCredentialTransport, statusWorld } from './runtime.ts';
|
|
12
|
+
import { errorMessage } from './error-message.ts';
|
|
12
13
|
import { worldAttachmentNetworkEnv, worldStrictEgress } from '@volter/world-core/network-policy';
|
|
13
14
|
|
|
14
15
|
export const MANIFEST_PATH = '/.well-known/volter-world';
|
|
@@ -16,7 +17,9 @@ export const MANIFEST_PATH = '/.well-known/volter-world';
|
|
|
16
17
|
/** The manifest a REMOTE attacher sees: every vendor behind the one advertised
|
|
17
18
|
* door. The serving side keeps routing against its own loopback env. */
|
|
18
19
|
export function advertiseWorldManifest(name: string, root: string, advertisedOrigin: string): WorldManifest {
|
|
19
|
-
const
|
|
20
|
+
const status = statusWorld(name, root);
|
|
21
|
+
refuseRequiredCredentialTransport(status, 'remote WorldManifest publication');
|
|
22
|
+
const local = deriveWorldManifest(name, status.env);
|
|
20
23
|
const vendors: Record<string, string> = {};
|
|
21
24
|
// /__vendor/<id> keeps the fetch flow routable through the one door (the
|
|
22
25
|
// injector's fetch patch rewrites URLs wholesale and loses the vendor Host);
|
|
@@ -57,8 +60,15 @@ export async function startManifestServer(options: ManifestServerOptions): Promi
|
|
|
57
60
|
response.end('volter-world: GET ' + MANIFEST_PATH + '\n');
|
|
58
61
|
return;
|
|
59
62
|
}
|
|
63
|
+
let body: string;
|
|
64
|
+
try { body = `${JSON.stringify(options.manifest(), null, 2)}\n`; }
|
|
65
|
+
catch (error) {
|
|
66
|
+
response.writeHead(503, { 'content-type': 'text/plain' });
|
|
67
|
+
response.end(`World manifest unavailable: ${errorMessage(error)}\n`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
60
70
|
response.writeHead(200, { 'content-type': 'application/json' });
|
|
61
|
-
response.end(
|
|
71
|
+
response.end(body);
|
|
62
72
|
});
|
|
63
73
|
await new Promise<void>((resolveListen, reject) => {
|
|
64
74
|
server.once('error', reject);
|
|
@@ -81,18 +91,26 @@ export async function fetchRemoteManifest(ref: string, options: { token?: string
|
|
|
81
91
|
const response = await fetch(url, options.token === undefined ? undefined : { headers: { authorization: `Bearer ${options.token}` } });
|
|
82
92
|
if (!response.ok) throw new Error(`remote world manifest: ${url} → ${response.status}`);
|
|
83
93
|
const manifest = (await response.json()) as WorldManifest;
|
|
84
|
-
if (
|
|
94
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)
|
|
95
|
+
|| typeof manifest.name !== 'string' || typeof manifest.vendors !== 'object' || manifest.vendors === null) {
|
|
85
96
|
throw new Error(`remote world manifest: ${url} returned no manifest`);
|
|
86
97
|
}
|
|
98
|
+
refuseRequiredRemoteManifest(manifest);
|
|
87
99
|
return manifest;
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/** Required credentials have no approved remote transport; their field must never be silently ignored. */
|
|
103
|
+
function refuseRequiredRemoteManifest(manifest: WorldManifest): void {
|
|
104
|
+
if (Object.hasOwn(manifest, 'requiredCredentials')) throw new Error('Remote WorldManifest required credential transport is unsupported; refusing attachment');
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
/** The env a remote env-attachment exports: vendor twin URLs (the advertised
|
|
91
108
|
* door), suggested fake credentials, CA trust, and the injector preload. */
|
|
92
109
|
export function remoteAttachEnv(
|
|
93
110
|
manifest: WorldManifest,
|
|
94
111
|
options: { caFile?: string; injectPath: string; worldRef?: string; callerEnv?: Readonly<Record<string, string | undefined>> },
|
|
95
112
|
): Record<string, string> {
|
|
113
|
+
refuseRequiredRemoteManifest(manifest);
|
|
96
114
|
const env: Record<string, string> = { ...manifest.env,
|
|
97
115
|
...worldAttachmentNetworkEnv(options.worldRef ?? manifest.name, manifest.network, options.callerEnv ?? process.env, worldStrictEgress(manifest.env)),
|
|
98
116
|
};
|
package/src/served-world.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { describeActionFields, resourceBlobFile } from '@volter/world-core/runti
|
|
|
53
53
|
import { worldLedgers } from './changeset.ts';
|
|
54
54
|
import { clockFile, downWorld, instanceDir, isWorldOrigin, listWorlds, readWorldBootRecord, statusWorld, upWorld } from './runtime.ts';
|
|
55
55
|
import { assertChangesetRules, type WorldChangesetRules, type WorldInstance } from './schema.ts';
|
|
56
|
+
import { forwardableHeaders } from './forwardable-headers.ts';
|
|
56
57
|
|
|
57
58
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
58
59
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
@@ -194,7 +195,7 @@ function configRefFor(worldRoot: string, name: string): string {
|
|
|
194
195
|
/** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
|
|
195
196
|
* `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
|
|
196
197
|
* with the call — and keeps them beside the world like `boot` does. */
|
|
197
|
-
export type MountedWorld = { maxRequestBodySize: number; name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** the same site at its local address, its framed pages carrying the board's picture script (localSite) */ localSite: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
|
|
198
|
+
export type MountedWorld = { maxRequestBodySize: number; name: string; served: string; root: string; readonly token: string; readonly readToken: string; /** whether its boot has finished: until then its twins are not known */ ready: () => boolean; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** the same site at its local address, its framed pages carrying the board's picture script (localSite) */ localSite: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
|
|
198
199
|
/** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
|
|
199
200
|
/** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
|
|
200
201
|
* session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
|
|
@@ -244,7 +245,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
244
245
|
if (!target) return null;
|
|
245
246
|
// the body as bytes, none when empty: an empty body streamed is sent chunked with no last chunk (Bun 1.2), and the
|
|
246
247
|
// twin's server reads the next request on the connection as its continuation (HPE_INVALID_CHUNK_SIZE)
|
|
247
|
-
return async (request) => { const at = new URL(request.url); const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: request.headers, ...(body?.byteLength ? { body } : {}), redirect: 'manual' } as RequestInit); };
|
|
248
|
+
return async (request) => { const at = new URL(request.url); const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: forwardableHeaders(request.headers), ...(body?.byteLength ? { body } : {}), redirect: 'manual' } as RequestInit); };
|
|
248
249
|
},
|
|
249
250
|
};
|
|
250
251
|
const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
|
|
@@ -255,6 +256,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
255
256
|
name, served, root: worldRoot,
|
|
256
257
|
maxRequestBodySize: Math.max(1024 * 1024 * 1024, ...loadWorldConfig(configRef, worldRoot).config.services.map((service) => packFacts()[service.package?.replace(/^@volter\/twin-/, '') ?? service.id]?.maxRequestBodySize ?? 0)),
|
|
257
258
|
get token() { return world.token; }, get readToken() { return world.readToken; },
|
|
259
|
+
ready: () => host.ready(),
|
|
258
260
|
twins: () => world.twins(),
|
|
259
261
|
rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
|
|
260
262
|
handle: (request) => world.handle(request),
|
|
@@ -1209,7 +1211,7 @@ export class WorldDoors {
|
|
|
1209
1211
|
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
1210
1212
|
const enforced = browser && grant?.scope === 'read' && await this.enforcesRead(vendor, twin, url.origin);
|
|
1211
1213
|
if (browser && grant?.scope === 'read' && !reads && !enforced) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1212
|
-
const headers = browser ?
|
|
1214
|
+
const headers = browser ? forwardableHeaders(request.headers) : new Headers();
|
|
1213
1215
|
for (const key of [TOKEN_HEADER, TWINS_KEY_HEADER, CALLER_HEADER, ORIGINAL_PATH_HEADER, READ_ONLY_HEADER, 'host', TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, TWIN_SITE_HEADER]) headers.delete(key);
|
|
1214
1216
|
for (const name of ['accept', 'accept-language', 'if-none-match', 'range', 'user-agent']) { const v = request.headers.get(name); if (v !== null) headers.set(name, v); }
|
|
1215
1217
|
if (browser) {
|
|
@@ -1421,7 +1423,7 @@ export class WorldDoors {
|
|
|
1421
1423
|
// (a Slack read over POST passes, a post is refused); a twin that cannot enforce that gets reads only
|
|
1422
1424
|
const enforced = scope === 'read' && (await this.enforcesRead(vendor!, twin, url.origin));
|
|
1423
1425
|
if (scope === 'read' && !reads && !enforced) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1424
|
-
const headers =
|
|
1426
|
+
const headers = forwardableHeaders(request.headers); headers.delete(TOKEN_HEADER); headers.delete(TWINS_KEY_HEADER); headers.delete('host');
|
|
1425
1427
|
// who made it is the World's word, never the caller's: the twin's request journal records it. The World is a hop of
|
|
1426
1428
|
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
1427
1429
|
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
@@ -1507,7 +1509,7 @@ export class WorldDoors {
|
|
|
1507
1509
|
private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
|
|
1508
1510
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
1509
1511
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
1510
|
-
const caller =
|
|
1512
|
+
const caller = forwardableHeaders(request.headers);
|
|
1511
1513
|
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
|
|
1512
1514
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
1513
1515
|
const authorization = caller.get('authorization');
|
package/src/service-command.ts
CHANGED
|
@@ -1,29 +1,51 @@
|
|
|
1
|
-
// Process services run without a shell. On Windows
|
|
2
|
-
// shim
|
|
1
|
+
// Process services run without a shell. On Windows a bare command name is found the way a shell would find it, and an
|
|
2
|
+
// npm command shim (`.cmd`, which Node's shell-free spawn cannot execute) runs as what it launches: its package's
|
|
3
|
+
// script under Node, or the native executable of npm's Bun.
|
|
3
4
|
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
-
import { join, resolve } from 'node:path';
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
6
|
|
|
6
7
|
const file = (path: string): boolean => {
|
|
7
8
|
try { return statSync(path).isFile(); } catch { return false; }
|
|
8
9
|
};
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/** npm's Bun package: `bun.cmd` beside `node_modules/bun/bin/bun.exe`. */
|
|
12
|
+
function npmBun(directory: string): string | undefined {
|
|
13
|
+
const installed = resolve(directory, 'node_modules', 'bun');
|
|
14
|
+
const executable = join(installed, 'bin', 'bun.exe');
|
|
15
|
+
if (!file(executable)) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as { name?: unknown; bin?: { bun?: unknown } };
|
|
18
|
+
if (manifest.name === 'bun' && manifest.bin?.bun === 'bin/bun.exe') return executable;
|
|
19
|
+
} catch { /* An incomplete installation follows the ordinary spawn-error path. */ }
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The script an npm cmd-shim launches (`"%dp0%\node_modules\<package>\bin\<tool>.js" %*`), when it names one under
|
|
24
|
+
* its own directory. Only the shim's text is read; nothing in it is executed. */
|
|
25
|
+
function shimScript(shim: string): string | undefined {
|
|
26
|
+
let text: string;
|
|
27
|
+
try { text = readFileSync(shim, 'utf8'); } catch { return undefined; }
|
|
28
|
+
const match = /"%dp0%\\([^"%]+\.[cm]?js)"\s+%\*/i.exec(text) ?? /"%~dp0\\([^"%]+\.[cm]?js)"\s+%\*/i.exec(text);
|
|
29
|
+
if (!match) return undefined;
|
|
30
|
+
const script = resolve(shim, '..', match[1]!);
|
|
31
|
+
return file(script) ? script : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The program and arguments a service command runs as. Explicit paths, commands with an extension and every
|
|
35
|
+
* non-Windows command keep their meaning; a command that cannot be resolved is spawned as given, and fails through
|
|
36
|
+
* the ordinary recorded spawn error. */
|
|
37
|
+
export function serviceInvocation(command: string, args: string[], env: NodeJS.ProcessEnv): { command: string; args: string[] } {
|
|
38
|
+
if (process.platform !== 'win32' || !command || isAbsolute(command) || /[\\/]/.test(command) || /\.[a-z0-9]+$/i.test(command)) return { command, args };
|
|
14
39
|
const path = Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] ?? '';
|
|
15
|
-
const directories = [process.cwd(), ...path.split(';').map((value) => value.replace(/^"(.*)"$/, '$1'))];
|
|
40
|
+
const directories = [process.cwd(), ...path.split(';').filter(Boolean).map((value) => value.replace(/^"(.*)"$/, '$1'))];
|
|
16
41
|
for (const directory of directories) {
|
|
17
|
-
const native = resolve(directory,
|
|
18
|
-
if (file(native)) return native;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as { name?: unknown; bin?: { bun?: unknown } };
|
|
25
|
-
if (manifest.name === 'bun' && manifest.bin?.bun === 'bin/bun.exe') return executable;
|
|
26
|
-
} catch { /* An incomplete installation follows the ordinary spawn-error path. */ }
|
|
42
|
+
const native = resolve(directory, `${command}.exe`);
|
|
43
|
+
if (file(native)) return { command: native, args };
|
|
44
|
+
const shim = resolve(directory, `${command}.cmd`);
|
|
45
|
+
if (!file(shim)) continue;
|
|
46
|
+
if (command.toLowerCase() === 'bun') { const bun = npmBun(directory); if (bun) return { command: bun, args }; }
|
|
47
|
+
const script = shimScript(shim);
|
|
48
|
+
if (script) return { command: process.execPath, args: [script, ...args] };
|
|
27
49
|
}
|
|
28
|
-
return command;
|
|
50
|
+
return { command, args };
|
|
29
51
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The recorder's environment and its child's are computed here so the boot gate sees the exact final names. */
|
|
2
|
+
export function recorderEnvironment(env: NodeJS.ProcessEnv, service: string, log: string, logDir: string, events: string): NodeJS.ProcessEnv {
|
|
3
|
+
const out: NodeJS.ProcessEnv = { ...env, VOLTER_WORLD_SERVICE_ID: service, VOLTER_WORLD_SERVICE_LOG: log,
|
|
4
|
+
VOLTER_WORLD_SERVICE_LOG_DIR: logDir, VOLTER_WORLD_EVENTS: events };
|
|
5
|
+
if (out.NODE_OPTIONS !== undefined) {
|
|
6
|
+
out.VOLTER_WORLD_SERVICE_NODE_OPTIONS = out.NODE_OPTIONS;
|
|
7
|
+
delete out.NODE_OPTIONS;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Restore the injector for the real child and omit only the recorder's existing private transport names. */
|
|
13
|
+
export function recordedApplicationEnvironment(recorded: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
14
|
+
const out: NodeJS.ProcessEnv = { ...recorded };
|
|
15
|
+
if (out.VOLTER_WORLD_SERVICE_NODE_OPTIONS !== undefined) {
|
|
16
|
+
out.NODE_OPTIONS = out.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
|
|
17
|
+
delete out.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
|
|
18
|
+
}
|
|
19
|
+
for (const key of ['VOLTER_WORLD_EVENTS', 'VOLTER_WORLD_SERVICE_LOG_MAX']) delete out[key];
|
|
20
|
+
return out;
|
|
21
|
+
}
|
package/src/service-recorder.ts
CHANGED
|
@@ -17,19 +17,18 @@
|
|
|
17
17
|
// The World's NODE_OPTIONS (the injector) is for the service, not this parent: it arrives renamed and is restored.
|
|
18
18
|
import { signalChild } from './process-signal.mjs';
|
|
19
19
|
import { errorMessage } from './error-message.ts';
|
|
20
|
-
import {
|
|
20
|
+
import { serviceInvocation } from './service-command.ts';
|
|
21
|
+
import { recordedApplicationEnvironment } from './service-environment.ts';
|
|
21
22
|
import { spawn } from 'node:child_process';
|
|
22
23
|
import { constants } from 'node:os';
|
|
23
24
|
import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync, truncateSync, writeFileSync } from 'node:fs';
|
|
24
25
|
|
|
25
26
|
const [command = '', ...args] = process.argv.slice(2);
|
|
26
|
-
const env =
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const cap = Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) > 0 ? Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) : 64 * 1024 * 1024;
|
|
32
|
-
for (const key of ['VOLTER_WORLD_EVENTS', 'VOLTER_WORLD_SERVICE_LOG_MAX']) delete env[key];
|
|
27
|
+
const env = recordedApplicationEnvironment(process.env);
|
|
28
|
+
const service = process.env.VOLTER_WORLD_SERVICE_ID;
|
|
29
|
+
const logPath = process.env.VOLTER_WORLD_SERVICE_LOG;
|
|
30
|
+
const eventsPath = process.env.VOLTER_WORLD_EVENTS;
|
|
31
|
+
const cap = Number(process.env.VOLTER_WORLD_SERVICE_LOG_MAX) > 0 ? Number(process.env.VOLTER_WORLD_SERVICE_LOG_MAX) : 64 * 1024 * 1024;
|
|
33
32
|
|
|
34
33
|
/** The log's size and whether its last byte ends a line (a record never lands inside a service's line). */
|
|
35
34
|
const logEnd = (): { size: number; atLineStart: boolean } => {
|
|
@@ -49,7 +48,8 @@ const record = (entry: Record<string, unknown>, then?: () => void): void => {
|
|
|
49
48
|
};
|
|
50
49
|
|
|
51
50
|
const started = Date.now();
|
|
52
|
-
const
|
|
51
|
+
const invocation = serviceInvocation(command, args, env);
|
|
52
|
+
const child = spawn(invocation.command, invocation.args, { windowsHide: true, stdio: 'inherit', env });
|
|
53
53
|
if (child.pid !== undefined) record({ event: 'world.service.started', pid: child.pid, parentPid: process.pid });
|
|
54
54
|
|
|
55
55
|
/** The log's last `bytes` (from a line start) as the rotation `<log>.1`: however far a runaway writer overshot the cap,
|