@volter/world-runtime 3.0.145 → 3.0.147

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.
@@ -994,11 +994,12 @@ export function formatCoverageReport(report) {
994
994
  lines.push(`ACKNOWLEDGED: ${report.acknowledged.length} signal(s) accepted with recorded reasons (no twin): ${report.acknowledged.map((a) => a.vendor).join(', ')}`);
995
995
  }
996
996
  if (report.excluded.length > 0)
997
- lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (not covered; no access granted).`);
997
+ lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (outside automatic selection; declared service bindings are reported separately).`);
998
+ const detectionScope = report.excluded.length > 0 ? 'selected detected vendor' : 'detected vendor';
998
999
  lines.push(report.ok
999
1000
  ? report.acknowledged.length > 0
1000
- ? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other detected vendor has a twin in world ${report.world}.`
1001
- : `COVERED: every detected vendor has a twin in world ${report.world}.`
1001
+ ? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other ${detectionScope} has a twin in world ${report.world}.`
1002
+ : `COVERED: every ${detectionScope} has a twin in world ${report.world}.`
1002
1003
  : `PROOF FAILED for world ${report.world}.`);
1003
1004
  lines.push('Static connection check only; run an app scenario to verify behavior and shared state.');
1004
1005
  return `${lines.join('\n')}\n`;
@@ -1,6 +1,7 @@
1
1
  type PriorOutput = {
2
2
  path: string;
3
3
  contents: string;
4
+ sha256?: string;
4
5
  };
5
6
  export type EnvOutputClaim = {
6
7
  path: string;
@@ -17,7 +17,8 @@ function inspect(path) {
17
17
  /** Refuse a custody/input file before any World service or lifecycle mutation starts. */
18
18
  export function claimEnvOutput(path, backupDir, priors = [], force = false) {
19
19
  const contents = inspect(path);
20
- if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && Buffer.from(prior.contents).equals(contents))) {
20
+ if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && (Buffer.from(prior.contents).equals(contents)
21
+ || /^[a-f0-9]{64}$/.test(prior.sha256 ?? '') && createHash('sha256').update(contents).digest('hex') === prior.sha256))) {
21
22
  throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
22
23
  }
