@volter/world-runtime 2.0.0 → 2.0.2

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 (78) hide show
  1. package/dist/known-external-services.json +0 -8
  2. package/dist/src/app-url.js +1 -1
  3. package/dist/src/branch.js +79 -2
  4. package/dist/src/catalog.js +1 -1
  5. package/dist/src/cli.js +50 -22
  6. package/dist/src/console-apart.d.ts +1 -0
  7. package/dist/src/console-apart.js +7 -0
  8. package/dist/src/covers.js +4 -2
  9. package/dist/src/fixture-env.d.ts +3 -0
  10. package/dist/src/fixture-env.js +24 -0
  11. package/dist/src/host-worker.js +2 -9
  12. package/dist/src/host.js +2 -9
  13. package/dist/src/import-module.d.ts +1 -0
  14. package/dist/src/import-module.js +16 -0
  15. package/dist/src/index.d.ts +7 -2
  16. package/dist/src/index.js +4 -1
  17. package/dist/src/infra-cli.js +61 -12
  18. package/dist/src/init.d.ts +1 -1
  19. package/dist/src/init.js +34 -5
  20. package/dist/src/local-branches.d.ts +37 -0
  21. package/dist/src/local-branches.js +193 -0
  22. package/dist/src/pglite-backing.d.ts +8 -0
  23. package/dist/src/pglite-backing.js +121 -35
  24. package/dist/src/pglite-host.mjs +520 -14
  25. package/dist/src/prerequisites.js +1 -1
  26. package/dist/src/process-groups.js +1 -1
  27. package/dist/src/redirect-proxy.d.ts +1 -1
  28. package/dist/src/redirect-proxy.js +4 -4
  29. package/dist/src/redis-backing.d.ts +8 -0
  30. package/dist/src/redis-backing.js +120 -0
  31. package/dist/src/root.d.ts +23 -0
  32. package/dist/src/root.js +22 -10
  33. package/dist/src/run-task.js +1 -1
  34. package/dist/src/runtime.d.ts +1 -0
  35. package/dist/src/runtime.js +37 -20
  36. package/dist/src/schema.d.ts +5 -0
  37. package/dist/src/schema.js +10 -1
  38. package/dist/src/served-world.d.ts +196 -9
  39. package/dist/src/served-world.js +847 -103
  40. package/dist/src/service-recorder.js +1 -1
  41. package/dist/src/storage-capacity.js +2 -2
  42. package/dist/src/up-task.js +1 -1
  43. package/dist/src/world-origins.d.ts +13 -0
  44. package/dist/src/world-origins.js +37 -0
  45. package/dist/src/world-view.d.ts +25 -0
  46. package/dist/src/world-view.js +110 -0
  47. package/known-external-services.json +0 -8
  48. package/package.json +10 -4
  49. package/src/app-url.ts +1 -1
  50. package/src/branch.ts +66 -2
  51. package/src/catalog.ts +1 -1
  52. package/src/cli.ts +43 -21
  53. package/src/console-apart.ts +7 -1
  54. package/src/covers.ts +4 -2
  55. package/src/fixture-env.ts +25 -0
  56. package/src/host-worker.ts +2 -1
  57. package/src/host.ts +2 -1
  58. package/src/import-module.ts +9 -0
  59. package/src/index.ts +7 -2
  60. package/src/infra-cli.ts +56 -12
  61. package/src/init.ts +34 -5
  62. package/src/local-branches.ts +173 -0
  63. package/src/pglite-backing.ts +112 -36
  64. package/src/pglite-host.mjs +520 -14
  65. package/src/prerequisites.ts +1 -1
  66. package/src/process-groups.ts +1 -1
  67. package/src/redirect-proxy.ts +4 -4
  68. package/src/redis-backing.ts +107 -0
  69. package/src/root.ts +27 -2
  70. package/src/run-task.ts +1 -1
  71. package/src/runtime.ts +38 -20
  72. package/src/schema.ts +11 -1
  73. package/src/served-world.ts +762 -85
  74. package/src/service-recorder.ts +1 -1
  75. package/src/storage-capacity.ts +2 -2
  76. package/src/up-task.ts +1 -1
  77. package/src/world-origins.ts +38 -0
  78. package/src/world-view.ts +100 -0
