@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.
Files changed (82) hide show
  1. package/dist/known-external-services.json +0 -1
  2. package/dist/src/actions-runner.js +53 -14
  3. package/dist/src/attach.d.ts +9 -0
  4. package/dist/src/attach.js +9 -2
  5. package/dist/src/cli-commands.js +19 -3
  6. package/dist/src/console-apart.js +3 -2
  7. package/dist/src/credential-door.d.ts +8 -0
  8. package/dist/src/credential-door.js +54 -0
  9. package/dist/src/fixture-env.js +2 -2
  10. package/dist/src/forwardable-headers.d.ts +1 -0
  11. package/dist/src/forwardable-headers.js +19 -0
  12. package/dist/src/go-mysql-binding.d.ts +2 -0
  13. package/dist/src/go-mysql-binding.js +7 -1
  14. package/dist/src/guest-runner.js +8 -4
  15. package/dist/src/host-ready.d.ts +7 -3
  16. package/dist/src/host-ready.js +16 -3
  17. package/dist/src/index.d.ts +5 -1
  18. package/dist/src/index.js +4 -1
  19. package/dist/src/infra.js +10 -5
  20. package/dist/src/init.d.ts +5 -0
  21. package/dist/src/init.js +157 -23
  22. package/dist/src/managed-api-needs.d.ts +101 -0
  23. package/dist/src/managed-api-needs.js +312 -0
  24. package/dist/src/managed-api-source.d.ts +24 -0
  25. package/dist/src/managed-api-source.js +59 -0
  26. package/dist/src/mongodb-backing.js +33 -58
  27. package/dist/src/mongodb-command.d.ts +21 -0
  28. package/dist/src/mongodb-command.js +317 -0
  29. package/dist/src/pglite-host.mjs +2 -2
  30. package/dist/src/placeholder.d.ts +24 -1
  31. package/dist/src/placeholder.js +105 -13
  32. package/dist/src/process-signal.d.mts +2 -0
  33. package/dist/src/process-signal.mjs +44 -2
  34. package/dist/src/project-inspect.d.ts +1 -1
  35. package/dist/src/project-inspect.js +8 -1
  36. package/dist/src/redirect-proxy.js +4 -19
  37. package/dist/src/reflect.js +2 -2
  38. package/dist/src/runtime.d.ts +5 -1
  39. package/dist/src/runtime.js +309 -58
  40. package/dist/src/schema.d.ts +10 -0
  41. package/dist/src/schema.js +76 -3
  42. package/dist/src/serve.js +24 -4
  43. package/dist/src/served-world.d.ts +2 -1
  44. package/dist/src/served-world.js +6 -4
  45. package/dist/src/service-command.d.ts +7 -3
  46. package/dist/src/service-command.js +53 -22
  47. package/dist/src/service-environment.d.ts +4 -0
  48. package/dist/src/service-environment.js +21 -0
  49. package/dist/src/service-recorder.js +9 -13
  50. package/known-external-services.json +0 -1
  51. package/package.json +5 -5
  52. package/src/actions-runner.ts +38 -10
  53. package/src/attach.ts +15 -2
  54. package/src/cli-commands.ts +18 -3
  55. package/src/console-apart.ts +3 -2
  56. package/src/credential-door.ts +54 -0
  57. package/src/fixture-env.ts +2 -2
  58. package/src/forwardable-headers.ts +15 -0
  59. package/src/go-mysql-binding.ts +8 -1
  60. package/src/guest-runner.ts +8 -4
  61. package/src/host-ready.ts +12 -3
  62. package/src/index.ts +5 -1
  63. package/src/infra.ts +8 -3
  64. package/src/init.ts +145 -23
  65. package/src/managed-api-needs.ts +285 -0
  66. package/src/managed-api-source.ts +67 -0
  67. package/src/mongodb-backing.ts +32 -48
  68. package/src/mongodb-command.ts +238 -0
  69. package/src/pglite-host.mjs +2 -2
  70. package/src/placeholder.ts +90 -13
  71. package/src/process-signal.d.mts +2 -0
  72. package/src/process-signal.mjs +44 -2
  73. package/src/project-inspect.ts +9 -2
  74. package/src/redirect-proxy.ts +4 -15
  75. package/src/reflect.ts +2 -2
  76. package/src/runtime.ts +270 -44
  77. package/src/schema.ts +73 -3
  78. package/src/serve.ts +22 -4
  79. package/src/served-world.ts +7 -5
  80. package/src/service-command.ts +41 -19
  81. package/src/service-environment.ts +21 -0
  82. 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
- return { name, vendors, ca, proxy: worldEnv.VOLTER_WORLD_PROXY ?? null, env, ...(network === undefined ? {} : { network }) };
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
  }
@@ -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,
@@ -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 = new Headers(request.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 = new Headers(request.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
+ }
@@ -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: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
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: () => 'sk_test_twinfake000000000000000000000000000000', source: 'earlier @volter/twin-clerk manifest.ts' },
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
+ }
@@ -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 ((version !== 6 && version !== 7) || entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
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)
@@ -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 = new Headers(request.headers);
405
- for (const key of ['host', 'connection', 'content-length', 'transfer-encoding']) headers.delete(key);
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 host log's size, which grows with every
27
- * twin it starts) begins the bound again, so a host still starting its twins
28
- * on a busy machine is waited for, and one silent for `timeoutMs` is not.
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
- return (env.PATH ?? '').split(delimiter).some((dir) => {
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, name); accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; }
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 (process.platform !== 'win32' && !onPath('docker', env)) return 'pglite';
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';