23
24
  if (force && contents !== undefined) {
@@ -37,6 +37,8 @@ export type EnvDisposition =
37
37
  'faked'
38
38
  /** a credential the vendor's twin issues at boot → `$issue:<vendor>` (the twin refuses a key it did not make) */
39
39
  | 'issued'
40
+ /** a World-owned endpoint or reserved environment name → supplied at boot, never copied from the app */
41
+ | 'injected'
40
42
  /** non-credential app config → the example value copied verbatim */
41
43
  | 'kept'
42
44
  /** infra connection string → points at a World-managed infrastructure service */
package/dist/src/init.js CHANGED
@@ -734,7 +734,7 @@ export function planWorldInit(name, repoPath, options = {}) {
734
734
  // World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
735
735
  // stale copy would shadow the live one.
736
736
  if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name) || worldInjected.has(name)) {
737
- envRows.push({ name, disposition: 'unknown', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
737
+ envRows.push({ name, disposition: 'injected', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
738
738
  continue;
739
739
  }
740
740
  // The application's own origin (its base URL, its auth library's URL): only the booter knows where the app is
@@ -1319,7 +1319,7 @@ export function formatInitReport(result) {
1319
1319
  lines.push('');
1320
1320
  const counts = (disposition) => plan.envRows.filter((row) => row.disposition === disposition);
1321
1321
  lines.push(`Env (${plan.envRows.length} name(s) from ${plan.envSources.join(', ') || 'no example env file'}):`);
1322
- for (const disposition of ['faked', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown']) {
1322
+ for (const disposition of ['faked', 'issued', 'injected', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown']) {
1323
1323
  const rows = counts(disposition);
1324
1324
  if (rows.length === 0)
1325
1325
  continue;
@@ -669,8 +669,8 @@ function readWorldBootRecordLocked(name, root) {
669
669
  }
670
670
  if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
671
671
  || new Set(record.bootServices).size !== record.bootServices.length
672
- || record.bootServices.length !== Object.keys(record.services).length
673
- || record.bootServices.some(id => !Object.hasOwn(record.services, id)))
672
+ || Object.keys(record.services).some(id => !record.bootServices.includes(id))
673
+ || !stopped && record.bootServices.length !== Object.keys(record.services).length)
674
674
  throw new Error();
675
675
  for (const [id, service] of Object.entries(record.services)) {
676
676
  if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string')
@@ -695,7 +695,7 @@ function readWorldBootRecordLocked(name, root) {
695
695
  return record;
696
696
  }
697
697
  catch {
698
- throw new Error(`World boot record is incomplete or invalid: ${path}`);
698
+ throw new Error(`World boot record is incomplete or invalid: ${path}\nFrom the app directory, inspect: volter-world doctor ${name} --root .\nComplete teardown with the owning runtime: volter-world down ${name} --root .\nRetain the startup diagnostics and any history. Only a disposable instance with no state to keep may be removed with down --purge; resume never discards it automatically.`);
699
699
  }
700
700
  }
701
701
  export function admittedEnv(strip, env = process.env) {
@@ -2043,7 +2043,9 @@ export async function upWorld(configId, options = {}, abort) {
2043
2043
  return [];
2044
2044
  try {
2045
2045
  const prior = JSON.parse(readFileSync(file, 'utf8'));
2046
- return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env) }] : [];
2046
+ return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env),
2047
+ ...(prior.previousEnvOutput?.path === resolve(prior.envFile) ? { sha256: prior.previousEnvOutput.sha256 } : {}),
2048
+ }] : [];
2047
2049
  }
2048
2050
  catch {
2049
2051
  return [];
@@ -2248,6 +2250,7 @@ export async function upWorld(configId, options = {}, abort) {
2248
2250
  const worldInstance = {
2249
2251
  name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
2250
2252
  dirs: { instance, logs, data }, services, env, envFile, pidsFile,
2253
+ ...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
2251
2254
  networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]))),
2252
2255
  bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
2253
2256
  ...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
@@ -2416,7 +2419,12 @@ export async function upWorld(configId, options = {}, abort) {
2416
2419
  mkdirSync(dirname(envFile), { recursive: true });
2417
2420
  // Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
2418
2421
  // the mode the caller chose (a shared env file may be read under another uid).
2422
+ // Persist final environment identity while the claimed old output is still recoverable; caller loss between
2423
+ // this record and publication must not make the old file appear to be custody.
2424
+ saveWorldInstance(worldInstance);
2419
2425
  publishEnvOutput(envOutputClaim, envFileContents(env));
2426
+ delete worldInstance.previousEnvOutput;
2427
+ saveWorldInstance(worldInstance);
2420
2428
  boot.progress(`environment written: ${envFile}`);
2421
2429
  materializeBrowserFiles(data, root, name, loaded.config.id);
2422
2430
  writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
@@ -3562,6 +3570,10 @@ export async function doctorWorld(name, options = {}) {
3562
3570
  return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
3563
3571
  }
3564
3572
  checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
3573
+ const missingServices = (instance.bootServices ?? []).filter(id => !Object.hasOwn(instance.services, id));
3574
+ checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
3575
+ ? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
3576
+ : 'declared boot services have instance records' });
3565
3577
  if (instance.lifecycle) {
3566
3578
  checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
3567
3579
  message: `lifecycle ownership recorded; no capacity reserved; log=${instance.lifecycle.log}` });
