@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.
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
@@ -1017,7 +1017,6 @@
1017
1017
  "organizationlicense": "generic app-internal env stem, names no vendor",
1018
1018
  "osso": "defunct SAML SaaS (Osso), no live vendor surface",
1019
1019
  "postgrest": "self-instance client; targets the app's own self-hosted deployment, which is local in a keyless world",
1020
- "postizoauth": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
1021
1020
  "seedoauth2": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
1022
1021
  "seedplatformoauth": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
1023
1022
  "sink": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
@@ -8,6 +8,7 @@
8
8
  // What it does not do (named, so a workflow that needs them fails plainly): matrices beyond their first combination,
9
9
  // services and containers, composite and JavaScript actions from the marketplace, reusable workflows, concurrency groups.
10
10
  import { errorMessage } from "./error-message.js";
11
+ import { worldRequiredCredentialGate } from "./runtime.js";
11
12
  import { spawn } from 'node:child_process';
12
13
  import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
14
  import { join } from 'node:path';
@@ -110,19 +111,50 @@ function readKv(file) {
110
111
  }
111
112
  return out;
112
113
  }
113
- function sh(command, cwd, env, shell, log) {
114
- return new Promise((resolve) => {
114
+ function sh(command, cwd, env, shell, log, requireEnv) {
115
+ requireEnv(env, 'Actions command');
116
+ return new Promise((resolve, reject) => {
115
117
  const file = join(cwd, `.volter-step-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`);
116
- writeFileSync(file, command);
117
- const argv = shell === 'pwsh' ? ['pwsh', ['-File', file]] : shell === 'sh' ? ['sh', ['-e', file]] : ['bash', ['--noprofile', '--norc', '-eo', 'pipefail', file]];
118
- const child = spawn(argv[0], [...argv[1]], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
119
- child.stdout.on('data', (d) => { for (const l of d.toString().split('\n'))
120
- if (l)
121
- log(` ${l}`); });
122
- child.stderr.on('data', (d) => { for (const l of d.toString().split('\n'))
123
- if (l)
124
- log(` ${l}`); });
125
- child.on('close', (code) => { rmSync(file, { force: true }); resolve(code ?? 1); });
118
+ let failure = undefined;
119
+ const finish = (code, signal) => {
120
+ const primary = failure ?? (signal ? Object.assign(new Error(`Actions command terminated by ${signal}`), { signal })
121
+ : code === null ? new Error('Actions command closed without an exit status') : undefined);
122
+ try {
123
+ rmSync(file, { force: true });
124
+ }
125
+ catch (cleanup) {
126
+ reject(primary === undefined ? cleanup : new AggregateError([primary, cleanup], 'Actions command and script cleanup failed', { cause: primary }));
127
+ return;
128
+ }
129
+ if (primary !== undefined)
130
+ reject(primary);
131
+ else
132
+ resolve(code);
133
+ };
134
+ try {
135
+ writeFileSync(file, command);
136
+ const argv = shell === 'pwsh' ? ['pwsh', ['-File', file]] : shell === 'sh' ? ['sh', ['-e', file]] : ['bash', ['--noprofile', '--norc', '-eo', 'pipefail', file]];
137
+ const child = spawn(argv[0], [...argv[1]], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
138
+ // Spawn errors settle at close, after the owned child's streams close; retain the original native cause.
139
+ child.on('error', (error) => { failure ??= error; });
140
+ const output = (d) => {
141
+ try {
142
+ for (const l of d.toString().split('\n'))
143
+ if (l)
144
+ log(` ${l}`);
145
+ }
146
+ catch (error) {
147
+ failure ??= error;
148
+ }
149
+ };
150
+ child.stdout.on('data', output);
151
+ child.stderr.on('data', output);
152
+ child.on('close', finish);
153
+ }
154
+ catch (error) {
155
+ failure = error;
156
+ finish(null, null);
157
+ }
126
158
  });
127
159
  }
128
160
  /** One of GitHub's own actions, performed by the runner; an action it cannot perform fails by name. */
@@ -151,6 +183,12 @@ function builtin(step, env, workspace) {
151
183
  /** One run: started, each job's steps run in order of `needs`, completed with its conclusion. */
152
184
  export async function runOnce(opts, repo, run) {
153
185
  const log = opts.log ?? (() => { });
186
+ // The runner's original World context, before job/step overrides, owns its required values.
187
+ const instancePath = process.env.VOLTER_WORLD_INSTANCE ?? opts.env.VOLTER_WORLD_INSTANCE;
188
+ if (!instancePath && (process.env.VOLTER_WORLD || opts.env.VOLTER_WORLD))
189
+ throw new Error('Actions runner has no local credential declaration');
190
+ const requireEnv = instancePath ? worldRequiredCredentialGate(JSON.parse(readFileSync(instancePath, 'utf8')))
191
+ : (_env, _boundary) => { };
154
192
  const id = Number(run.id);
155
193
  const started = await api(opts, 'POST', `/_twin/repos/${repo}/actions/runs/${id}/start`);
156
194
  if (started.status !== 200) {
@@ -175,7 +213,7 @@ export async function runOnce(opts, repo, run) {
175
213
  const root = mkdtempSync(join(opts.workRoot, `run-${id}-`));
176
214
  const workspace = join(root, 'workspace');
177
215
  const clone = `${opts.github.replace(/\/$/, '').replace(/^(https?:\/\/)/, `$1x-access-token:${jobToken}@`)}/${repo}.git`;
178
- const git = async (args, cwd) => sh(`git ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, cwd, { ...process.env, ...opts.env }, 'bash', log);
216
+ const git = async (args, cwd) => sh(`git ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, cwd, { ...process.env, ...opts.env }, 'bash', log, requireEnv);
179
217
  let conclusion = 'success';
180
218
  try {
181
219
  mkdirSync(root, { recursive: true });
@@ -233,6 +271,7 @@ export async function runOnce(opts, repo, run) {
233
271
  continue;
234
272
  }
235
273
  log(` step ${i + 1}: ${step.name ?? step.uses ?? String(step.run).split('\n')[0]}`);
274
+ requireEnv(env, 'Actions step environment');
236
275
  let ok;
237
276
  if (step.uses) {
238
277
  // its inputs are expressions too (`registry-url: ${{ vars.NPM_REGISTRY }}`)
@@ -243,7 +282,7 @@ export async function runOnce(opts, repo, run) {
243
282
  }
244
283
  else {
245
284
  const cwd = join(workspace, step['working-directory'] ?? job.defaults?.run?.['working-directory'] ?? '');
246
- ok = (await sh(expand(String(step.run ?? ''), stepScope), cwd, env, step.shell ?? job.defaults?.run?.shell ?? 'bash', log)) === 0;
285
+ ok = (await sh(expand(String(step.run ?? ''), stepScope), cwd, env, step.shell ?? job.defaults?.run?.shell ?? 'bash', log, requireEnv)) === 0;
247
286
  }
248
287
  if (step.id)
249
288
  scope.steps[step.id] = { outputs: readKv(files.output), outcome: ok ? 'success' : 'failure', conclusion: ok || step['continue-on-error'] ? 'success' : 'failure' };
@@ -43,7 +43,16 @@ export interface WorldManifest {
43
43
  url: string;
44
44
  env: Record<string, string>;
45
45
  }>;
46
+ /** 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
47
+ * its proxy, and this World's CA alone trusted, by its key's SPKI hash, which Chrome matches in the chain the proxy
48
+ * presents (each leaf followed by the CA). Chrome honours the trust flag only with a `--user-data-dir` of its own,
49
+ * so the browser trusts this World's CA and no other profile is touched. Present when the World runs its proxy. */
50
+ browser?: {
51
+ args: string[];
52
+ };
46
53
  }
54
+ /** The base64 SHA-256 of a certificate's SubjectPublicKeyInfo, as Chrome's `--ignore-certificate-errors-spki-list` takes it. */
55
+ export declare function spkiHash(pem: string): string;
47
56
  /** Derive the manifest from a world's generated env map (the local rendering).
48
57
  * Pure — the CA is inlined only when its file is readable. */
49
58
  export declare function deriveWorldManifest(name: string, worldEnv: Record<string, string | undefined>): WorldManifest;
@@ -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.js";
12
- import { statusWorld } from "./runtime.js";
13
+ import { refuseRequiredCredentialTransport, statusWorld } from "./runtime.js";
13
14
  import { worldNetworkPolicyFromEnv } from '@volter/world-core/network-policy';
14
15
  export const WORLD_REF_FILE = '.volter-world';
15
16
  /** A world reference is remote when it is a URL; every other ref is a local world name. */
@@ -51,6 +52,10 @@ export function resolveWorldRef(explicit, options = {}) {
51
52
  return { ref: fromFile.ref, source: 'file', file: fromFile.file };
52
53
  throw new Error(`no world reference: pass one (volter-world attach <world> …), set VOLTER_WORLD, or commit a ${WORLD_REF_FILE} file`);
53
54
  }
55
+ /** The base64 SHA-256 of a certificate's SubjectPublicKeyInfo, as Chrome's `--ignore-certificate-errors-spki-list` takes it. */
56
+ export function spkiHash(pem) {
57
+ return createHash('sha256').update(new X509Certificate(pem).publicKey.export({ type: 'spki', format: 'der' })).digest('base64');
58
+ }
54
59
  // Internal runtime vars are plumbing, not suggested credentials. CA-trust
55
60
  // bundle vars are local renderings of `ca` (they hold machine-local paths), so
56
61
  // a manifest never carries them — an attacher re-renders trust from `ca`.
@@ -81,10 +86,12 @@ export function deriveWorldManifest(name, worldEnv) {
81
86
  }
82
87
  const policy = worldNetworkPolicyFromEnv(worldEnv);
83
88
  const network = policy === undefined ? undefined : (({ version, world, ...permission }) => permission)(policy);
84
- return { name, vendors, ca, proxy: worldEnv.VOLTER_WORLD_PROXY ?? null, env, ...(network === undefined ? {} : { network }) };
89
+ const proxy = worldEnv.VOLTER_WORLD_PROXY ?? null;
90
+ return { name, vendors, ca, proxy, env, ...(network === undefined ? {} : { network }), ...(proxy && ca ? { browser: { args: [`--proxy-server=${proxy}`, `--ignore-certificate-errors-spki-list=${spkiHash(ca)}`] } } : {}) };
85
91
  }
86
92
  /** The manifest of a local world instance (name resolved against `root` like every other verb). */
87
93
  export function worldManifest(name, root = process.cwd()) {
88
94
  const status = statusWorld(name, root);
95
+ refuseRequiredCredentialTransport(status, 'WorldManifest publication');
89
96
  return deriveWorldManifest(name, status.env);
90
97
  }
@@ -133,6 +133,9 @@ function printHelp() {
133
133
  # ref: explicit → $VOLTER_WORLD → nearest .volter-world file (docs/guides/route-a-cli-through-the-world.md)
134
134
  # --via env (default): run the command attached; --via direct: print the manifest JSON
135
135
  volter-world manifest <name> [--root <repo>] # the world manifest (vendors map, CA, traffic proxy, suggested env) as JSON
136
+ volter-world browser-flags <name> [--root <repo>]
137
+ # Chrome's flags for the World's pages: its proxy, and its CA alone trusted (by SPKI);
138
+ # pass them with a --user-data-dir of the browser's own
136
139
  volter-world reflect <name> --target-ip <ip> [--resolver-ip <ip>] [--port <p>] [--resolver-port <p>] [--upstream <dns-ip>] [--root <repo>]
137
140
  # run the reflect front (SNI TLS door) + resolver in the foreground (docs/ATTACH.md);
138
141
  # --port 443 --resolver-port 53 is what containers can reach (attach --via reflect);
@@ -950,7 +953,7 @@ export async function main() {
950
953
  // The local env attach, every app's CMD path, needs the World's ref and its runtime alone: the two load together,
951
954
  // and the remote door (serve.ts) and the reflect front (reflect.ts, ca-trust.ts) load only on their own paths.
952
955
  // All five were imported one after another first (S2 R16: Dub's attach, 225 bodies in 341 ms before its `next`).
953
- const [{ resolveWorldRef, isRemoteWorldRef, worldManifest }, { withoutVaultSignIn, instanceDir, attachWorld }] = await Promise.all([import("./attach.js"), import("./runtime.js")]);
956
+ const [{ resolveWorldRef, isRemoteWorldRef, worldManifest }, { withoutVaultSignIn, instanceDir, attachWorld, statusWorld: readAttachStatus, refuseRequiredCredentialTransport }] = await Promise.all([import("./attach.js"), import("./runtime.js")]);
954
957
  // `subject` may be the ref, a flag, or the `--` separator — reassemble and split.
955
958
  const args = [subject, ...rest].filter((arg) => arg !== undefined);
956
959
  const split = args.indexOf('--');
@@ -1011,6 +1014,7 @@ export async function main() {
1011
1014
  // being the caller's to point (a container's --dns, a resolver on the host).
1012
1015
  if (command.length === 0)
1013
1016
  throw new Error('volter-world attach: pass the command after --');
1017
+ refuseRequiredCredentialTransport(readAttachStatus(resolved.ref, attachRoot), 'reflect attachment');
1014
1018
  const [{ readReflectManifest, splitDockerComposeArgs, composeOverrideForReflect, dockerComposeWithOverride, ATTACHED_CA_PATH }, { sessionTrustEnv }] = await Promise.all([import("./reflect.js"), import("./ca-trust.js")]);
1015
1019
  const manifest = readReflectManifest(attachRoot, resolved.ref);
1016
1020
  if (!manifest)
@@ -1050,11 +1054,22 @@ export async function main() {
1050
1054
  process.stdout.write(`${JSON.stringify(worldManifest(subject, root), null, 2)}\n`);
1051
1055
  return;
1052
1056
  }
1057
+ if (cmd === 'browser-flags') {
1058
+ const { worldManifest } = await import("./attach.js");
1059
+ if (!subject)
1060
+ throw new Error('volter-world browser-flags: missing world name');
1061
+ const browser = worldManifest(subject, root).browser;
1062
+ if (!browser)
1063
+ 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`);
1064
+ process.stdout.write(`${browser.args.join('\n')}\n`);
1065
+ return;
1066
+ }
1053
1067
  if (cmd === 'reflect') {
1054
1068
  const { reflectRoutesPath, startReflectFront, startReflectResolver, readReflectRoutes, writeReflectManifest, clearReflectManifest } = await import("./reflect.js");
1055
- const { statusWorld, instanceDir } = await import("./runtime.js");
1069
+ const { statusWorld, instanceDir, refuseRequiredCredentialTransport } = await import("./runtime.js");
1056
1070
  if (!subject)
1057
1071
  throw new Error('volter-world reflect: missing world name');
1072
+ refuseRequiredCredentialTransport(statusWorld(subject, root), 'reflect front publication');
1058
1073
  const targetIp = optionValue(rest, '--target-ip');
1059
1074
  if (!targetIp)
1060
1075
  throw new Error('volter-world reflect: --target-ip <ip> is required (the address attachers reach the front at)');
@@ -1088,7 +1103,7 @@ export async function main() {
1088
1103
  }
1089
1104
  if (cmd === 'serve') {
1090
1105
  const { startReflectFront } = await import("./reflect.js");
1091
- const { statusWorld, instanceDir } = await import("./runtime.js");
1106
+ const { statusWorld, instanceDir, refuseRequiredCredentialTransport } = await import("./runtime.js");
1092
1107
  const { startManifestServer, advertiseWorldManifest, MANIFEST_PATH } = await import("./serve.js");
1093
1108
  if (!subject)
1094
1109
  throw new Error('volter-world serve: missing world name');
@@ -1104,6 +1119,7 @@ export async function main() {
1104
1119
  }
1105
1120
  if (advertisedUrl.protocol !== 'https:')
1106
1121
  throw new Error('volter-world serve: the advertised origin must be https (the door terminates TLS with the session CA)');
1122
+ refuseRequiredCredentialTransport(statusWorld(subject, root), 'remote WorldManifest serving');
1107
1123
  const frontPort = Number(advertisedUrl.port) || 443;
1108
1124
  const front = await startReflectFront({
1109
1125
  envLoader: () => statusWorld(subject, root).env,
@@ -6,6 +6,7 @@
6
6
  // The console opens a World's pages (a vendor's screens) on the Worlds' origin, which it is told.
7
7
  import { errorMessage } from "./error-message.js";
8
8
  import { serveHttp } from '@volter/world-core/runtime';
9
+ import { forwardableHeaders } from "./forwardable-headers.js";
9
10
  export const CONSOLE_BASE = '/-/console';
10
11
  /** Serve `mount` on its own listener; doors under `/-/` go to `upstream` (the Worlds' origin). */
11
12
  /** `upstream`: where this listener reaches the host's doors (its own address); `worlds`: the origin the
@@ -26,7 +27,7 @@ export async function serveConsoleApart(mount, opts) {
26
27
  return Response.json({ error: 'the console serves the console and the host\'s doors only' }, { status: 404 });
27
28
  // a door, forwarded: the caller's token header rides along; no cookie goes up (this origin holds
28
29
  // none) and none comes back (a World's session belongs to the Worlds' origin, set there)
29
- const headers = new Headers(request.headers);
30
+ const headers = forwardableHeaders(request.headers);
30
31
  for (const h of ['host', 'cookie', 'origin', 'referer'])
31
32
  headers.delete(h);
32
33
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
@@ -75,7 +76,7 @@ export async function serveConsoleFor(target, opts = {}) {
75
76
  hostname, port: opts.port !== undefined && opts.port !== 0 ? opts.port + 1 : 0, twinRequestJournal: false,
76
77
  async fetch(request) {
77
78
  const url = new URL(request.url);
78
- const headers = new Headers(request.headers);
79
+ const headers = forwardableHeaders(request.headers);
79
80
  for (const h of ['host', 'origin', 'referer'])
80
81
  headers.delete(h);
81
82
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
@@ -0,0 +1,8 @@
1
+ import type { WorldServiceConfig } from './schema.js';
2
+ export type CredentialDoor = NonNullable<WorldServiceConfig['issues']>;
3
+ /** The answer's own scalar property, without a file, directory or side transform. */
4
+ export declare function isScalarCredentialField(value: string | undefined): boolean;
5
+ /** A saved alias extends the current descriptor; it cannot replace its issuer or fields. */
6
+ export declare function composeCredentialDoor(saved: CredentialDoor | undefined, current: CredentialDoor | undefined, service: string): CredentialDoor | undefined;
7
+ /** Non-pack process doors keep their own contract. Omitted type means twin, as at startup. */
8
+ export declare function withCurrentCredentialDoor(service: WorldServiceConfig, current: CredentialDoor | undefined): WorldServiceConfig;
@@ -0,0 +1,54 @@
1
+ /** The answer's own scalar property, without a file, directory or side transform. */
2
+ export function isScalarCredentialField(value) {
3
+ return value !== undefined && value.length > 0 && !/^(?:file|directory|side):/.test(value);
4
+ }
5
+ /** A saved alias extends the current descriptor; it cannot replace its issuer or fields. */
6
+ export function composeCredentialDoor(saved, current, service) {
7
+ if (current === undefined) {
8
+ if (saved !== undefined)
9
+ throw new Error(`World service ${service}: its current pack has no credential door; revalidate its declared aliases`);
10
+ return undefined;
11
+ }
12
+ const aliases = [];
13
+ const fields = new Set(Object.values(current.fill).filter(isScalarCredentialField));
14
+ for (const [name, field] of Object.entries(saved?.fill ?? {})) {
15
+ if (Object.hasOwn(current.fill, name)) {
16
+ if (current.fill[name] !== field)
17
+ throw new Error(`World service ${service}: credential binding ${name} conflicts with its current pack; revalidate the source`);
18
+ continue;
19
+ }
20
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isScalarCredentialField(field) || !fields.has(field))
21
+ throw new Error(`World service ${service}: alias ${name} does not name a current scalar credential field; revalidate the source`);
22
+ aliases.push([name, field]);
23
+ }
24
+ const fill = Object.fromEntries([...Object.entries(current.fill), ...aliases]);
25
+ const required = saved?.required;
26
+ if (required !== undefined) {
27
+ if (!Array.isArray(required))
28
+ throw new Error(`World service ${service}: required credential names must be an array`);
29
+ const seen = new Set();
30
+ for (const name of required) {
31
+ if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || seen.has(name)
32
+ || !Object.hasOwn(fill, name) || !isScalarCredentialField(fill[name]) || !fields.has(fill[name])) {
33
+ throw new Error(`World service ${service}: required credential ${String(name)} does not name a current scalar field`);
34
+ }
35
+ seen.add(name);
36
+ }
37
+ }
38
+ return { path: current.path, body: current.body, fill, ...(required === undefined ? {} : { required: [...required] }) };
39
+ }
40
+ /** Non-pack process doors keep their own contract. Omitted type means twin, as at startup. */
41
+ export function withCurrentCredentialDoor(service, current) {
42
+ if ((service.type ?? 'twin') !== 'twin')
43
+ return service;
44
+ const door = composeCredentialDoor(service.issues, current, service.id);
45
+ if (door === undefined)
46
+ return service;
47
+ for (const name of Object.keys(door.fill)) {
48
+ if (Object.hasOwn(current.fill, name) && !door.required?.includes(name))
49
+ 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
+ }
@@ -185,7 +185,7 @@ const CREDENTIAL_SHAPES = [
185
185
  // Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
186
186
  // refuses a key that is not `sk_…`/`pk_…`); a webhook signing secret is `whsec_…`. Test mode, as a World is.
187
187
  { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
188
- { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
188
+ { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test' + '_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
189
189
  { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
190
190
  // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
191
191
  { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'earlier @volter/twin-tavily tavily-auth.ts' },
@@ -193,7 +193,7 @@ const CREDENTIAL_SHAPES = [
193
193
  // a Clerk webhook's signing secret is Svix's (`whsec_` and base64), which the app's verifier decodes; the World registers
194
194
  // its endpoint with the same secret (the Clerk twin's webhooks door takes `signing_secret`)
195
195
  { 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' },
196
- { match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'earlier @volter/twin-clerk manifest.ts' },
196
+ { match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test' + '_twinfake000000000000000000000000000000', source: 'earlier @volter/twin-clerk manifest.ts' },
197
197
  { 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' },
198
198
  ];
199
199
  /** Keys the APPLICATION parses before it can use them: a fake that is not the shape its own code decodes throws at the
@@ -0,0 +1 @@
1
+ export declare function forwardableHeaders(incoming: Headers): Headers;
@@ -0,0 +1,19 @@
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
+ export function forwardableHeaders(incoming) {
10
+ const headers = new Headers(incoming);
11
+ for (const listed of (incoming.get('connection') ?? '').split(',')) {
12
+ const name = listed.trim();
13
+ if (TOKEN.test(name))
14
+ headers.delete(name);
15
+ }
16
+ for (const h of HOP_BY_HOP)
17
+ headers.delete(h);
18
+ return headers;
19
+ }
@@ -7,6 +7,8 @@ export type GoMysqlBinding = {
7
7
  };
8
8
  /** Only versioned, explicitly typed data selects a driver grammar. */
9
9
  export declare function goMysqlBinding(entry: Record<string, unknown>, version: unknown, name: string, declaredPort: number): Partial<GoMysqlBinding>;
10
+ /** The driver fields themselves; callers own their separate input-version gate. */
11
+ export declare function goMysqlBindingFields(entry: Record<string, unknown>, name: string, declaredPort: number): Partial<GoMysqlBinding>;
10
12
  export declare function renderGoMysqlBinding(entry: GoMysqlBinding & {
11
13
  name: string;
12
14
  containerPort?: number;
@@ -1,11 +1,17 @@
1
1
  /** Only versioned, explicitly typed data selects a driver grammar. */
2
2
  export function goMysqlBinding(entry, version, name, declaredPort) {
3
+ if (entry.dsnFormat !== undefined && version !== 6 && version !== 7)
4
+ throw new Error(`volter init: ${name} needs a valid stack-needs version6/7 Go MySQL binding`);
5
+ return goMysqlBindingFields(entry, name, declaredPort);
6
+ }
7
+ /** The driver fields themselves; callers own their separate input-version gate. */
8
+ export function goMysqlBindingFields(entry, name, declaredPort) {
3
9
  if (entry.dsnFormat === undefined) {
4
10
  if (entry.endpoint !== undefined || entry.dsnPrefix !== undefined || entry.dsnProtocol !== undefined)
5
11
  throw new Error(`volter init: ${name} has DSN fields without a declared format`);
6
12
  return {};
7
13
  }
8
- if ((version !== 6 && version !== 7) || entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
14
+ if (entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
9
15
  || (entry.endpoint !== 'explicit-port' && entry.endpoint !== 'default-port') || (entry.dsnPrefix !== '' && entry.dsnPrefix !== 'mysql://')
10
16
  || (entry.dsnProtocol !== '' && entry.dsnProtocol !== 'tcp') || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535
11
17
  || (entry.endpoint === 'default-port' && entry.containerPort !== declaredPort)
@@ -8,9 +8,10 @@ import { allocatePort } from "./tcp-port.js";
8
8
  import { ownedPath, assertOwnedRealPath } from "./owned-path.js";
9
9
  import { commandLifetime, commandProcessGroup } from "./command-lifetime.js";
10
10
  import { beginGuestProcess, retainGuestProcess, readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
11
- import { retireOwnedProcessGroups } from "./runtime.js";
11
+ import { refuseRequiredCredentialTransport, retireOwnedProcessGroups } from "./runtime.js";
12
12
  import { guestControl } from './guest-control.mjs';
13
13
  import { admitGuestArgv, guestCommandGap, guestEnvironment } from './guest-command.mjs';
14
+ import { forwardableHeaders } from "./forwardable-headers.js";
14
15
  const delay = (ms) => new Promise(r => setTimeout(r, ms));
15
16
  // Re-enrolling image metadata must not lose ownership of guests already running in this World.
16
17
  const pools = new Map();
@@ -192,6 +193,7 @@ function worldEnv() {
192
193
  if (!instancePath)
193
194
  throw new Error('guest execution requires a World instance');
194
195
  const instance = JSON.parse(readFileSync(instancePath, 'utf8'));
196
+ refuseRequiredCredentialTransport(instance, 'light machine application transport', 'WORLD_MACHINE_CAPABILITY_GAP');
195
197
  // proxy-env has the completed vendor map; the early twin process env may not.
196
198
  const current = JSON.parse(readFileSync(join(dirname(instancePath), 'proxy-env.json'), 'utf8'));
197
199
  const env = { ...instance.env, ...current };
@@ -339,7 +341,7 @@ export function createLightGuestPool(options) {
339
341
  async start({ id }) { const g = guests.get(id); if (!g)
340
342
  throw new Error('Substrate guest launch was not prepared'); return { name: id, url: `http://127.0.0.1:${g.agentPort}` }; },
341
343
  async pause({ name }) { const g = live(name); await ask(g.child, 'pause'); update(g, { state: 'paused' }); },
342
- async resume({ name }) { const g = guests.get(name); if (!g || g.status.state !== 'paused')
344
+ async resume({ name }) { worldEnv(); const g = guests.get(name); if (!g || g.status.state !== 'paused')
343
345
  throw new Error('Substrate guest cannot resume'); await ask(g.child, 'resume'); update(g, { state: 'started' }); },
344
346
  async stop({ name }) { await stop(name); },
345
347
  running() { return new Set([...guests].filter(([, g]) => g.status.state === 'started').map(([name]) => name)); },
@@ -347,6 +349,7 @@ export function createLightGuestPool(options) {
347
349
  return {
348
350
  kind: 'light',
349
351
  async admitRun(spec) {
352
+ worldEnv(); // Required application bindings have no admitted guest transport.
350
353
  if (!/^[a-zA-Z0-9_-]+$/.test(spec.name))
351
354
  throw new Error('invalid guest name');
352
355
  const image = options.images?.[spec.image];
@@ -527,6 +530,7 @@ export function createLightGuestPool(options) {
527
530
  }
528
531
  },
529
532
  async exec(name, spec) {
533
+ worldEnv(); // Re-read the owning boot before a later command can start.
530
534
  const g = live(name);
531
535
  const queue = [];
532
536
  let wake, done = false;
@@ -552,8 +556,8 @@ export function createLightGuestPool(options) {
552
556
  const g = live(name);
553
557
  if (!g.ports.has(port))
554
558
  throw new Error('guest port is not declared');
555
- const url = new URL(request.url), headers = new Headers(request.headers);
556
- for (const key of ['host', 'connection', 'content-length', 'transfer-encoding'])
559
+ const url = new URL(request.url), headers = forwardableHeaders(request.headers);
560
+ for (const key of ['host', 'content-length'])
557
561
  headers.delete(key);
558
562
  const init = { method: request.method, headers, body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(), signal: request.signal, redirect: 'manual' };
559
563
  return g.runtime.request(port, url.pathname + url.search, init);
@@ -12,12 +12,16 @@ export type HostReadyTimes = {
12
12
  } & Partial<HostStartTimes>;
13
13
  /** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
14
14
  export declare function publishHostReady(path: string, boot: string, ports: Record<string, number>, times?: HostStartTimes, streams?: readonly string[]): void;
15
+ /** The twins a co-located host's log says it has started (host-cli's `event twin=<id> kind=started` lines). Only
16
+ * starts count: a twin's own output, the recorder's time marks or another twin's errors are not progress. */
17
+ export declare function startedTwins(log: string): number;
15
18
  /**
16
19
  * Receipt admission is cancellation-aware; stale or malformed state is never
17
20
  * readiness. `timeoutMs` bounds a host that stops making progress, not a slow
18
- * one: each change in `progress` (the host log's size, which grows with every
19
- * twin it starts) begins the bound again, so a host still starting its twins
20
- * on a busy machine is waited for, and one silent for `timeoutMs` is not.
21
+ * one: each change in `progress` (the count of twins started, `startedTwins`)
22
+ * begins the bound again, so a host still starting its twins on a busy machine
23
+ * is waited for, and one that starts no further twin for `timeoutMs` is not.
24
+ * Starts are finite, so the whole wait is at most (twins + 1) × `timeoutMs`.
21
25
  * Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
22
26
  * twins' main ports are byte streams.
23
27
  */
@@ -11,12 +11,25 @@ export function publishHostReady(path, boot, ports, times, streams) {
11
11
  writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports, at: Date.now(), ...times, ...(streams?.length ? { streams } : {}) })}\n`, { mode: 0o600 });
12
12
  renameSync(temporary, path);
13
13
  }
14
+ /** The twins a co-located host's log says it has started (host-cli's `event twin=<id> kind=started` lines). Only
15
+ * starts count: a twin's own output, the recorder's time marks or another twin's errors are not progress. */
16
+ export function startedTwins(log) {
17
+ let text;
18
+ try {
19
+ text = readFileSync(log, 'utf8');
20
+ }
21
+ catch {
22
+ return 0;
23
+ }
24
+ return new Set(Array.from(text.matchAll(/^event twin=(\S+) kind=started\b/gmu), (match) => match[1])).size;
25
+ }
14
26
  /**
15
27
  * Receipt admission is cancellation-aware; stale or malformed state is never
16
28
  * readiness. `timeoutMs` bounds a host that stops making progress, not a slow
17
- * one: each change in `progress` (the host log's size, which grows with every
18
- * twin it starts) begins the bound again, so a host still starting its twins
19
- * on a busy machine is waited for, and one silent for `timeoutMs` is not.
29
+ * one: each change in `progress` (the count of twins started, `startedTwins`)
30
+ * begins the bound again, so a host still starting its twins on a busy machine
31
+ * is waited for, and one that starts no further twin for `timeoutMs` is not.
32
+ * Starts are finite, so the whole wait is at most (twins + 1) × `timeoutMs`.
20
33
  * Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
21
34
  * twins' main ports are byte streams.
22
35
  */
@@ -21,6 +21,10 @@ export { coverWorld, coverageRecord, detectRepoVendors, envNameVendor, formatCov
21
21
  export type { CoverageReport, CoverageRow, CoverageStatus, CoverageOptions, RepoVendorSignals } from './covers.js';
22
22
  export { formatInitReport, initWorld, packCatalog, planWorldInit, renderEnvFile, writeWorldInit } from './init.js';
23
23
  export { STATE_GITIGNORE } from './init.js';
24
+ export { MANAGED_API_SOURCE_READER_CONTRACT } from './managed-api-source.js';
25
+ export type { ManagedApiSourceReader, ManagedApiSourceReading } from './managed-api-source.js';
26
+ /** Current resolved-catalog metadata; callers authenticate its supplying package bytes separately. */
27
+ export { packFacts, type PackFacts } from './pack-facts.js';
24
28
  export { clockFile, instanceDir } from './runtime.js';
25
29
  export { setWorldClock, writeWorldClock } from './clock.js';
26
30
  export { checkoutCatalog, findInstalledPackage, installedCatalog, resolveCatalog, resolveServicePackage, twinPackageName, TWIN_PACKAGE_PREFIX, type Catalog, type CatalogKind, packCli, packEntry } from './catalog.js';
@@ -35,7 +39,7 @@ export { startColocatedHost } from './host.js';
35
39
  export type { ColocatedTwinSpec, ColocatedHost, HostIsolation } from './host.js';
36
40
  export { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttachEnv, startManifestServer, } from './serve.js';
37
41
  export type { ManifestServerHandle, ManifestServerOptions } from './serve.js';
38
- export { resetWorld, seedEntryFor, seedWorld } from './placeholder.js';
42
+ export { resetWorld, seedEntryFor, seedServedWorld, seedWorld, type SeedServedOutcome } from './placeholder.js';
39
43
  export type { SeedWorldOptions, SeedWorldOutcome } from './placeholder.js';
40
44
  export { fetchFromOrigin, worldOrigin } from './origin.js';
41
45
  export type { FetchOptions, FetchOutcome } from './origin.js';
package/dist/src/index.js CHANGED
@@ -19,6 +19,9 @@ export { coverWorld, coverageRecord, detectRepoVendors, envNameVendor, formatCov
19
19
  // world config + env file OUTSIDE the repo (fixture-env), and prove the emission with `covers`.
20
20
  export { formatInitReport, initWorld, packCatalog, planWorldInit, renderEnvFile, writeWorldInit } from "./init.js";
21
21
  export { STATE_GITIGNORE } from "./init.js";
22
+ export { MANAGED_API_SOURCE_READER_CONTRACT } from "./managed-api-source.js";
23
+ /** Current resolved-catalog metadata; callers authenticate its supplying package bytes separately. */
24
+ export { packFacts } from "./pack-facts.js";
22
25
  export { clockFile, instanceDir } from "./runtime.js";
23
26
  export { setWorldClock, writeWorldClock } from "./clock.js";
24
27
  export { checkoutCatalog, findInstalledPackage, installedCatalog, resolveCatalog, resolveServicePackage, twinPackageName, TWIN_PACKAGE_PREFIX, packCli, packEntry } from "./catalog.js";
@@ -27,7 +30,7 @@ export { deriveWorldManifest, findWorldRefFile, isRemoteWorldRef, resolveWorldRe
27
30
  export { parseSni, readReflectRoutes, reflectRoutesPath, reflectManifestPath, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, CA_TRUST_ENV, ATTACHED_CA_PATH, startReflectFront, startReflectResolver, writeReflectRoutes, } from "./reflect.js";
28
31
  export { startColocatedHost } from "./host.js";
29
32
  export { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttachEnv, startManifestServer, } from "./serve.js";
30
- export { resetWorld, seedEntryFor, seedWorld } from "./placeholder.js";
33
+ export { resetWorld, seedEntryFor, seedServedWorld, seedWorld } from "./placeholder.js";
31
34
  export { fetchFromOrigin, worldOrigin } from "./origin.js";
32
35
  export { branchWorld, checkoutWorld } from "./branch.js";
33
36
  export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCredentials, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
package/dist/src/infra.js CHANGED
@@ -17,18 +17,23 @@ import { containerlessDataDirectory, infraConnections, liveDatabaseBehind, liveD
17
17
  import { errorMessage } from "./error-message.js";
18
18
  /** Whether a program is on PATH, read from the file system without starting a process: where a missing program
19
19
  * still costs a process round trip (a browser tab's kernel), the container probe is not spawned at all. */
20
+ /** Whether a command is on PATH. On Windows the variable may be spelled `Path` (a copied environment is not
21
+ * case-insensitive as `process.env` is) and a command is found with PATHEXT's extensions, as the shell finds it. */
20
22
  function onPath(name, env) {
21
- return (env.PATH ?? '').split(delimiter).some((dir) => {
23
+ const windows = process.platform === 'win32';
24
+ const value = windows ? Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] : env.PATH;
25
+ const names = windows ? ['', ...(Object.entries(env).find(([key]) => key.toUpperCase() === 'PATHEXT')?.[1] ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)].map(ext => name + ext) : [name];
26
+ return (value ?? '').split(delimiter).some((dir) => {
22
27
  if (!dir)
23
28
  return false;
24
- try {
25
- const path = join(dir, name);
29
+ return names.some((candidate) => { try {
30
+ const path = join(dir, candidate);
26
31
  accessSync(path, constants.X_OK);
27
32
  return statSync(path).isFile();
28
33
  }
29
34
  catch {
30
35
  return false;
31
- }
36
+ } });
32
37
  });
33
38
  }
34
39
  /** A command run to its end without blocking this process: in the World's own `up` the phase shares the event loop
@@ -248,7 +253,7 @@ export async function runInfrastructure(phase, env, io, signal) {
248
253
  return 2;
249
254
  }
250
255
  // No docker on PATH is the same absence the probe's ENOENT reports, read without a process.
251
- if (process.platform !== 'win32' && !onPath('docker', env))
256
+ if (!onPath('docker', env))
252
257
  return 'pglite';
253
258
  const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
254
259
  signal?.throwIfAborted();