@@ -152,6 +152,11 @@ export function isGoogleServiceAccountEnvName(name: string): boolean {
152
152
  * world-runtime must not import a pack, and the tests pin every value against the injector's actual
153
153
  * VENDOR_HOSTS predicate so either side changing makes the contract fail loudly. */
154
154
  const ENDPOINT_SHAPES: Array<{ match: RegExp; value: string; source: string }> = [
155
+ // the vendor's own API URL, which the World routes to its twin: an app that reads a base-URL override (empty in its
156
+ // example, the vendor's URL as its default) gets the vendor's URL, never an unreachable fake
157
+ { match: /^DYNADOT_BASE_URL$/, value: 'https://api.dynadot.com/api3.json', source: 'packages/twin/dynadot/src/index.ts hosts (api.dynadot.com); dubinc/dub apps/web/lib/dynadot/client.ts default' },
158
+ // Vercel KV's REST URL (the Upstash Redis REST API under Vercel's names): an upstash host the twin serves
159
+ { match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'packages/world-core inject VENDOR_HOSTS.upstash; lukevella/rallly KV_REST_API_URL' },
155
160
  {
156
161
  match: /(^|_)UPSTASH_REDIS_REST_URL$/,
157
162
  value: 'https://twin-fake.upstash.io',
@@ -200,6 +205,14 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
200
205
  // (`.clerk.accounts.dev`, packages/twin/clerk/src/index.ts hosts), so the boundary — the injector
201
206
  // server-side, the browser proxy browser-side — resolves it to the twin, and an app CSP that
202
207
  // allowlists that host stays legal (peak drive 2026-08-26).
208
+ // Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
209
+ // refuses a key that is not `sk_…`/`pk_…`); a webhook signing secret is `whsec_…`. Test mode, as a World is.
210
+ { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
211
+ { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
212
+ { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
213
+ // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
214
+ { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
215
+ { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
203
216
  { match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'packages/twin/clerk/src/clerk-twin.ts' },
204
217
  { match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'packages/twin/clerk/src/index.ts' },
205
218
  ];
@@ -211,8 +224,20 @@ const APP_KEY_SHAPES: Array<{ match: RegExp; value: () => string; source: string
211
224
  // AES-256-GCM: the key base64-decodes to 32 bytes ("ENCRYPTION_KEY must be 32 bytes (base64-encoded)"); the 32 bytes
212
225
  // spell out that they are fake
213
226
  { match: /^ENCRYPTION_KEY$/, value: () => Buffer.from('twin-fake-encryption-key-32bytes').toString('base64'), source: "dubinc/dub apps/web/lib/encryption.ts" },
227
+ // AES-256-CBC as hex: the key is 32 bytes (64 hex characters) and the IV 16 (32); LibreChat refuses to boot on any
228
+ // other shape; the bytes spell out that they are fake
229
+ // AES-256 over the key's latin1 bytes: exactly 32 characters (Cal.com's symmetricEncrypt)
230
+ { match: /^CALENDSO_ENCRYPTION_KEY$/, value: () => 'twin-fake-calendso-key-32-chars!', source: 'calcom/cal.diy packages/lib/crypto.ts' },
231
+ { match: /^CREDS_KEY$/, value: () => Buffer.from('twin-fake-creds-key-of-32-bytes!').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
232
+ { match: /^CREDS_IV$/, value: () => Buffer.from('twin-fake-iv-16b').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
214
233
  ];
215
234
 
235
+ /** Whether `name` is a key an application parses (APP_KEY_SHAPES): a secret init fakes in its shape, whatever the
236
+ * example leaves it (LibreChat's CREDS_IV is empty in its example and not credential-shaped by name). */
237
+ export function isAppKeyName(name: string): boolean {
238
+ return APP_KEY_SHAPES.some((shape) => shape.match.test(name));
239
+ }
240
+
216
241
  export function fakeEnvValue(name: string): string {
217
242
  if (isGoogleOAuthClientEnvName(name)) return fakeGoogleOAuthClientJson();
218
243
  for (const shape of APP_KEY_SHAPES) if (shape.match.test(name)) return shape.value();
@@ -4,13 +4,14 @@
4
4
  // red) without disturbing sibling twins. Posts {type:'ready'} once listening.
5
5
  import { parentPort, workerData } from 'node:worker_threads';
6
6
  import type { ColocatedTwinSpec } from './host.ts';
7
+ import { importModule } from './import-module.ts';
7
8
 
8
9
  const spec = workerData as ColocatedTwinSpec;
9
10
 
10
11
  type TwinServer = { port: number; stop: () => void | Promise<void> };
11
12
  type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean }) => TwinServer | Promise<TwinServer>;
12
13
 
13
- const mod = (await import(spec.module)) as Record<string, unknown>;
14
+ const mod = await importModule(spec.module);
14
15
  const factory = mod[spec.export] as TwinServerFactory | undefined;
15
16
  if (typeof factory !== 'function') {
16
17
  throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
package/src/host.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  // world config that drives it) stays vendor-agnostic — exactly like `bin` spawning.
22
22
  import { Worker } from 'node:worker_threads';
23
23
  import { siblingScript } from './sibling.ts';
24
+ import { importModule } from './import-module.ts';
24
25
  import { pathToFileURL } from 'node:url';
25
26
 
26
27
  /** One twin to mount in the host. `module`/`export` name a `({port,root,readOnly}) => {port,stop}`
@@ -57,7 +58,7 @@ type TwinServer = { port: number; stop: () => void | Promise<void> };
57
58
  type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string }) => TwinServer | Promise<TwinServer>;
58
59
 
59
60
  async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory> {
60
- const mod = (await import(spec.module)) as Record<string, unknown>;
61
+ const mod = await importModule(spec.module);
61
62
  const factory = mod[spec.export];
62
63
  if (typeof factory !== 'function') {
63
64
  throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
@@ -0,0 +1,9 @@
1
+ // import() of what a World names a module by: a package name, a file URL, or a file path. Node's ESM loader takes an
2
+ // absolute path only as a file:// URL — on Windows `C:\…` reads as a URL with the scheme `c:` and is refused
3
+ // (ERR_UNSUPPORTED_ESM_URL_SCHEME); Bun takes either. A path becomes its file URL here; anything else is passed on.
4
+ import { isAbsolute } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ export function importModule<T = Record<string, unknown>>(specifier: string): Promise<T> {
8
+ return import(isAbsolute(specifier) ? pathToFileURL(specifier).href : specifier) as Promise<T>;
9
+ }
package/src/index.ts CHANGED
@@ -163,8 +163,13 @@ export { branchWorld, checkoutWorld } from './branch.ts';
163
163
  export type { BranchOptions } from './branch.ts';
164
164
  export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
165
165
  export type { DeployTwinOutcome, MaterializedRoot } from './root.ts';
166
- export { landReceipts, mountWorld, readServeRecord, refreshSchedule, servedName, serveWorld, TOKEN_HEADER, WorldDoors } from './served-world.ts';
167
- export type { DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
166
+ export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.ts';
167
+ export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
168
+ export { LocalBranches } from './local-branches.ts';
169
+ export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.ts';
170
+ export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
171
+ export type { ViewConsole, WorldView } from './world-view.ts';
172
+ export type { LocalBranchesOptions } from './local-branches.ts';
168
173
  export type { ServedWorld } from './served-world.ts';
169
174
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type ConsoleMount } from './console-apart.ts';
170
175
 
package/src/infra-cli.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
8
9
 
9
10
  const phase = process.argv[2];
@@ -25,7 +26,7 @@ if (!existsSync(definition)) {
25
26
  }
26
27
 
27
28
  const base = ['compose', '-f', definition];
28
- const run = (args: string[]) => spawnSync('docker', [...base, ...args], {
29
+ const run = (args: string[]) => spawnSync('docker', [...base, ...args], { windowsHide: true,
29
30
  encoding: 'utf8',
30
31
  env: { ...process.env, VOLTER_WORLD_DATA: worldData },
31
32
  timeout: 120_000,
@@ -48,8 +49,9 @@ const fail = (result: ReturnType<typeof run>): never => {
48
49
  // runtime answers it is private and chosen here, per machine, at each phase:
49
50
  // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
50
51
  // 2. a working container runtime — the compose path, byte-identical to before;
51
- // 3. no container runtime + a postgres-only definition — the PGlite backing
52
- // (pglite-host.ts), announced loudly;
52
+ // 3. no container runtime + a definition of postgres and/or mongodb services —
53
+ // the containerless backing (pglite-backing.ts: PGlite for postgres, the
54
+ // MongoDB twin for mongodb), announced loudly;
53
55
  // 4. otherwise the honest refusal naming what this machine cannot serve.
54
56
  function selectBacking(): 'docker' | 'pglite' {
55
57
  const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
@@ -58,7 +60,7 @@ function selectBacking(): 'docker' | 'pglite' {
58
60
  process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
59
61
  process.exit(2);
60
62
  }
61
- const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8', timeout: 10_000 });
63
+ const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { windowsHide: true, encoding: 'utf8', timeout: 10_000 });
62
64
  if (probe.status === 0) return 'docker';
63
65
  // Absence of a container runtime (no binary, no daemon) is a capability
64
66
  // difference: swap backings. A PRESENT runtime failing on resources stays
@@ -71,7 +73,7 @@ function selectBacking(): 'docker' | 'pglite' {
71
73
 
72
74
  async function runPglite(): Promise<never> {
73
75
  const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
74
- const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
76
+ const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
75
77
  if (services.length === 0) {
76
78
  process.stderr.write('managed infrastructure: the declared definition names no services\n');
77
79
  process.exit(1);
@@ -82,7 +84,7 @@ async function runPglite(): Promise<never> {
82
84
  process.exit(1);
83
85
  }
84
86
  if (phase === 'up') {
85
- process.stdout.write('managed infrastructure backing: pglite (no container runtime)\n');
87
+ process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
86
88
  try {
87
89
  await pgliteUp(services, worldData!);
88
90
  } catch (error) {
@@ -98,7 +100,7 @@ async function runPglite(): Promise<never> {
98
100
  process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
99
101
  process.exit(1);
100
102
  }
101
- process.stdout.write(`${JSON.stringify({ ok: true, services: services.length, connections: infraConnections(services, process.env) })}\n`);
103
+ process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
102
104
  process.exit(0);
103
105
  }
104
106
  await pgliteDown(services, worldData!);
@@ -106,12 +108,54 @@ async function runPglite(): Promise<never> {
106
108
  process.exit(0);
107
109
  }
108
110
 
111
+ // ---- redis: the twin, containerless ----------------------------------------
112
+ // A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
113
+ // unless the operator forces the container backing; the other services take the backing chosen above. Where the
114
+ // twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
115
+ // the container where there is one, else the backing's refusal naming redis.
116
+ const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
117
+ const { redisTwinCli } = await import('./redis-backing.ts');
118
+ const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
119
+ const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
120
+ /** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
121
+ const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
122
+ if (twinned.length > 0) await runRedisTwin();
123
+
124
+ async function runRedisTwin(): Promise<void> {
125
+ const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
126
+ const cli = twinCli!;
127
+ if (phase === 'up') {
128
+ process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
129
+ try {
130
+ await redisTwinUp(twinned, worldData!, cli);
131
+ } catch (error) {
132
+ process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
133
+ process.exit(1);
134
+ }
135
+ } else if (phase === 'status') {
136
+ const ready = await redisTwinStatus(twinned, worldData!);
137
+ if (ready !== twinned.length) {
138
+ process.stderr.write(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
139
+ process.exit(1);
140
+ }
141
+ } else {
142
+ await redisTwinDown(twinned, worldData!);
143
+ }
144
+ if (composed!.length === 0) {
145
+ if (phase === 'up') process.stdout.write('managed infrastructure ready\n');
146
+ else if (phase === 'status') process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
147
+ else process.stdout.write('managed infrastructure stopped\n');
148
+ process.exit(0);
149
+ }
150
+ }
151
+
109
152
  if (selectBacking() === 'pglite') {
110
153
  await runPglite();
111
154
  }
112
155
 
156
+ const only = composed ?? [];
113
157
  if (phase === 'up') {
114
- const result = run(['up', '-d', '--wait']);
158
+ const result = run(['up', '-d', '--wait', ...only]);
115
159
  if (result.status !== 0) fail(result);
116
160
  process.stdout.write('managed infrastructure ready\n');
117
161
  } else if (phase === 'status') {
@@ -119,13 +163,13 @@ if (phase === 'up') {
119
163
  if (expected.status !== 0) fail(expected);
120
164
  const running = run(['ps', '--status', 'running', '--services']);
121
165
  if (running.status !== 0) fail(running);
122
- const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).sort();
123
- const runningNames = running.stdout.split(/\s+/u).filter(Boolean).sort();
166
+ const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
167
+ const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
124
168
  if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
125
- process.stderr.write(`managed infrastructure status failed: ${runningNames.length}/${expectedNames.length} declared services are ready\n`);
169
+ process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
126
170
  process.exit(1);
127
171
  }
128
- process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length, connections: infraConnections(parseInfraDefinition(readFileSync(definition, 'utf8')), process.env) })}\n`);
172
+ process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
129
173
  } else {
130
174
  const result = run(['down', '--remove-orphans']);
131
175
  if (result.status !== 0) fail(result);
package/src/init.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  registryAcknowledgedReason,
48
48
  type CoverageReport,
49
49
  } from './covers.ts';
50
- import { fakeEnvValue, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
50
+ import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
51
51
  import { resolveCatalog, twinPackageName, type Catalog } from './catalog.ts';
52
52
  import { projectEnvReads, projectManifestDirs } from './project-inspect.ts';
53
53
  import { overlayEndpointEnv, packFacts } from './pack-facts.ts';
@@ -300,6 +300,20 @@ export const APP_READ_ENDPOINT_ENV: Record<string, { injectEnv?: string; injectE
300
300
  + 'sends there. Read the mail back over the twin-only inspect sidecar: `world-smtp serve --inspect-port N`, then '
301
301
  + 'GET http://127.0.0.1:N/twin/messages/latest.',
302
302
  },
303
+ // RAW PROTOCOL, the gRPC kind. The Temporal SDKs dial the frontend address they are given
304
+ // (`Connection.connect({ address })`, `NativeConnection.connect({ address })`), conventionally read
305
+ // from TEMPORAL_ADDRESS — Postiz's temporal.module.ts, the SDK samples and the Temporal CLI all read
306
+ // that name — and speak gRPC over HTTP/2, which the http/fetch injector never sees. The value is an
307
+ // address (host:port), not a URL, which is why this lives here as a template and not as the
308
+ // descriptor's `endpointEnv` (whose `name` receives the twin's URL).
309
+ temporal: {
310
+ injectEnvTemplates: {
311
+ TEMPORAL_ADDRESS: '${host}:${port}',
312
+ },
313
+ note: 'no injector entry and none possible: the Temporal SDKs speak gRPC to the address they are configured with. '
314
+ + 'TEMPORAL_ADDRESS=<host>:<port> points them at the twin (namespace `default` exists); an app that sets '
315
+ + 'TEMPORAL_API_KEY turns TLS on in the SDK, which this loopback frontend does not speak — leave it unset.',
316
+ },
303
317
  };
304
318
  // descriptor-first migration (adding-a-twin.md §3): packs now declare their endpoint-env wiring (with its grounding
305
319
  // note) on the descriptor (`endpointEnv` on TwinPack); the table above shrinks toward empty as
@@ -442,7 +456,7 @@ const INFRA_PLACEHOLDER: Record<string, string> = {
442
456
  //
443
457
  // The subject pilots (dub, cal.com) put the number on it: world-side setup is seconds, and the
444
458
  // minutes go to the app side — most avoidably, to hand-writing infrastructure for the Postgres/
445
- // MySQL/Redis the repo signalled. For the kinds below, `init` upgrades the placeholder to a
459
+ // MySQL/Redis/MongoDB the repo signalled. For the kinds below, `init` upgrades the placeholder to a
446
460
  // World-managed service and private definition with env URLs already pointing at it. The helper
447
461
  // owns its implementation behind the declared service boundary. Kinds without a recipe keep the
448
462
  // placeholder + `//infra` stub.
@@ -479,6 +493,15 @@ const COMPOSE_INFRA: Record<string, {
479
493
  volumePath: '/data',
480
494
  healthcheck: () => ['CMD', 'redis-cli', 'ping'],
481
495
  },
496
+ // Without a container runtime the containerless backing serves this kind with the MongoDB twin
497
+ // (@volter/twin-mongodb), at the same loopback port and URL.
498
+ mongodb: {
499
+ image: 'mongo:7',
500
+ containerPort: 27017,
501
+ memoryMiB: 1024,
502
+ volumePath: '/data/db',
503
+ healthcheck: () => ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping').ok"],
504
+ },
482
505
  };
483
506
 
484
507
  /** FNV-1a 32-bit — a tiny, dependency-free stable string hash for port derivation. */
@@ -525,9 +548,12 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
525
548
  const recipe = COMPOSE_INFRA[kind]!;
526
549
  const hostPort = composePort(name, kind, taken);
527
550
  const ident = composeIdent(name);
551
+ // redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
528
552
  const url = kind === 'redis'
529
553
  ? `redis://127.0.0.1:${hostPort}`
530
- : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
554
+ : kind === 'mongodb'
555
+ ? `mongodb://127.0.0.1:${hostPort}/${ident}`
556
+ : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
531
557
  return {
532
558
  kind,
533
559
  image: recipe.image,
@@ -540,7 +566,7 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
540
566
  };
541
567
  }
542
568
 
543
- /** The declared env of one compose service (empty for redis). */
569
+ /** The declared env of one compose service (empty for redis and mongodb). */
544
570
  function composeEnvironment(name: string, kind: string): Array<[string, string]> {
545
571
  const ident = composeIdent(name);
546
572
  if (kind === 'postgres') {
@@ -858,7 +884,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
858
884
  // RULE 3: credential-shaped means faked, even when no vendor claims the stem. That covers
859
885
  // app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
860
886
  // reason a live key committed to `.env.example` can never reach the emitted world.
861
- if (isCredentialShapedEnvName(name)) {
887
+ if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
862
888
  env[name] = fakeEnvValue(name);
863
889
  envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
864
890
  continue;
@@ -1138,6 +1164,9 @@ current
1138
1164
  *.env
1139
1165
  credentials/
1140
1166
  token
1167
+ token.read
1168
+ sessions.json
1169
+ keys.json
1141
1170
  `;
1142
1171
 
1143
1172
  export function writeWorldInit(plan: InitPlan, options: { force?: boolean } = {}): void {
@@ -0,0 +1,173 @@
1
+ // LOCAL BRANCHES: the branches a local host makes of the Worlds it serves (docs/contributing/architecture.md,
2
+ // "Viewing a World"). A branch is another World: the parent's config under a new name, cloned from the
3
+ // parent through its own doors (its history cut at an instant, when one is asked), its clock frozen at
4
+ // that instant so no twin's catch-up walks it forward, and removed when its time runs out. world-host
5
+ // and `volter world view` both make them with this; a hosted World's supervisor makes its own.
6
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { stateDirName, withStateRemoval } from '@volter/world-core';
9
+ import { clockFile } from './runtime.ts';
10
+ import { loadWorldConfig } from './configs.ts';
11
+ import { TOKEN_HEADER, type BranchDoors, type BranchRow, type MountedWorld } from './served-world.ts';
12
+
13
+ /** What a branch records beside itself: whose branch it is, as of when, and until when. */
14
+ type BranchRecord = { from: string; at: { instant?: string; live?: boolean; label?: string } | null; createdAt: string; expiresAt: string | null };
15
+ const RECORD = 'branch.json';
16
+ const SWEEP_MS = 60_000;
17
+
18
+ /** A World's manifest under another name, in the manifest's own format: format 2 names a World in
19
+ * `metadata.id` and serves it bare at `serving.name`; format 1 in `id` and `bare.name`. */
20
+ function renamed(config: Record<string, unknown>, id: string, name: string): Record<string, unknown> {
21
+ if (config.schemaVersion === 2) {
22
+ return { ...config, metadata: { ...(config.metadata as Record<string, unknown> | undefined), id }, serving: { ...(config.serving as Record<string, unknown> | undefined), mode: 'bare', name } };
23
+ }
24
+ return { ...config, id, bare: { ...(config.bare as Record<string, unknown> | undefined), name } };
25
+ }
26
+
27
+ export type LocalBranchesOptions = {
28
+ /** where branches live: `<dir>/<org>/<world>/` */
29
+ dir: string;
30
+ /** the URL a branch reaches its parent at (the host's own listener, on loopback) */
31
+ origin: () => string;
32
+ /** the Worlds the host serves, by served name; branches join it while they live */
33
+ worlds: Map<string, MountedWorld>;
34
+ /** mount a World at a root, with its branches doors wired */
35
+ mount: (root: string) => Promise<MountedWorld>;
36
+ announce?: (line: string) => void;
37
+ };
38
+
39
+ export class LocalBranches {
40
+ private sweeper: ReturnType<typeof setInterval> | null = null;
41
+ constructor(private readonly o: LocalBranchesOptions) {}
42
+
43
+ private record(world: MountedWorld): BranchRecord | null {
44
+ try { return JSON.parse(readFileSync(join(world.root, stateDirName(), RECORD), 'utf8')) as BranchRecord; } catch { return null; }
45
+ }
46
+
47
+ /** The branches of `parent` this host holds. */
48
+ list(parent: string): BranchRow[] {
49
+ const rows: BranchRow[] = [];
50
+ for (const [name, world] of this.o.worlds) {
51
+ const r = this.record(world);
52
+ if (r?.from === parent) rows.push({ name, from: r.from, at: r.at, createdAt: r.createdAt, expiresAt: r.expiresAt });
53
+ }
54
+ return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
55
+ }
56
+
57
+ /** Branches being made, by parent: a parent is not removed mid-clone. */
58
+ private readonly making = new Map<string, number>();
59
+ hasBranches(parent: string): boolean { return (this.making.get(parent) ?? 0) > 0 || this.list(parent).length > 0; }
60
+
61
+ /** A World's branches doors, answered by this host. */
62
+ doorsFor(parent: string): BranchDoors {
63
+ return {
64
+ list: async () => this.list(parent),
65
+ create: (at, ttl, _origin, parentKey) => this.create(parent, at, ttl, parentKey),
66
+ remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent) return false; await this.remove(name); return true; },
67
+ };
68
+ }
69
+
70
+ private async create(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
71
+ this.making.set(parent, (this.making.get(parent) ?? 0) + 1);
72
+ try { return await this.make(parent, at, ttlSeconds, parentKey); } finally {
73
+ const left = (this.making.get(parent) ?? 1) - 1;
74
+ if (left > 0) this.making.set(parent, left); else this.making.delete(parent);
75
+ }
76
+ }
77
+ private async make(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
78
+ const from = this.o.worlds.get(parent);
79
+ if (!from) throw new Error(`no world ${parent} here`);
80
+ const [org, world] = parent.split('/') as [string, string];
81
+ const stamp = at.instant ? at.instant.replace(/[-:]/g, '').replace(/\.\d+Z$|Z$/, '').toLowerCase().slice(0, 13) : 'now';
82
+ // a labelled branch (a pull request's preview) is named for its label; the random tail keeps a replacement distinct
83
+ // a name already taken (a 4-hex tail's rare clash) is passed over for a fresh tail, never made over
84
+ const free = (): { id: string; name: string; root: string } | null => {
85
+ for (let tries = 0; tries < 8; tries++) {
86
+ const suffix = `${at.label ? `-${at.label}` : `-at-${stamp}`}-${Buffer.from(crypto.getRandomValues(new Uint8Array(2))).toString('hex')}`;
87
+ const id = `${world.slice(0, 64 - suffix.length)}${suffix}`;
88
+ const name = `${org}/${id}`; const root = join(this.o.dir, org, id);
89
+ if (!this.o.worlds.has(name) && !existsSync(root)) return { id, name, root };
90
+ }
91
+ return null;
92
+ };
93
+ const picked = free();
94
+ if (!picked) throw new Error(`no free name for a branch of ${parent}`);
95
+ const { id, name, root } = picked;
96
+ // the parent's config under the branch's own name: the same twins, served at the branch's place
97
+ mkdirSync(join(root, stateDirName()), { recursive: true });
98
+ writeFileSync(join(root, stateDirName(), 'world.json'), `${JSON.stringify(renamed(JSON.parse(readFileSync(join(from.root, stateDirName(), 'world.json'), 'utf8')) as Record<string, unknown>, id, name), null, 2)}\n`);
99
+ let mounted: MountedWorld | undefined;
100
+ try {
101
+ mounted = await this.o.mount(root);
102
+ await mounted.boot(this.o.origin());
103
+ this.o.worlds.set(name, mounted);
104
+ const origin = this.o.origin();
105
+ // each twin's history cut at the instant, by the parent's own history door
106
+ let views: unknown;
107
+ if (at.instant) {
108
+ const cut = await from.handle(new Request(`${origin}/-/${parent}/history?at=${encodeURIComponent(at.instant)}`, { headers: { [TOKEN_HEADER]: from.token } }));
109
+ if (!cut.ok) throw new Error(`the history of ${parent} at ${at.instant}: ${await cut.text()}`);
110
+ views = ((await cut.json()) as { views: unknown }).views;
111
+ }
112
+ const cloned = await mounted.handle(new Request(`${origin}/-/${name}/origin`, { method: 'PUT', headers: { [TOKEN_HEADER]: mounted.token, 'content-type': 'application/json' }, body: JSON.stringify({ url: `${origin}/${parent}`, token: parentKey ?? from.token, ...(views ? { views } : {}) }) })); // the branch's own key to its parent (the parent's doors make it), never the parent's token
113
+ if (!cloned.ok) throw new Error(`cloning ${parent}: ${await cloned.text()}`);
114
+ // the branch's clock stands at its instant, else at the parent's: no twin's catch-up walks it on
115
+ // (a parent whose clock was never set: the branch's stands at now, frozen there, as http-api says);
116
+ // a live branch (a pull request's preview) keeps the parent's time: real time, or its simulated clock
117
+ const clock = at.instant ?? this.frozenClock(from) ?? (at.live ? null : new Date().toISOString());
118
+ if (clock) { const file = clockFile(root, id); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${clock}\n`); }
119
+ const createdAt = new Date().toISOString();
120
+ const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
121
+ writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: at.instant ? { instant: at.instant } : null, createdAt, expiresAt } satisfies BranchRecord, null, 2)}\n`);
122
+ this.o.announce?.(`branch ${name} of ${parent}${at.instant ? ` as of ${at.instant}` : ''}${expiresAt ? `, until ${expiresAt}` : ''}`);
123
+ return { name, token: mounted.token, readToken: mounted.readToken, expiresAt };
124
+ } catch (error) {
125
+ this.o.worlds.delete(name);
126
+ try { await mounted?.stop(); } catch { /* reported below */ }
127
+ withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ /** The parent's frozen clock, when it has one. */
133
+ private frozenClock(world: MountedWorld): string | null {
134
+ try {
135
+ const held = readFileSync(clockFile(world.root, loadWorldConfig(join(world.root, stateDirName(), 'world.json'), world.root).config.id), 'utf8').trim();
136
+ return Number.isNaN(Date.parse(held)) ? null : held;
137
+ } catch { return null; }
138
+ }
139
+
140
+ /** Stop a branch and remove its World (a branch's own branches first). */
141
+ async remove(name: string): Promise<void> {
142
+ const world = this.o.worlds.get(name);
143
+ if (!world) return;
144
+ for (const child of this.list(name)) await this.remove(child.name);
145
+ await world.stop();
146
+ withStateRemoval(world.root, () => rmSync(world.root, { recursive: true, force: true }));
147
+ this.o.worlds.delete(name);
148
+ this.o.announce?.(`removed ${name}`);
149
+ }
150
+
151
+ /** Remove the branches whose time ran out, now and every minute while the host serves. */
152
+ async resume(): Promise<void> {
153
+ await this.sweep();
154
+ this.sweeper = setInterval(() => { void this.sweep(); }, SWEEP_MS);
155
+ this.sweeper.unref?.();
156
+ }
157
+ /** One sweep at a time: a slow one never overlaps the next. */
158
+ private sweeping = false;
159
+ private async sweep(): Promise<void> {
160
+ if (this.sweeping) return;
161
+ this.sweeping = true;
162
+ try {
163
+ const now = Date.now();
164
+ for (const [name, world] of [...this.o.worlds]) {
165
+ const r = this.record(world);
166
+ if (r?.expiresAt && Date.parse(r.expiresAt) <= now && this.o.worlds.has(name)) {
167
+ try { await this.remove(name); } catch (error) { this.o.announce?.(`branch ${name}: removal failed: ${error instanceof Error ? error.message : String(error)}`); }
168
+ }
169
+ }
170
+ } finally { this.sweeping = false; }
171
+ }
172
+ stop(): void { if (this.sweeper) clearInterval(this.sweeper); this.sweeper = null; }
173
+ }