@@ -532,6 +532,11 @@ export type WorldInstance = {
532
532
  bootServices?: string[];
533
533
  bootOwner?: string | null;
534
534
  envFile: string;
535
+ /** Exact output claimed before this boot replaced its predecessor; retained only until publication completes. */
536
+ previousEnvOutput?: {
537
+ path: string;
538
+ sha256: string;
539
+ };
535
540
  pidsFile: string;
536
541
  actors?: Record<string, unknown>;
537
542
  fixtures?: Record<string, unknown>;
@@ -1271,6 +1271,7 @@ export class WorldDoors {
1271
1271
  if (template)
1272
1272
  headers.set(TWIN_SITES_HEADER, template);
1273
1273
  headers.set(TWIN_SITE_HEADER, pageHost);
1274
+ headers.set(TWIN_PREFIX_HEADER, '/');
1274
1275
  headers.set('x-forwarded-host', url.host);
1275
1276
  headers.set('x-forwarded-proto', url.protocol.slice(0, -1));
1276
1277
  }
@@ -1460,6 +1461,10 @@ export class WorldDoors {
1460
1461
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
1461
1462
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
1462
1463
  const link = this.link(vendor);
1464
+ const screenMount = tail[0] === '_screen';
1465
+ // A declared screen mount is answered only by the twin, never forwarded to a real-system link.
1466
+ if (screenMount && link)
1467
+ return Response.json({ error: `${vendor} is linked to the vendor itself: no twin screen is mounted` }, { status: 404 });
1463
1468
  // a link spends the World's sealed vendor credential: never from the content plane, where any twin's markup runs
1464
1469
  if (link && this.onPages(url))
1465
1470
  return Response.json({ error: `${vendor} is linked to the vendor itself: it is used through the World's own origin` }, { status: 404 });
@@ -1471,6 +1476,22 @@ export class WorldDoors {
1471
1476
  const twin = this.host.twinFetch(vendor);
1472
1477
  if (!twin)
1473
1478
  return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
1479
+ // The Board's screen mount keeps the hostname the workspace declared. Without it, a multi-lane vendor's
1480
+ // /redis console page is sent to its Redis data API. The mount is the World's word, checked against discovery;
1481
+ // it survives redirects and forms through the public prefix instead of trusting a browser's routing header.
1482
+ let screenHost;
1483
+ if (screenMount) {
1484
+ try {
1485
+ screenHost = decodeURIComponent(tail[1] ?? '').toLowerCase();
1486
+ }
1487
+ catch {
1488
+ return Response.json({ error: 'invalid screen hostname' }, { status: 400 });
1489
+ }
1490
+ if (!screenHost || !(await this.screensOf(vendor)).some(screen => screen.host?.toLowerCase() === screenHost)) {
1491
+ return Response.json({ error: `no built workspace of ${vendor} declares that screen hostname` }, { status: 404 });
1492
+ }
1493
+ }
1494
+ const vendorPath = (screenMount ? tail.slice(2) : tail).join('/');
1474
1495
  // THE READ SCOPE: the twin is told the request is read-only and refuses the caller's writes itself
1475
1496
  // (a Slack read over POST passes, a post is refused); a twin that cannot enforce that gets reads only
1476
1497
  const enforced = scope === 'read' && (await this.enforcesRead(vendor, twin, url.origin));
@@ -1505,7 +1526,9 @@ export class WorldDoors {
1505
1526
  else
1506
1527
  headers.delete('cookie');
1507
1528
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
1508
- headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
1529
+ headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}${screenHost ? `/_screen/${encodeURIComponent(screenHost)}` : ''}`);
1530
+ if (screenHost)
1531
+ headers.set('x-volter-twin-original-host', screenHost);
1509
1532
  headers.set('x-forwarded-host', url.host);
1510
1533
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1511
1534
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
@@ -1516,7 +1539,7 @@ export class WorldDoors {
1516
1539
  else
1517
1540
  headers.delete(TWIN_SITES_HEADER);
1518
1541
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
1519
- const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
1542
+ const answer = await twin(new Request(`${url.origin}/${vendorPath}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
1520
1543
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
1521
1544
  if (answer.status === 101)
1522
1545
  return answer;
@@ -1802,6 +1825,8 @@ export class WorldDoors {
1802
1825
  return new Response(null, { status: 204, headers });
1803
1826
  }
1804
1827
  if (request.method === 'PUT') {
1828
+ if (request.headers.get('if-none-match') === '*' && worldBrowser(data, name))
1829
+ return Response.json({ error: `browser ${name} already exists; choose it or use another name` }, { status: 412 });
1805
1830
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
1806
1831
  return Response.json({ error: 'browser state is application/json' }, { status: 415 });
1807
1832
  if (!worldBrowser(data, name) && worldBrowsers(data).length >= BROWSER_COUNT_LIMIT)
@@ -2383,7 +2408,10 @@ export class WorldDoors {
2383
2408
  const origin = readBranchMeta(state, this.controlRoot(name))?.origin;
2384
2409
  const identity = { mounted: mounted?.resolvedPackage ? { package: mounted.resolvedPackage, version: mounted.resolvedVersion ?? null } : null, pinned: mounted?.pinnedSource ?? null };
2385
2410
  const dataOrigin = origin ? { kind: 'shared-history', url: origin.at } : refresh ? { kind: 'vendor-refresh', url: root?.url ?? null } : { kind: 'unrecorded', url: null };
2386
- return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
2411
+ const claimed = await this.claimedHosts(url.origin);
2412
+ const sites = (await this.browserSites(url.origin, claimed)).find(row => row.vendor === name)?.hosts ?? [];
2413
+ const readOnlySites = claimed.find(row => row.vendor === name)?.hosts ?? [];
2414
+ return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), sites, readOnlySites, resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
2387
2415
  }
2388
2416
  if (verb === 'refresh' && request.method === 'POST') {
2389
2417
  const root = rootForControlRoot(this.controlRoot(name), name);
@@ -2719,9 +2747,14 @@ export class WorldDoors {
2719
2747
  // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
2720
2748
  // one), whose changes are all of the vendor's
2721
2749
  const fallback = !listed.length;
2750
+ const screens = await this.screensOf(vendor);
2751
+ const screenPath = (path, hostname) => {
2752
+ const host = hostname?.toLowerCase();
2753
+ return host && !/[{<]/.test(host) && screens.some(screen => screen.host?.toLowerCase() === host)
2754
+ ? `/_screen/${encodeURIComponent(host)}${path}` : path;
2755
+ };
2722
2756
  let homeHost;
2723
2757
  if (fallback) {
2724
- const screens = await this.screensOf(vendor);
2725
2758
  const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
2726
2759
  homeHost = home?.host;
2727
2760
  if (home)
@@ -2785,7 +2818,7 @@ export class WorldDoors {
2785
2818
  at = { site: opened };
2786
2819
  }
2787
2820
  else if (f.drawn)
2788
- at = { screen: `${u.pathname}${u.search}` };
2821
+ at = { screen: screenPath(`${u.pathname}${u.search}`, u.hostname) };
2789
2822
  if (!at)
2790
2823
  unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
2791
2824
  }
@@ -2796,7 +2829,7 @@ export class WorldDoors {
2796
2829
  unseen = 'not-drawn';
2797
2830
  }
2798
2831
  if (fallback)
2799
- at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` };
2832
+ at = { screen: screenPath(f.url.startsWith('/') ? f.url : `/${f.url}`, homeHost) };
2800
2833
  add(at, undefined, unseen);
2801
2834
  }
2802
2835
  }
@@ -1,3 +1,3 @@
1
1
  import type { upWorld } from './runtime.js';
2
2
  export declare function upCommand(subject: string | undefined, rest: string[], root: string): Promise<void>;
3
- export declare function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void;
3
+ export declare function printReady(instance: Awaited<ReturnType<typeof upWorld>>, fresh?: boolean): void;
@@ -44,7 +44,7 @@ export async function upCommand(subject, rest, root) {
44
44
  if (rest.includes('--json'))
45
45
  process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
46
46
  else
47
- printReady(instance);
47
+ printReady(instance, !keepState);
48
48
  }
49
49
  async function upInProcess(configId, options) {
50
50
  const { upWorld } = await import("./runtime.js");
@@ -60,7 +60,7 @@ async function upInProcess(configId, options) {
60
60
  process.off('SIGTERM', cancel);
61
61
  }
62
62
  }
63
- export function printReady(instance) {
63
+ export function printReady(instance, fresh = false) {
64
64
  process.stdout.write(`World ${instance.name} is ready (${instance.config})
65
65
  Instance: ${instance.instance}
66
66
  Created: ${instance.createdAt}
@@ -68,8 +68,8 @@ Created: ${instance.createdAt}
68
68
  Services:
69
69
  ${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
70
70
 
71
- This instance started CLEAN (a world's state lives only while it runs; the world dir is the
72
- reproducible story) — set the clock and run your seed now, after every up.
71
+ ${fresh ? 'This is a fresh instance. Set the clock and run your seed before the application.'
72
+ : 'This World retains its branch state. Continue with the application; do not seed its records again.'}
73
73
 
74
74
  The two moves: if the twin stores it, create it through the vendor's own API (seed as the
75
75
  person, move the clock first for history). Everything else — judgment, lookups, faults — is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.145",
3
+ "version": "3.0.147",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -70,14 +70,14 @@
70
70
  "node": ">=22.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@volter/world-core": "3.0.145",
74
- "@volter/world-console": "3.0.140"
73
+ "@volter/world-core": "3.0.147",
74
+ "@volter/world-console": "3.0.142"
75
75
  },
76
76
  "dependencies": {
77
77
  "@electric-sql/pglite": "0.5.8",
78
78
  "@electric-sql/pglite-pgvector": "0.0.9",
79
79
  "@volter/world-access": "3.0.1",
80
- "@volter/world-core": "3.0.145",
80
+ "@volter/world-core": "3.0.147",
81
81
  "mongodb": "~6.20.0",
82
82
  "pg-gateway": "0.3.0-beta.4",
83
83
  "smol-toml": "^1.8.0",
package/src/covers.ts CHANGED
@@ -1125,11 +1125,12 @@ export function formatCoverageReport(report: CoverageReport): string {
1125
1125
  if (report.acknowledged.length > 0) {
1126
1126
  lines.push(`ACKNOWLEDGED: ${report.acknowledged.length} signal(s) accepted with recorded reasons (no twin): ${report.acknowledged.map((a) => a.vendor).join(', ')}`);
1127
1127
  }
1128
- if (report.excluded.length > 0) lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (not covered; no access granted).`);
1128
+ if (report.excluded.length > 0) lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (outside automatic selection; declared service bindings are reported separately).`);
1129
+ const detectionScope = report.excluded.length > 0 ? 'selected detected vendor' : 'detected vendor';
1129
1130
  lines.push(report.ok
1130
1131
  ? report.acknowledged.length > 0
1131
- ? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other detected vendor has a twin in world ${report.world}.`
1132
- : `COVERED: every detected vendor has a twin in world ${report.world}.`
1132
+ ? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other ${detectionScope} has a twin in world ${report.world}.`
1133
+ : `COVERED: every ${detectionScope} has a twin in world ${report.world}.`
1133
1134
  : `PROOF FAILED for world ${report.world}.`);
1134
1135
  lines.push('Static connection check only; run an app scenario to verify behavior and shared state.');
1135
1136
  return `${lines.join('\n')}\n`;
package/src/env-output.ts CHANGED
@@ -2,7 +2,7 @@ import { constants, closeSync, existsSync, fstatSync, ftruncateSync, lstatSync,
2
2
  import { dirname, join } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
 
5
- type PriorOutput = { path: string; contents: string };
5
+ type PriorOutput = { path: string; contents: string; sha256?: string };
6
6
  export type EnvOutputClaim = { path: string; contents?: Buffer; force: boolean; backupDir: string };
7
7
 
8
8
  function inspect(path: string): Buffer | undefined {
@@ -19,7 +19,10 @@ function inspect(path: string): Buffer | undefined {
19
19
  /** Refuse a custody/input file before any World service or lifecycle mutation starts. */
20
20
  export function claimEnvOutput(path: string, backupDir: string, priors: PriorOutput[] = [], force = false): EnvOutputClaim {
21
21
  const contents = inspect(path);
22
- if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && Buffer.from(prior.contents).equals(contents))) {
22
+ if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && (
23
+ Buffer.from(prior.contents).equals(contents)
24
+ || /^[a-f0-9]{64}$/.test(prior.sha256 ?? '') && createHash('sha256').update(contents).digest('hex') === prior.sha256
25
+ ))) {
23
26
  throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
24
27
  }
25
28
  if (force && contents !== undefined) {
package/src/init.ts CHANGED
@@ -122,6 +122,8 @@ export type EnvDisposition =
122
122
  | 'faked'
123
123
  /** a credential the vendor's twin issues at boot → `$issue:<vendor>` (the twin refuses a key it did not make) */
124
124
  | 'issued'
125
+ /** a World-owned endpoint or reserved environment name → supplied at boot, never copied from the app */
126
+ | 'injected'
125
127
  /** non-credential app config → the example value copied verbatim */
126
128
  | 'kept'
127
129
  /** infra connection string → points at a World-managed infrastructure service */
@@ -926,7 +928,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
926
928
  // World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
927
929
  // stale copy would shadow the live one.
928
930
  if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name) || worldInjected.has(name)) {
929
- envRows.push({ name, disposition: 'unknown', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
931
+ envRows.push({ name, disposition: 'injected', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
930
932
  continue;
931
933
  }
932
934
  // The application's own origin (its base URL, its auth library's URL): only the booter knows where the app is
@@ -1514,7 +1516,7 @@ export function formatInitReport(result: InitResult): string {
1514
1516
  lines.push('');
1515
1517
  const counts = (disposition: EnvDisposition): InitEnvRow[] => plan.envRows.filter((row) => row.disposition === disposition);
1516
1518
  lines.push(`Env (${plan.envRows.length} name(s) from ${plan.envSources.join(', ') || 'no example env file'}):`);
1517
- for (const disposition of ['faked', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown'] as EnvDisposition[]) {
1519
+ for (const disposition of ['faked', 'issued', 'injected', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown'] as EnvDisposition[]) {
1518
1520
  const rows = counts(disposition);
1519
1521
  if (rows.length === 0) continue;
1520
1522
  lines.push(` ${disposition.padEnd(11)} ${String(rows.length).padStart(3)} ${rows.map((row) => row.name).join(', ')}`);
package/src/runtime.ts CHANGED
@@ -698,8 +698,8 @@ function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
698
698
  }
699
699
  if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
700
700
  || new Set(record.bootServices).size !== record.bootServices.length
701
- || record.bootServices.length !== Object.keys(record.services).length
702
- || record.bootServices.some(id => !Object.hasOwn(record.services, id))) throw new Error();
701
+ || Object.keys(record.services).some(id => !record.bootServices!.includes(id))
702
+ || !stopped && record.bootServices.length !== Object.keys(record.services).length) throw new Error();
703
703
  for (const [id, service] of Object.entries(record.services)) {
704
704
  if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string') throw new Error();
705
705
  if (!Object.hasOwn(service, 'branchState')) {
@@ -716,7 +716,7 @@ function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
716
716
  instanceHash: createHash('sha256').update(readFileSync(path, 'utf8')).digest('hex') }) + '\n');
717
717
  }
718
718
  return record;
719
- } catch { throw new Error(`World boot record is incomplete or invalid: ${path}`); }
719
+ } catch { throw new Error(`World boot record is incomplete or invalid: ${path}\nFrom the app directory, inspect: volter-world doctor ${name} --root .\nComplete teardown with the owning runtime: volter-world down ${name} --root .\nRetain the startup diagnostics and any history. Only a disposable instance with no state to keep may be removed with down --purge; resume never discards it automatically.`); }
720
720
  }
721
721
 
722
722
  export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
@@ -1961,7 +1961,9 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1961
1961
  if (!existsSync(file)) return [];
1962
1962
  try {
1963
1963
  const prior = JSON.parse(readFileSync(file, 'utf8')) as WorldInstance;
1964
- return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env) }] : [];
1964
+ return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env),
1965
+ ...(prior.previousEnvOutput?.path === resolve(prior.envFile) ? { sha256: prior.previousEnvOutput.sha256 } : {}),
1966
+ }] : [];
1965
1967
  } catch { return []; }
1966
1968
  });
