@volter/world-runtime 3.0.165 → 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/attach.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// delegates to runWithWorldEnv, `--via direct` prints the manifest. Reflect and
|
|
7
7
|
// remote refs are proposals (docs/guides/route-a-cli-through-the-world.md) and are refused loudly, never
|
|
8
8
|
// silently degraded.
|
|
9
|
+
import { createHash, X509Certificate } from 'node:crypto';
|
|
9
10
|
import { existsSync, readFileSync } from 'node:fs';
|
|
10
11
|
import { dirname, join, resolve } from 'node:path';
|
|
11
12
|
import { activeVendorMap } from './redirect-proxy.ts';
|
|
12
|
-
import { statusWorld } from './runtime.ts';
|
|
13
|
+
import { refuseRequiredCredentialTransport, statusWorld } from './runtime.ts';
|
|
13
14
|
import { worldNetworkPolicyFromEnv, type WorldNetwork } from '@volter/world-core/network-policy';
|
|
14
15
|
|
|
15
16
|
export const WORLD_REF_FILE = '.volter-world';
|
|
@@ -82,6 +83,16 @@ export interface WorldManifest {
|
|
|
82
83
|
* env its client reads, `${host}`/`${port}` being the attacher's loopback bridge
|
|
83
84
|
* (@volter/world-core/stream-bridge) */
|
|
84
85
|
streams?: Record<string, { protocol: string; url: string; env: Record<string, string> }>;
|
|
86
|
+
/** what a browser is started with to reach the World's pages as a person's does (Chrome's flags): the World's proxy as
|
|
87
|
+
* its proxy, and this World's CA alone trusted, by its key's SPKI hash, which Chrome matches in the chain the proxy
|
|
88
|
+
* presents (each leaf followed by the CA). Chrome honours the trust flag only with a `--user-data-dir` of its own,
|
|
89
|
+
* so the browser trusts this World's CA and no other profile is touched. Present when the World runs its proxy. */
|
|
90
|
+
browser?: { args: string[] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The base64 SHA-256 of a certificate's SubjectPublicKeyInfo, as Chrome's `--ignore-certificate-errors-spki-list` takes it. */
|
|
94
|
+
export function spkiHash(pem: string): string {
|
|
95
|
+
return createHash('sha256').update(new X509Certificate(pem).publicKey.export({ type: 'spki', format: 'der' })).digest('base64');
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
// Internal runtime vars are plumbing, not suggested credentials. CA-trust
|
|
@@ -112,11 +123,13 @@ export function deriveWorldManifest(name: string, worldEnv: Record<string, strin
|
|
|
112
123
|
}
|
|
113
124
|
const policy = worldNetworkPolicyFromEnv(worldEnv);
|
|
114
125
|
const network = policy === undefined ? undefined : (({ version, world, ...permission }) => permission)(policy);
|
|
115
|
-
|
|
126
|
+
const proxy = worldEnv.VOLTER_WORLD_PROXY ?? null;
|
|
127
|
+
return { name, vendors, ca, proxy, env, ...(network === undefined ? {} : { network }), ...(proxy && ca ? { browser: { args: [`--proxy-server=${proxy}`, `--ignore-certificate-errors-spki-list=${spkiHash(ca)}`] } } : {}) };
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
/** The manifest of a local world instance (name resolved against `root` like every other verb). */
|
|
119
131
|
export function worldManifest(name: string, root = process.cwd()): WorldManifest {
|
|
120
132
|
const status = statusWorld(name, root);
|
|
133
|
+
refuseRequiredCredentialTransport(status, 'WorldManifest publication');
|
|
121
134
|
return deriveWorldManifest(name, status.env);
|
|
122
135
|
}
|
package/src/cli-commands.ts
CHANGED
|
@@ -152,6 +152,9 @@ function printHelp(): void {
|
|
|
152
152
|
# ref: explicit → $VOLTER_WORLD → nearest .volter-world file (docs/guides/route-a-cli-through-the-world.md)
|
|
153
153
|
# --via env (default): run the command attached; --via direct: print the manifest JSON
|
|
154
154
|
volter-world manifest <name> [--root <repo>] # the world manifest (vendors map, CA, traffic proxy, suggested env) as JSON
|
|
155
|
+
volter-world browser-flags <name> [--root <repo>]
|
|
156
|
+
# Chrome's flags for the World's pages: its proxy, and its CA alone trusted (by SPKI);
|
|
157
|
+
# pass them with a --user-data-dir of the browser's own
|
|
155
158
|
volter-world reflect <name> --target-ip <ip> [--resolver-ip <ip>] [--port <p>] [--resolver-port <p>] [--upstream <dns-ip>] [--root <repo>]
|
|
156
159
|
# run the reflect front (SNI TLS door) + resolver in the foreground (docs/ATTACH.md);
|
|
157
160
|
# --port 443 --resolver-port 53 is what containers can reach (attach --via reflect);
|
|
@@ -841,7 +844,7 @@ export async function main(): Promise<void> {
|
|
|
841
844
|
// The local env attach, every app's CMD path, needs the World's ref and its runtime alone: the two load together,
|
|
842
845
|
// and the remote door (serve.ts) and the reflect front (reflect.ts, ca-trust.ts) load only on their own paths.
|
|
843
846
|
// All five were imported one after another first (S2 R16: Dub's attach, 225 bodies in 341 ms before its `next`).
|
|
844
|
-
const [{ resolveWorldRef, isRemoteWorldRef, worldManifest }, { withoutVaultSignIn, instanceDir, attachWorld }] = await Promise.all([import('./attach.ts'), import('./runtime.ts')]);
|
|
847
|
+
const [{ resolveWorldRef, isRemoteWorldRef, worldManifest }, { withoutVaultSignIn, instanceDir, attachWorld, statusWorld: readAttachStatus, refuseRequiredCredentialTransport }] = await Promise.all([import('./attach.ts'), import('./runtime.ts')]);
|
|
845
848
|
// `subject` may be the ref, a flag, or the `--` separator — reassemble and split.
|
|
846
849
|
const args = [subject, ...rest].filter((arg): arg is string => arg !== undefined);
|
|
847
850
|
const split = args.indexOf('--');
|
|
@@ -899,6 +902,7 @@ export async function main(): Promise<void> {
|
|
|
899
902
|
// gets the override — and everything else runs with the trust env and VOLTER_WORLD set, its DNS
|
|
900
903
|
// being the caller's to point (a container's --dns, a resolver on the host).
|
|
901
904
|
if (command.length === 0) throw new Error('volter-world attach: pass the command after --');
|
|
905
|
+
refuseRequiredCredentialTransport(readAttachStatus(resolved.ref, attachRoot), 'reflect attachment');
|
|
902
906
|
const [{ readReflectManifest, splitDockerComposeArgs, composeOverrideForReflect, dockerComposeWithOverride, ATTACHED_CA_PATH }, { sessionTrustEnv }] = await Promise.all([import('./reflect.ts'), import('./ca-trust.ts')]);
|
|
903
907
|
const manifest = readReflectManifest(attachRoot, resolved.ref);
|
|
904
908
|
if (!manifest) throw new Error(`volter-world attach --via reflect: no reflect front is running for world ${resolved.ref} — start one: volter-world reflect ${resolved.ref} --target-ip <ip> --port 443 --resolver-port 53`);
|
|
@@ -936,10 +940,20 @@ export async function main(): Promise<void> {
|
|
|
936
940
|
return;
|
|
937
941
|
}
|
|
938
942
|
|
|
943
|
+
if (cmd === 'browser-flags') {
|
|
944
|
+
const { worldManifest } = await import('./attach.ts');
|
|
945
|
+
if (!subject) throw new Error('volter-world browser-flags: missing world name');
|
|
946
|
+
const browser = worldManifest(subject, root).browser;
|
|
947
|
+
if (!browser) throw new Error(`volter-world browser-flags: World "${subject}" runs no proxy (it has no twinned host or network policy, or this host opens no raw sockets), so a browser has no route to its pages`);
|
|
948
|
+
process.stdout.write(`${browser.args.join('\n')}\n`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
939
952
|
if (cmd === 'reflect') {
|
|
940
953
|
const { reflectRoutesPath, startReflectFront, startReflectResolver, readReflectRoutes, writeReflectManifest, clearReflectManifest } = await import('./reflect.ts');
|
|
941
|
-
const { statusWorld, instanceDir } = await import('./runtime.ts');
|
|
954
|
+
const { statusWorld, instanceDir, refuseRequiredCredentialTransport } = await import('./runtime.ts');
|
|
942
955
|
if (!subject) throw new Error('volter-world reflect: missing world name');
|
|
956
|
+
refuseRequiredCredentialTransport(statusWorld(subject, root), 'reflect front publication');
|
|
943
957
|
const targetIp = optionValue(rest, '--target-ip');
|
|
944
958
|
if (!targetIp) throw new Error('volter-world reflect: --target-ip <ip> is required (the address attachers reach the front at)');
|
|
945
959
|
const routesPath = reflectRoutesPath(root, subject);
|
|
@@ -973,7 +987,7 @@ export async function main(): Promise<void> {
|
|
|
973
987
|
|
|
974
988
|
if (cmd === 'serve') {
|
|
975
989
|
const { startReflectFront } = await import('./reflect.ts');
|
|
976
|
-
const { statusWorld, instanceDir } = await import('./runtime.ts');
|
|
990
|
+
const { statusWorld, instanceDir, refuseRequiredCredentialTransport } = await import('./runtime.ts');
|
|
977
991
|
const { startManifestServer, advertiseWorldManifest, MANIFEST_PATH } = await import('./serve.ts');
|
|
978
992
|
if (!subject) throw new Error('volter-world serve: missing world name');
|
|
979
993
|
const advertise = optionValue(rest, '--advertise');
|
|
@@ -985,6 +999,7 @@ export async function main(): Promise<void> {
|
|
|
985
999
|
throw new Error(`volter-world serve: --advertise must be an origin URL, got "${advertise}"`);
|
|
986
1000
|
}
|
|
987
1001
|
if (advertisedUrl.protocol !== 'https:') throw new Error('volter-world serve: the advertised origin must be https (the door terminates TLS with the session CA)');
|
|
1002
|
+
refuseRequiredCredentialTransport(statusWorld(subject, root), 'remote WorldManifest serving');
|
|
988
1003
|
const frontPort = Number(advertisedUrl.port) || 443;
|
|
989
1004
|
const front = await startReflectFront({
|
|
990
1005
|
envLoader: () => statusWorld(subject, root).env,
|
package/src/console-apart.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { errorMessage } from './error-message.ts';
|
|
9
9
|
import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
|
|
10
|
+
import { forwardableHeaders } from './forwardable-headers.ts';
|
|
10
11
|
|
|
11
12
|
/** What a console offers a host: its shell and assets under `base`, told where the Worlds are served. */
|
|
12
13
|
export type ConsoleMount = { handle: (request: Request, base: string, opts?: { worlds?: string; names?: string[]; worldOrigin?: boolean }) => Promise<Response | null> };
|
|
@@ -28,7 +29,7 @@ export async function serveConsoleApart(mount: ConsoleMount, opts: { upstream: s
|
|
|
28
29
|
if (!url.pathname.startsWith('/-/') || url.pathname.startsWith(`${CONSOLE_BASE}/`)) return Response.json({ error: 'the console serves the console and the host\'s doors only' }, { status: 404 });
|
|
29
30
|
// a door, forwarded: the caller's token header rides along; no cookie goes up (this origin holds
|
|
30
31
|
// none) and none comes back (a World's session belongs to the Worlds' origin, set there)
|
|
31
|
-
const headers =
|
|
32
|
+
const headers = forwardableHeaders(request.headers);
|
|
32
33
|
for (const h of ['host', 'cookie', 'origin', 'referer']) headers.delete(h);
|
|
33
34
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
34
35
|
const answer = await fetch(`${upstream}${url.pathname}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' });
|
|
@@ -65,7 +66,7 @@ export async function serveConsoleFor(target: string, opts: { hostname?: string;
|
|
|
65
66
|
hostname, port: opts.port !== undefined && opts.port !== 0 ? opts.port + 1 : 0, twinRequestJournal: false,
|
|
66
67
|
async fetch(request) {
|
|
67
68
|
const url = new URL(request.url);
|
|
68
|
-
const headers =
|
|
69
|
+
const headers = forwardableHeaders(request.headers);
|
|
69
70
|
for (const h of ['host', 'origin', 'referer']) headers.delete(h);
|
|
70
71
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
71
72
|
const answer = await fetch(`${origin}${url.pathname}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' });
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { WorldServiceConfig } from './schema.ts';
|
|
2
|
+
|
|
3
|
+
export type CredentialDoor = NonNullable<WorldServiceConfig['issues']>;
|
|
4
|
+
|
|
5
|
+
/** The answer's own scalar property, without a file, directory or side transform. */
|
|
6
|
+
export function isScalarCredentialField(value: string | undefined): boolean {
|
|
7
|
+
return value !== undefined && value.length > 0 && !/^(?:file|directory|side):/.test(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A saved alias extends the current descriptor; it cannot replace its issuer or fields. */
|
|
11
|
+
export function composeCredentialDoor(saved: CredentialDoor | undefined, current: CredentialDoor | undefined, service: string): CredentialDoor | undefined {
|
|
12
|
+
if (current === undefined) {
|
|
13
|
+
if (saved !== undefined) throw new Error(`World service ${service}: its current pack has no credential door; revalidate its declared aliases`);
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const aliases: Array<[string, string]> = [];
|
|
17
|
+
const fields = new Set(Object.values(current.fill).filter(isScalarCredentialField));
|
|
18
|
+
for (const [name, field] of Object.entries(saved?.fill ?? {})) {
|
|
19
|
+
if (Object.hasOwn(current.fill, name)) {
|
|
20
|
+
if (current.fill[name] !== field) throw new Error(`World service ${service}: credential binding ${name} conflicts with its current pack; revalidate the source`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isScalarCredentialField(field) || !fields.has(field))
|
|
24
|
+
throw new Error(`World service ${service}: alias ${name} does not name a current scalar credential field; revalidate the source`);
|
|
25
|
+
aliases.push([name, field]);
|
|
26
|
+
}
|
|
27
|
+
const fill = Object.fromEntries([...Object.entries(current.fill), ...aliases]);
|
|
28
|
+
const required = saved?.required;
|
|
29
|
+
if (required !== undefined) {
|
|
30
|
+
if (!Array.isArray(required)) throw new Error(`World service ${service}: required credential names must be an array`);
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
for (const name of required) {
|
|
33
|
+
if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || seen.has(name)
|
|
34
|
+
|| !Object.hasOwn(fill, name) || !isScalarCredentialField(fill[name]) || !fields.has(fill[name]!)) {
|
|
35
|
+
throw new Error(`World service ${service}: required credential ${String(name)} does not name a current scalar field`);
|
|
36
|
+
}
|
|
37
|
+
seen.add(name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { path: current.path, body: current.body, fill, ...(required === undefined ? {} : { required: [...required] }) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Non-pack process doors keep their own contract. Omitted type means twin, as at startup. */
|
|
44
|
+
export function withCurrentCredentialDoor(service: WorldServiceConfig, current: CredentialDoor | undefined): WorldServiceConfig {
|
|
45
|
+
if ((service.type ?? 'twin') !== 'twin') return service;
|
|
46
|
+
const door = composeCredentialDoor(service.issues, current, service.id);
|
|
47
|
+
if (door === undefined) return service;
|
|
48
|
+
for (const name of Object.keys(door.fill)) {
|
|
49
|
+
if (Object.hasOwn(current!.fill, name) && !door.required?.includes(name)) continue;
|
|
50
|
+
if (name === service.injectEnv || Object.hasOwn(service.injectEnvTemplates ?? {}, name) || Object.hasOwn(service.cliRedirect ?? {}, name))
|
|
51
|
+
throw new Error(`World service ${service.id}: credential alias ${name} conflicts with an endpoint binding`);
|
|
52
|
+
}
|
|
53
|
+
return { ...service, issues: door };
|
|
54
|
+
}
|
package/src/fixture-env.ts
CHANGED
|
@@ -201,7 +201,7 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
|
|
|
201
201
|
// Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
|
|
202
202
|
// refuses a key that is not `sk_…`/`pk_…`); a webhook signing secret is `whsec_…`. Test mode, as a World is.
|
|
203
203
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
|
|
204
|
-
{ match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => '
|
|
204
|
+
{ match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test' + '_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
|
|
205
205
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
|
|
206
206
|
// tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
|
|
207
207
|
{ match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'earlier @volter/twin-tavily tavily-auth.ts' },
|
|
@@ -209,7 +209,7 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
|
|
|
209
209
|
// a Clerk webhook's signing secret is Svix's (`whsec_` and base64), which the app's verifier decodes; the World registers
|
|
210
210
|
// its endpoint with the same secret (the Clerk twin's webhooks door takes `signing_secret`)
|
|
211
211
|
{ match: /(^|_)CLERK_WEBHOOK(_SIGNING)?_SECRET$/, value: () => `whsec_${Buffer.from('twin-fake-clerk-webhook-secret!!').toString('base64')}`, source: 'https://clerk.com/docs/webhooks/sync-data (CLERK_WEBHOOK_SIGNING_SECRET, whsec_); https://docs.svix.com/receiving/verifying-payloads/how-manual' },
|
|
212
|
-
{ match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => '
|
|
212
|
+
{ match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test' + '_twinfake000000000000000000000000000000', source: 'earlier @volter/twin-clerk manifest.ts' },
|
|
213
213
|
{ match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'earlier @volter/twin-clerk index.ts' },
|
|
214
214
|
];
|
|
215
215
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The headers of one connection, not of the request (RFC 9110 §7.6.1), with `expect`: the hop that received them
|
|
2
|
+
* answered them (Node's server sends the 100 Continue itself). Node's fetch refuses `transfer-encoding`, `keep-alive`,
|
|
3
|
+
* `upgrade`, `expect` and a `connection` other than close/keep-alive outright, so a request forwarded with them fails
|
|
4
|
+
* `fetch failed (UND_ERR_INVALID_ARG)`; the body streams on and fetch frames it afresh. Every World path that forwards
|
|
5
|
+
* a received request into fetch (the redirect proxy, a served World's twin routes, a reflected host) copies its
|
|
6
|
+
* headers through this. */
|
|
7
|
+
const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'expect'];
|
|
8
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
|
|
9
|
+
|
|
10
|
+
export function forwardableHeaders(incoming: Headers): Headers {
|
|
11
|
+
const headers = new Headers(incoming);
|
|
12
|
+
for (const listed of (incoming.get('connection') ?? '').split(',')) { const name = listed.trim(); if (TOKEN.test(name)) headers.delete(name); }
|
|
13
|
+
for (const h of HOP_BY_HOP) headers.delete(h);
|
|
14
|
+
return headers;
|
|
15
|
+
}
|
package/src/go-mysql-binding.ts
CHANGED
|
@@ -3,11 +3,18 @@ export type GoMysqlBinding = { dsnFormat: 'go-mysql'; endpoint: 'explicit-port'
|
|
|
3
3
|
|
|
4
4
|
/** Only versioned, explicitly typed data selects a driver grammar. */
|
|
5
5
|
export function goMysqlBinding(entry: Record<string, unknown>, version: unknown, name: string, declaredPort: number): Partial<GoMysqlBinding> {
|
|
6
|
+
if (entry.dsnFormat !== undefined && version !== 6 && version !== 7)
|
|
7
|
+
throw new Error(`volter init: ${name} needs a valid stack-needs version6/7 Go MySQL binding`);
|
|
8
|
+
return goMysqlBindingFields(entry, name, declaredPort);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The driver fields themselves; callers own their separate input-version gate. */
|
|
12
|
+
export function goMysqlBindingFields(entry: Record<string, unknown>, name: string, declaredPort: number): Partial<GoMysqlBinding> {
|
|
6
13
|
if (entry.dsnFormat === undefined) {
|
|
7
14
|
if (entry.endpoint !== undefined || entry.dsnPrefix !== undefined || entry.dsnProtocol !== undefined) throw new Error(`volter init: ${name} has DSN fields without a declared format`);
|
|
8
15
|
return {};
|
|
9
16
|
}
|
|
10
|
-
if (
|
|
17
|
+
if (entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
|
|
11
18
|
|| (entry.endpoint !== 'explicit-port' && entry.endpoint !== 'default-port') || (entry.dsnPrefix !== '' && entry.dsnPrefix !== 'mysql://')
|
|
12
19
|
|| (entry.dsnProtocol !== '' && entry.dsnProtocol !== 'tcp') || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535
|
|
13
20
|
|| (entry.endpoint === 'default-port' && entry.containerPort !== declaredPort)
|
package/src/guest-runner.ts
CHANGED
|
@@ -9,9 +9,10 @@ import { allocatePort } from './tcp-port.ts';
|
|
|
9
9
|
import { ownedPath, assertOwnedRealPath } from './owned-path.ts';
|
|
10
10
|
import { commandLifetime, commandProcessGroup } from './command-lifetime.ts';
|
|
11
11
|
import { beginGuestProcess, retainGuestProcess, readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
|
|
12
|
-
import { retireOwnedProcessGroups } from './runtime.ts';
|
|
12
|
+
import { refuseRequiredCredentialTransport, retireOwnedProcessGroups } from './runtime.ts';
|
|
13
13
|
import { guestControl } from './guest-control.mjs';
|
|
14
14
|
import { admitGuestArgv, guestCommandGap, guestEnvironment } from './guest-command.mjs';
|
|
15
|
+
import { forwardableHeaders } from './forwardable-headers.ts';
|
|
15
16
|
type Plan = { runtime: string; cwd: string; env: Record<string, string>; command: string[]; ports: number[]; copies: Array<{ sources: string[]; destination: string; cwd: string }> };
|
|
16
17
|
type Guest = { name: string; dir: string; cwd: string; env: Record<string, string>; child: ChildProcess; lifetime: ReturnType<typeof commandLifetime>; runtime: RemoteRuntime; ports: Set<number>; status: MachineStatus; stopping?: boolean; launch?: RemoteProcessHandle; imageRuntime: string; agentPort: number; resetOnStop?: boolean };
|
|
17
18
|
const delay = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
|
@@ -134,6 +135,7 @@ function worldEnv(): NodeJS.ProcessEnv {
|
|
|
134
135
|
const instancePath = process.env.VOLTER_WORLD_INSTANCE;
|
|
135
136
|
if (!instancePath) throw new Error('guest execution requires a World instance');
|
|
136
137
|
const instance = JSON.parse(readFileSync(instancePath, 'utf8'));
|
|
138
|
+
refuseRequiredCredentialTransport(instance, 'light machine application transport', 'WORLD_MACHINE_CAPABILITY_GAP');
|
|
137
139
|
// proxy-env has the completed vendor map; the early twin process env may not.
|
|
138
140
|
const current = JSON.parse(readFileSync(join(dirname(instancePath), 'proxy-env.json'), 'utf8'));
|
|
139
141
|
const env: NodeJS.ProcessEnv = { ...instance.env, ...current };
|
|
@@ -245,13 +247,14 @@ export function createLightGuestPool(options: LightPoolOptions & { world: string
|
|
|
245
247
|
pauseMode: 'freeze-processes',
|
|
246
248
|
async start({ id }) { const g = guests.get(id); if (!g) throw new Error('Substrate guest launch was not prepared'); return { name: id, url: `http://127.0.0.1:${g.agentPort}` }; },
|
|
247
249
|
async pause({ name }) { const g = live(name); await ask(g.child, 'pause'); update(g, { state: 'paused' }); },
|
|
248
|
-
async resume({ name }) { const g = guests.get(name); if (!g || g.status.state !== 'paused') throw new Error('Substrate guest cannot resume'); await ask(g.child, 'resume'); update(g, { state: 'started' }); },
|
|
250
|
+
async resume({ name }) { worldEnv(); const g = guests.get(name); if (!g || g.status.state !== 'paused') throw new Error('Substrate guest cannot resume'); await ask(g.child, 'resume'); update(g, { state: 'started' }); },
|
|
249
251
|
async stop({ name }) { await stop(name); },
|
|
250
252
|
running() { return new Set([...guests].filter(([, g]) => g.status.state === 'started').map(([name]) => name)); },
|
|
251
253
|
};
|
|
252
254
|
return {
|
|
253
255
|
kind: 'light',
|
|
254
256
|
async admitRun(spec) {
|
|
257
|
+
worldEnv(); // Required application bindings have no admitted guest transport.
|
|
255
258
|
if (!/^[a-zA-Z0-9_-]+$/.test(spec.name)) throw new Error('invalid guest name');
|
|
256
259
|
const image = options.images?.[spec.image];
|
|
257
260
|
if (!image) throw new Error(`image ${spec.image} has no light guest context`);
|
|
@@ -386,6 +389,7 @@ export function createLightGuestPool(options: LightPoolOptions & { world: string
|
|
|
386
389
|
} finally { starting = false; clearTimeout(deadline); }
|
|
387
390
|
},
|
|
388
391
|
async exec(name, spec) {
|
|
392
|
+
worldEnv(); // Re-read the owning boot before a later command can start.
|
|
389
393
|
const g = live(name);
|
|
390
394
|
const queue: Array<{ stdout?: string } | { stderr?: string } | { exit: number }> = []; let wake: (() => void) | undefined, done = false;
|
|
391
395
|
const push = (item: (typeof queue)[number]): void => { queue.push(item); wake?.(); };
|
|
@@ -401,8 +405,8 @@ export function createLightGuestPool(options: LightPoolOptions & { world: string
|
|
|
401
405
|
async request(name, port, request) {
|
|
402
406
|
const g = live(name);
|
|
403
407
|
if (!g.ports.has(port)) throw new Error('guest port is not declared');
|
|
404
|
-
const url = new URL(request.url), headers =
|
|
405
|
-
for (const key of ['host', '
|
|
408
|
+
const url = new URL(request.url), headers = forwardableHeaders(request.headers);
|
|
409
|
+
for (const key of ['host', 'content-length']) headers.delete(key);
|
|
406
410
|
const init = { method: request.method, headers, body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(), signal: request.signal, redirect: 'manual' as const };
|
|
407
411
|
return g.runtime.request(port, url.pathname + url.search, init);
|
|
408
412
|
},
|
package/src/host-ready.ts
CHANGED
|
@@ -20,12 +20,21 @@ export function publishHostReady(path: string, boot: string, ports: Record<strin
|
|
|
20
20
|
renameSync(temporary, path);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** The twins a co-located host's log says it has started (host-cli's `event twin=<id> kind=started` lines). Only
|
|
24
|
+
* starts count: a twin's own output, the recorder's time marks or another twin's errors are not progress. */
|
|
25
|
+
export function startedTwins(log: string): number {
|
|
26
|
+
let text: string;
|
|
27
|
+
try { text = readFileSync(log, 'utf8'); } catch { return 0; }
|
|
28
|
+
return new Set(Array.from(text.matchAll(/^event twin=(\S+) kind=started\b/gmu), (match) => match[1])).size;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
32
|
* Receipt admission is cancellation-aware; stale or malformed state is never
|
|
25
33
|
* readiness. `timeoutMs` bounds a host that stops making progress, not a slow
|
|
26
|
-
* one: each change in `progress` (the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
34
|
+
* one: each change in `progress` (the count of twins started, `startedTwins`)
|
|
35
|
+
* begins the bound again, so a host still starting its twins on a busy machine
|
|
36
|
+
* is waited for, and one that starts no further twin for `timeoutMs` is not.
|
|
37
|
+
* Starts are finite, so the whole wait is at most (twins + 1) × `timeoutMs`.
|
|
29
38
|
* Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
|
|
30
39
|
* twins' main ports are byte streams.
|
|
31
40
|
*/
|
package/src/index.ts
CHANGED
|
@@ -103,6 +103,10 @@ export type { CoverageReport, CoverageRow, CoverageStatus, CoverageOptions, Repo
|
|
|
103
103
|
// world config + env file OUTSIDE the repo (fixture-env), and prove the emission with `covers`.
|
|
104
104
|
export { formatInitReport, initWorld, packCatalog, planWorldInit, renderEnvFile, writeWorldInit } from './init.ts';
|
|
105
105
|
export { STATE_GITIGNORE } from './init.ts';
|
|
106
|
+
export { MANAGED_API_SOURCE_READER_CONTRACT } from './managed-api-source.ts';
|
|
107
|
+
export type { ManagedApiSourceReader, ManagedApiSourceReading } from './managed-api-source.ts';
|
|
108
|
+
/** Current resolved-catalog metadata; callers authenticate its supplying package bytes separately. */
|
|
109
|
+
export { packFacts, type PackFacts } from './pack-facts.ts';
|
|
106
110
|
export { clockFile, instanceDir } from './runtime.ts';
|
|
107
111
|
export { setWorldClock, writeWorldClock } from './clock.ts';
|
|
108
112
|
export { checkoutCatalog, findInstalledPackage, installedCatalog, resolveCatalog, resolveServicePackage, twinPackageName, TWIN_PACKAGE_PREFIX, type Catalog, type CatalogKind, packCli, packEntry } from './catalog.ts';
|
|
@@ -159,7 +163,7 @@ export {
|
|
|
159
163
|
} from './serve.ts';
|
|
160
164
|
export type { ManifestServerHandle, ManifestServerOptions } from './serve.ts';
|
|
161
165
|
|
|
162
|
-
export { resetWorld, seedEntryFor, seedWorld } from './placeholder.ts';
|
|
166
|
+
export { resetWorld, seedEntryFor, seedServedWorld, seedWorld, type SeedServedOutcome } from './placeholder.ts';
|
|
163
167
|
export type { SeedWorldOptions, SeedWorldOutcome } from './placeholder.ts';
|
|
164
168
|
|
|
165
169
|
export { fetchFromOrigin, worldOrigin } from './origin.ts';
|
package/src/infra.ts
CHANGED
|
@@ -19,10 +19,15 @@ import type { ManagedProtocol } from './twin-backing.ts';
|
|
|
19
19
|
|
|
20
20
|
/** Whether a program is on PATH, read from the file system without starting a process: where a missing program
|
|
21
21
|
* still costs a process round trip (a browser tab's kernel), the container probe is not spawned at all. */
|
|
22
|
+
/** Whether a command is on PATH. On Windows the variable may be spelled `Path` (a copied environment is not
|
|
23
|
+
* case-insensitive as `process.env` is) and a command is found with PATHEXT's extensions, as the shell finds it. */
|
|
22
24
|
function onPath(name: string, env: NodeJS.ProcessEnv): boolean {
|
|
23
|
-
|
|
25
|
+
const windows = process.platform === 'win32';
|
|
26
|
+
const value = windows ? Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] : env.PATH;
|
|
27
|
+
const names = windows ? ['', ...(Object.entries(env).find(([key]) => key.toUpperCase() === 'PATHEXT')?.[1] ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)].map(ext => name + ext) : [name];
|
|
28
|
+
return (value ?? '').split(delimiter).some((dir) => {
|
|
24
29
|
if (!dir) return false;
|
|
25
|
-
try { const path = join(dir,
|
|
30
|
+
return names.some((candidate) => { try { const path = join(dir, candidate); accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } });
|
|
26
31
|
});
|
|
27
32
|
}
|
|
28
33
|
|
|
@@ -224,7 +229,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
|
|
|
224
229
|
return 2;
|
|
225
230
|
}
|
|
226
231
|
// No docker on PATH is the same absence the probe's ENOENT reports, read without a process.
|
|
227
|
-
if (
|
|
232
|
+
if (!onPath('docker', env)) return 'pglite';
|
|
228
233
|
const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
|
|
229
234
|
signal?.throwIfAborted();
|
|
230
235
|
if (probe.status === 0 && probe.stdout.trim()) return 'docker';
|