1967
1969
  const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutputs, options.forceEnvOutput);
@@ -2129,6 +2131,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2129
2131
  const worldInstance: WorldInstance = {
2130
2132
  name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
2131
2133
  dirs: { instance, logs, data }, services, env, envFile, pidsFile,
2134
+ ...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
2132
2135
  networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]!))),
2133
2136
  bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
2134
2137
  ...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
@@ -2274,7 +2277,12 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2274
2277
  mkdirSync(dirname(envFile), { recursive: true });
2275
2278
  // Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
2276
2279
  // the mode the caller chose (a shared env file may be read under another uid).
2280
+ // Persist final environment identity while the claimed old output is still recoverable; caller loss between
2281
+ // this record and publication must not make the old file appear to be custody.
2282
+ saveWorldInstance(worldInstance);
2277
2283
  publishEnvOutput(envOutputClaim, envFileContents(env));
2284
+ delete worldInstance.previousEnvOutput;
2285
+ saveWorldInstance(worldInstance);
2278
2286
  boot.progress(`environment written: ${envFile}`);
2279
2287
  materializeBrowserFiles(data, root, name, loaded.config.id);
2280
2288
  writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
@@ -3360,6 +3368,10 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3360
3368
  }
3361
3369
 
3362
3370
  checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
3371
+ const missingServices = (instance.bootServices ?? []).filter(id => !Object.hasOwn(instance.services, id));
3372
+ checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
3373
+ ? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
3374
+ : 'declared boot services have instance records' });
3363
3375
 
3364
3376
  if (instance.lifecycle) {
3365
3377
  checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
package/src/schema.ts CHANGED
@@ -463,6 +463,8 @@ export type WorldInstance = {
463
463
  bootServices?: string[];
464
464
  bootOwner?: string | null;
465
465
  envFile: string;
466
+ /** Exact output claimed before this boot replaced its predecessor; retained only until publication completes. */
467
+ previousEnvOutput?: { path: string; sha256: string };
466
468
  pidsFile: string;
467
469
  actors?: Record<string, unknown>;
468
470
  fixtures?: Record<string, unknown>;
@@ -1133,6 +1133,7 @@ export class WorldDoors {
1133
1133
  const template = this.host.sitesTemplate?.();
1134
1134
  if (template) headers.set(TWIN_SITES_HEADER, template);
1135
1135
  headers.set(TWIN_SITE_HEADER, pageHost);
1136
+ headers.set(TWIN_PREFIX_HEADER, '/');
1136
1137
  headers.set('x-forwarded-host', url.host);
1137
1138
  headers.set('x-forwarded-proto', url.protocol.slice(0, -1));
1138
1139
  }
@@ -1294,6 +1295,9 @@ export class WorldDoors {
1294
1295
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
1295
1296
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
1296
1297
  const link = this.link(vendor!);
1298
+ const screenMount = tail[0] === '_screen';
1299
+ // A declared screen mount is answered only by the twin, never forwarded to a real-system link.
1300
+ if (screenMount && link) return Response.json({ error: `${vendor} is linked to the vendor itself: no twin screen is mounted` }, { status: 404 });
1297
1301
  // a link spends the World's sealed vendor credential: never from the content plane, where any twin's markup runs
1298
1302
  if (link && this.onPages(url)) return Response.json({ error: `${vendor} is linked to the vendor itself: it is used through the World's own origin` }, { status: 404 });
1299
1303
  if (link) {
@@ -1302,6 +1306,18 @@ export class WorldDoors {
1302
1306
  }
1303
1307
  const twin = this.host.twinFetch(vendor!);
1304
1308
  if (!twin) return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
1309
+ // The Board's screen mount keeps the hostname the workspace declared. Without it, a multi-lane vendor's
1310
+ // /redis console page is sent to its Redis data API. The mount is the World's word, checked against discovery;
1311
+ // it survives redirects and forms through the public prefix instead of trusting a browser's routing header.
1312
+ let screenHost: string | undefined;
1313
+ if (screenMount) {
1314
+ try { screenHost = decodeURIComponent(tail[1] ?? '').toLowerCase(); }
1315
+ catch { return Response.json({ error: 'invalid screen hostname' }, { status: 400 }); }
1316
+ if (!screenHost || !(await this.screensOf(vendor!)).some(screen => screen.host?.toLowerCase() === screenHost)) {
1317
+ return Response.json({ error: `no built workspace of ${vendor} declares that screen hostname` }, { status: 404 });
1318
+ }
1319
+ }
1320
+ const vendorPath = (screenMount ? tail.slice(2) : tail).join('/');
1305
1321
  // THE READ SCOPE: the twin is told the request is read-only and refuses the caller's writes itself
1306
1322
  // (a Slack read over POST passes, a post is refused); a twin that cannot enforce that gets reads only
1307
1323
  const enforced = scope === 'read' && (await this.enforcesRead(vendor!, twin, url.origin));
@@ -1323,14 +1339,15 @@ export class WorldDoors {
1323
1339
  const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
1324
1340
  if (cookies.length) headers.set('cookie', cookies.join('; ')); else headers.delete('cookie');
1325
1341
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
1326
- headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
1342
+ headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}${screenHost ? `/_screen/${encodeURIComponent(screenHost)}` : ''}`);
1343
+ if (screenHost) headers.set('x-volter-twin-original-host', screenHost);
1327
1344
  headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1328
1345
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
1329
1346
  // origin on this machine (siteOfHost); a World without one shows none
1330
1347
  const sitesAt = this.host.sitesTemplate?.() ?? null;
1331
1348
  if (sitesAt) headers.set(TWIN_SITES_HEADER, sitesAt); else headers.delete(TWIN_SITES_HEADER);
1332
1349
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
1333
- const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
1350
+ const answer = await twin(new Request(`${url.origin}/${vendorPath}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
1334
1351
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
1335
1352
  if (answer.status === 101) return answer;
1336
1353
  // staleness is a fact on the wire: a twin with a root says when the vendor was last observed
@@ -1554,6 +1571,7 @@ export class WorldDoors {
1554
1571
  return new Response(null, { status: 204, headers });
1555
1572
  }
1556
1573
  if (request.method === 'PUT') {
1574
+ if (request.headers.get('if-none-match') === '*' && worldBrowser(data, name)) return Response.json({ error: `browser ${name} already exists; choose it or use another name` }, { status: 412 });
1557
1575
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'browser state is application/json' }, { status: 415 });
1558
1576
  if (!worldBrowser(data, name) && worldBrowsers(data).length >= BROWSER_COUNT_LIMIT) return Response.json({ error: `a World holds at most ${BROWSER_COUNT_LIMIT} browsers; delete one before adding another` }, { status: 409 });
1559
1577
  const oversized = (): Response => Response.json({ error: `browser state PUT body is limited to ${BROWSER_STATE_BYTES_LIMIT} bytes (1 MiB)` }, { status: 413 });
@@ -1992,7 +2010,10 @@ export class WorldDoors {
1992
2010
  const origin = readBranchMeta(state, this.controlRoot(name))?.origin;
1993
2011
  const identity = { mounted: mounted?.resolvedPackage ? { package: mounted.resolvedPackage, version: mounted.resolvedVersion ?? null } : null, pinned: mounted?.pinnedSource ?? null };
1994
2012
  const dataOrigin = origin ? { kind: 'shared-history', url: origin.at } : refresh ? { kind: 'vendor-refresh', url: root?.url ?? null } : { kind: 'unrecorded', url: null };
1995
- return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
2013
+ const claimed = await this.claimedHosts(url.origin);
2014
+ const sites = (await this.browserSites(url.origin, claimed)).find(row => row.vendor === name)?.hosts ?? [];
2015
+ const readOnlySites = claimed.find(row => row.vendor === name)?.hosts ?? [];
2016
+ return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), sites, readOnlySites, resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
1996
2017
  }
1997
2018
  if (verb === 'refresh' && request.method === 'POST') {
1998
2019
  const root = rootForControlRoot(this.controlRoot(name), name);
@@ -2270,9 +2291,14 @@ export class WorldDoors {
2270
2291
  // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
2271
2292
  // one), whose changes are all of the vendor's
2272
2293
  const fallback = !listed.length;
2294
+ const screens = await this.screensOf(vendor);
2295
+ const screenPath = (path: string, hostname?: string): string => {
2296
+ const host = hostname?.toLowerCase();
2297
+ return host && !/[{<]/.test(host) && screens.some(screen => screen.host?.toLowerCase() === host)
2298
+ ? `/_screen/${encodeURIComponent(host)}${path}` : path;
2299
+ };
2273
2300
  let homeHost: string | undefined;
2274
2301
  if (fallback) {
2275
- const screens = await this.screensOf(vendor);
2276
2302
  const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
2277
2303
  homeHost = home?.host;
2278
2304
  if (home) listed = [{ id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, covers: [], drawn: true }];
@@ -2320,10 +2346,10 @@ export class WorldDoors {
2320
2346
  const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`);
2321
2347
  if (opened) at = { site: opened };
2322
2348
  }
2323
- else if (f.drawn) at = { screen: `${u.pathname}${u.search}` };
2349
+ else if (f.drawn) at = { screen: screenPath(`${u.pathname}${u.search}`, u.hostname) };
2324
2350
  if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
2325
2351
  } catch { if (fallback) at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` }; else unseen = 'not-drawn'; }
2326
- if (fallback) at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` };
2352
+ if (fallback) at = { screen: screenPath(f.url.startsWith('/') ? f.url : `/${f.url}`, homeHost) };
2327
2353
  add(at, undefined, unseen);
2328
2354
  }
2329
2355
  }
package/src/up-cli.ts CHANGED
@@ -40,7 +40,7 @@ export async function upCommand(subject: string | undefined, rest: string[], roo
40
40
  process.stderr.write(`volter-world up ${instance.name}: app url recorded: ${appUrl}\n`);
41
41
  }
42
42
  if (rest.includes('--json')) process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
43
- else printReady(instance);
43
+ else printReady(instance, !keepState);
44
44
  }
45
45
 
46
46
  async function upInProcess(configId: string, options: Parameters<typeof upWorld>[1]): Promise<Awaited<ReturnType<typeof upWorld>>> {
@@ -52,7 +52,7 @@ async function upInProcess(configId: string, options: Parameters<typeof upWorld>
52
52
  finally { process.off('SIGINT', cancel); process.off('SIGTERM', cancel); }
53
53
  }
54
54
 
55
- export function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void {
55
+ export function printReady(instance: Awaited<ReturnType<typeof upWorld>>, fresh = false): void {
56
56
  process.stdout.write(`World ${instance.name} is ready (${instance.config})
57
57
  Instance: ${instance.instance}
58
58
  Created: ${instance.createdAt}
@@ -60,8 +60,8 @@ Created: ${instance.createdAt}
60
60
  Services:
61
61
  ${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
62
62
 
63
- This instance started CLEAN (a world's state lives only while it runs; the world dir is the
64
- reproducible story) — set the clock and run your seed now, after every up.
63
+ ${fresh ? 'This is a fresh instance. Set the clock and run your seed before the application.'
64
+ : 'This World retains its branch state. Continue with the application; do not seed its records again.'}
65
65
 
66
66
  The two moves: if the twin stores it, create it through the vendor's own API (seed as the
67
67
  person, move the clock first for history). Everything else — judgment, lookups, faults — is