@volter/world-runtime 2.0.29 → 2.0.31

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.
@@ -39,7 +39,7 @@ export { fetchFromOrigin, worldOrigin } from './origin.js';
39
39
  export type { FetchOptions, FetchOutcome } from './origin.js';
40
40
  export { branchWorld, checkoutWorld } from './branch.js';
41
41
  export type { BranchOptions } from './branch.js';
42
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.js';
42
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.js';
43
43
  export type { DeployTwinOutcome, MaterializedRoot } from './root.js';
44
44
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.js';
45
45
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.js';
package/dist/src/index.js CHANGED
@@ -29,7 +29,7 @@ export { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttac
29
29
  export { resetWorld, seedEntryFor, seedWorld } from "./placeholder.js";
30
30
  export { fetchFromOrigin, worldOrigin } from "./origin.js";
31
31
  export { branchWorld, checkoutWorld } from "./branch.js";
32
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
32
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
33
33
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from "./served-world.js";
34
34
  export { LocalBranches } from "./local-branches.js";
35
35
  export { serveWorldView, VIEW_CONSOLE_BASE } from "./world-view.js";
@@ -5,14 +5,31 @@ import type { WorldConfig } from './schema.js';
5
5
  * seals is wrapped under. Made on first use, 0600, never printed. */
6
6
  export { userKek, userKekPath } from '@volter/world-core';
7
7
  export declare function credentialPath(worldRoot: string, vendor: string): string;
8
- export declare function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at?: string): Promise<{
8
+ /** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
9
+ * for that destination only, so pointing the root or link elsewhere never sends it there. */
10
+ export declare function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at: string | undefined, bound: string): Promise<{
9
11
  placedAt: string;
10
12
  fingerprint: string;
11
13
  }>;
14
+ /** Bind each credential sealed before bindings to its destination, keeping its placedAt, fingerprint and rotatedAt,
15
+ * and only over the record that was read; and move every record still under the host's own key (v1) to the vault,
16
+ * keeping its binding. Run by the World's host as it wakes, before it serves or refreshes
17
+ * (WorldDoors.bindLegacyCredentials). At its first run on this code it takes the inventory: every credential file
18
+ * beside the World, each one sealed before bindings with the destination `current()` names for it (the root's or
19
+ * link's URL, as no request has changed it since) or none. Every later run binds from that sealed inventory, never
20
+ * from a root or link changed since. `ready` is false when the inventory could not be kept (the vault down): the host
21
+ * admits nothing until it is. */
22
+ export declare function bindLegacyCredentials(worldRoot: string, current: () => Record<string, string>): Promise<{
23
+ bound: string[];
24
+ failed: string[];
25
+ refused: string[];
26
+ ready: boolean;
27
+ }>;
12
28
  export declare function sealedCredentialInfo(worldRoot: string, vendor: string): {
13
29
  placedAt: string;
14
30
  fingerprint: string;
15
31
  sealedBy: 'vault' | 'key';
32
+ boundTo?: string;
16
33
  } | null;
17
34
  /** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
18
35
  export declare function twinSigningSecret(worldRoot: string, vendor: string): Promise<string | null>;
package/dist/src/root.js CHANGED
@@ -11,7 +11,7 @@ import { packEntry } from "./catalog.js";
11
11
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join, relative, resolve } from 'node:path';
14
- import { authStrategyFor, currentSealer, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, readRoot, registerStateSystem, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
14
+ import { authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, readRoot, registerStateSystem, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
15
15
  import { loadWorldConfig, writeWorldConfig } from "./configs.js";
16
16
  import { changesetReadiness, collectObservations, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding } from '@volter/world-core';
17
17
  import { findWorldChangeset, listWorldChangesets } from "./changeset.js";
@@ -30,18 +30,149 @@ export { userKek, userKekPath } from '@volter/world-core';
30
30
  export function credentialPath(worldRoot, vendor) {
31
31
  return join(resolve(worldRoot), stateDirName(), 'credentials', `${vendor}.json`);
32
32
  }
33
- export async function sealTwinCredential(worldRoot, vendor, payload, at = new Date().toISOString()) {
34
- const sealed = await currentSealer().seal(payload, at, vendor);
33
+ /** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
34
+ * for that destination only, so pointing the root or link elsewhere never sends it there. */
35
+ export async function sealTwinCredential(worldRoot, vendor, payload, at, bound) {
36
+ if (!/^origin=https?:\/\/[^/]+$/.test(bound))
37
+ throw new Error(`a credential is sealed for a destination (destinationOf its root's or link's origin), not ${JSON.stringify(bound)}`);
38
+ const sealed = await currentSealer().seal(payload, at ?? new Date().toISOString(), vendor, bound);
35
39
  getActiveWorldStore().write(credentialPath(worldRoot, vendor), `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
36
40
  return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint };
37
41
  }
42
+ /** The inventory of credentials sealed before bindings, kept sealed beside them (`.legacy-inventory.json`, its own
43
+ * slot): `pending` maps each such credential to the destination it is bound to, or null when it had none and so is
44
+ * refused until set again. Sealed, so no one who can write the World's files can plant one; kept once made, as the
45
+ * record that the first inventory happened. */
46
+ const INVENTORY_SLOT = 'credential-inventory';
47
+ /** Bind each credential sealed before bindings to its destination, keeping its placedAt, fingerprint and rotatedAt,
48
+ * and only over the record that was read; and move every record still under the host's own key (v1) to the vault,
49
+ * keeping its binding. Run by the World's host as it wakes, before it serves or refreshes
50
+ * (WorldDoors.bindLegacyCredentials). At its first run on this code it takes the inventory: every credential file
51
+ * beside the World, each one sealed before bindings with the destination `current()` names for it (the root's or
52
+ * link's URL, as no request has changed it since) or none. Every later run binds from that sealed inventory, never
53
+ * from a root or link changed since. `ready` is false when the inventory could not be kept (the vault down): the host
54
+ * admits nothing until it is. */
55
+ export async function bindLegacyCredentials(worldRoot, current) {
56
+ const store = getActiveWorldStore();
57
+ const dir = dirname(credentialPath(worldRoot, 'x'));
58
+ const inventoryPath = join(dir, '.legacy-inventory.json');
59
+ const names = store.list(dir).filter((f) => /^[a-z0-9][a-z0-9_-]*\.json$/.test(f)).map((f) => f.replace(/\.json$/, ''));
60
+ if (names.length === 0)
61
+ return { bound: [], failed: [], refused: [], ready: true };
62
+ const sealedAt = (name) => { const text = store.read(credentialPath(worldRoot, name)); return text === null ? null : JSON.parse(text); };
63
+ // a snapshot from an earlier design names destinations no current code trusts: nothing is admitted until an operator
64
+ // has bound those credentials (or set them again) and removed it
65
+ if (store.read(join(dir, '.legacy-destinations.json')) !== null) {
66
+ console.error('[credential] an earlier destination snapshot is beside the World: an operator resolves it before it serves');
67
+ return { bound: [], failed: [], refused: [], ready: false };
68
+ }
69
+ let inventory = null;
70
+ const heldInventory = store.read(inventoryPath);
71
+ if (heldInventory !== null) {
72
+ // once taken, the inventory is the only authority: one that does not open (the vault down, or not this host's)
73
+ // admits nothing until it opens again, and is never taken again from roots a request may have changed
74
+ try {
75
+ inventory = (await currentSealer().open(JSON.parse(heldInventory), INVENTORY_SLOT));
76
+ }
77
+ catch (error) {
78
+ console.error(`[credential] the inventory beside the World does not open (${error.message}): nothing is admitted until it does`);
79
+ return { bound: [], failed: [], refused: [], ready: false };
80
+ }
81
+ }
82
+ const keep = async (next) => {
83
+ try {
84
+ const sealed = await currentSealer().seal({ headers: {}, ...next }, new Date().toISOString(), INVENTORY_SLOT);
85
+ store.write(inventoryPath, `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
86
+ return true;
87
+ }
88
+ catch (error) {
89
+ console.error(`[credential] the inventory could not be kept (${error.message})`);
90
+ return false;
91
+ }
92
+ };
93
+ if (inventory === null) {
94
+ const destinations = current();
95
+ const pending = {};
96
+ for (const name of names)
97
+ if (sealedAt(name)?.bound === undefined)
98
+ pending[name] = destinations[name] ?? null;
99
+ inventory = { pending };
100
+ if (!(await keep(inventory)))
101
+ return { bound: [], failed: Object.keys(pending), refused: [], ready: false };
102
+ }
103
+ const bound = [];
104
+ const failed = [];
105
+ const refused = [];
106
+ let changed = false;
107
+ for (const [name, url] of Object.entries(inventory.pending)) {
108
+ const path = credentialPath(worldRoot, name);
109
+ const text = store.read(path);
110
+ const sealed = text === null ? null : JSON.parse(text);
111
+ // set again since (bound by its door), or removed: nothing left to bind
112
+ if (sealed === null || sealed.bound !== undefined) {
113
+ delete inventory.pending[name];
114
+ changed = true;
115
+ continue;
116
+ }
117
+ if (url === null) {
118
+ refused.push(name);
119
+ continue;
120
+ }
121
+ try {
122
+ const payload = await currentSealer().open(sealed, name);
123
+ const resealed = await currentSealer().seal(payload, sealed.placedAt, name, destinationOf(url));
124
+ if (store.read(path) !== text) {
125
+ failed.push(name);
126
+ continue;
127
+ }
128
+ store.write(path, `${JSON.stringify({ ...resealed, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
129
+ delete inventory.pending[name];
130
+ changed = true;
131
+ bound.push(name);
132
+ }
133
+ catch (error) {
134
+ console.warn(`[credential] ${name}: not bound to its destination (${error.message})`);
135
+ failed.push(name);
136
+ }
137
+ }
138
+ if (changed)
139
+ await keep(inventory);
140
+ // a record already bound but still under the host's own key (v1) moves to the vault, keeping its binding: after a
141
+ // wake every bound record is the vault's, and the deployment's key opens nothing more
142
+ if (sealsThroughVault()) {
143
+ for (const name of names) {
144
+ const path = credentialPath(worldRoot, name);
145
+ const text = store.read(path);
146
+ if (text === null)
147
+ continue;
148
+ const sealed = JSON.parse(text);
149
+ if (sealed.v !== 1 || sealed.bound === undefined)
150
+ continue;
151
+ try {
152
+ const payload = await currentSealer().open(sealed, name);
153
+ const moved = await currentSealer().seal(payload, sealed.placedAt, name, sealed.bound);
154
+ if (store.read(path) !== text) {
155
+ failed.push(name);
156
+ continue;
157
+ }
158
+ store.write(path, `${JSON.stringify({ ...moved, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
159
+ bound.push(name);
160
+ }
161
+ catch (error) {
162
+ console.warn(`[credential] ${name}: not moved to the vault (${error.message})`);
163
+ failed.push(name);
164
+ }
165
+ }
166
+ }
167
+ return { bound, failed, refused, ready: true };
168
+ }
38
169
  export function sealedCredentialInfo(worldRoot, vendor) {
39
170
  const text = getActiveWorldStore().read(credentialPath(worldRoot, vendor));
40
171
  if (text === null)
41
172
  return null;
42
173
  const sealed = JSON.parse(text);
43
174
  // what wraps it: a vault's transit key (v: 2) or a key the host holds (v: 1), so an operator sees what still needs one
44
- return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key' };
175
+ return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key', ...(sealed.bound ? { boundTo: sealed.bound.replace(/^origin=/, '') } : {}) };
45
176
  }
46
177
  /** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
47
178
  export async function twinSigningSecret(worldRoot, vendor) {
@@ -240,6 +240,21 @@ export declare class WorldDoors {
240
240
  /** The world's twins, by vendor, once booted. */
241
241
  twins(): string[];
242
242
  private up;
243
+ /** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
244
+ * World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
245
+ * keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
246
+ * where it went. */
247
+ bindLegacyCredentials(layout?: {
248
+ services: Record<string, unknown>;
249
+ dirs: {
250
+ data: string;
251
+ };
252
+ }): Promise<{
253
+ bound: string[];
254
+ failed: string[];
255
+ refused: string[];
256
+ ready: boolean;
257
+ }>;
243
258
  /** The world's token, presented the way the app's SDK presents a credential: the world's own header,
244
259
  * the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
245
260
  * GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
29
- import { blobDigest, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
29
+ import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
30
30
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
@@ -36,7 +36,7 @@ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChange
36
36
  import { fetchFromOrigin } from "./origin.js";
37
37
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
38
38
  import { loadWorldConfig } from "./configs.js";
39
- import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from "./root.js";
39
+ import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
40
40
  import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from "./runtime.js";
41
41
  export const TOKEN_HEADER = 'x-volter-token';
42
42
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
@@ -172,9 +172,18 @@ export async function mountWorld(name, opts = {}) {
172
172
  let booted = null;
173
173
  try {
174
174
  booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
175
- instance = booted;
176
175
  // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
177
176
  materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
177
+ // credentials sealed before bindings are bound to where they go before any request is let in (instance unset,
178
+ // the doors answer "booting") or any refresh is scheduled
179
+ const legacy = await world.bindLegacyCredentials(booted);
180
+ if (!legacy.ready)
181
+ throw new Error('the credentials beside this World could not be inventoried (is its vault reachable?)');
182
+ if (legacy.failed.length)
183
+ process.stderr.write(`credentials not bound to a destination yet (refused until bound): ${legacy.failed.join(', ')}\n`);
184
+ if (legacy.refused.length)
185
+ process.stderr.write(`credentials sealed before bindings with no root or link (refused until set again): ${legacy.refused.join(', ')}\n`);
186
+ instance = booted;
178
187
  timers = await scheduleRefreshes(name, worldRoot, booted);
179
188
  }
180
189
  catch (error) {
@@ -589,6 +598,23 @@ export class WorldDoors {
589
598
  /** The world's twins, by vendor, once booted. */
590
599
  twins() { return Object.keys(this.up().services); }
591
600
  up() { return this.host.layout(); }
601
+ /** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
602
+ * World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
603
+ * keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
604
+ * where it went. */
605
+ async bindLegacyCredentials(layout = this.up()) {
606
+ return bindLegacyCredentials(this.worldRoot, () => {
607
+ const destinations = {};
608
+ for (const [name, link] of Object.entries(this.links()))
609
+ destinations[name] = link.origin;
610
+ for (const vendor of Object.keys(layout.services)) {
611
+ const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
612
+ if (url)
613
+ destinations[vendor] = url;
614
+ }
615
+ return destinations;
616
+ });
617
+ }
592
618
  /** The world's token, presented the way the app's SDK presents a credential: the world's own header,
593
619
  * the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
594
620
  * GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
@@ -912,10 +938,17 @@ export class WorldDoors {
912
938
  * token removed and the sealed credential's headers replacing any of the same name. The vendor's
913
939
  * answer (status, headers, body) is streamed back as it arrives. */
914
940
  async forward(request, url, rest, name, origin) {
915
- const credential = await openSealedAt(credentialPath(this.worldRoot, name), name);
941
+ const base = validateRemoteOrigin(origin);
942
+ // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
943
+ let credential;
944
+ try {
945
+ credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin));
946
+ }
947
+ catch (error) {
948
+ return Response.json({ error: error.message }, { status: 409 });
949
+ }
916
950
  if (credential === null)
917
951
  return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
918
- const base = validateRemoteOrigin(origin);
919
952
  const target = new URL(`${base.origin}/${rest}${url.search}`);
920
953
  if (target.origin !== base.origin)
921
954
  return Response.json({ error: 'the path left the link origin' }, { status: 400 });
@@ -1314,7 +1347,12 @@ export class WorldDoors {
1314
1347
  const text = await request.text();
1315
1348
  if (text.length > MAX_BODY)
1316
1349
  return Response.json({ error: 'too large' }, { status: 413 });
1317
- const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name));
1350
+ // sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
1351
+ // nowhere it may go, so nothing is sealed (set the root or the link first)
1352
+ const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
1353
+ if (!destination)
1354
+ return Response.json({ error: `the ${name} twin has no root and there is no ${name} link: set where its credential goes first` }, { status: 409 });
1355
+ const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
1318
1356
  return Response.json({ twin: name, sealed: true, ...info });
1319
1357
  }
1320
1358
  if (verb === 'credential' && request.method === 'GET')
@@ -1687,6 +1725,9 @@ export class WorldDoors {
1687
1725
  * door and the path transport both come here.
1688
1726
  */
1689
1727
  export async function landChangeset(name, worldRoot, instance, changeset, expected = {}) {
1728
+ // the name addresses the file the changeset is kept in: a name with a separator or a traversal would write anywhere in
1729
+ // the World's store (its token, its credentials), so it is refused before anything lands
1730
+ assertSafeChangesetName(typeof changeset.name === 'string' ? changeset.name : '');
1690
1731
  const receipts = [];
1691
1732
  const checks = await loadWorldChecks(worldRoot);
1692
1733
  const stateOf = (twin) => stateServiceOf(join(instance.dirs.data, twin), twin);
@@ -1744,7 +1785,10 @@ export async function landChangeset(name, worldRoot, instance, changeset, expect
1744
1785
  receipts.push({ actionId: id, twin, status: 'landed', at: worldNow() });
1745
1786
  }
1746
1787
  }
1747
- const path = join(worldChangesetsDir(name, worldRoot), `${changeset.name}.json`);
1788
+ const dir = worldChangesetsDir(name, worldRoot);
1789
+ const path = join(dir, `${changeset.name}.json`);
1790
+ if (dirname(path) !== dir)
1791
+ throw new Error(`Invalid changeset name: ${JSON.stringify(changeset.name)}`);
1748
1792
  const store = getActiveWorldStore();
1749
1793
  if (!store.exists(path))
1750
1794
  store.write(path, `${JSON.stringify(changeset, null, 2)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
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",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.17",
70
+ "@volter/world-core": "2.0.18",
71
71
  "@volter/world-console": "2.0.10"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.2",
77
- "@volter/world-core": "2.0.17",
77
+ "@volter/world-core": "2.0.18",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.17"
87
+ "@volter/twin-mongodb": "0.1.18"
88
88
  }
89
89
  }
package/src/index.ts CHANGED
@@ -161,7 +161,7 @@ export type { FetchOptions, FetchOutcome } from './origin.ts';
161
161
 
162
162
  export { branchWorld, checkoutWorld } from './branch.ts';
163
163
  export type { BranchOptions } from './branch.ts';
164
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
164
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
165
165
  export type { DeployTwinOutcome, MaterializedRoot } from './root.ts';
166
166
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.ts';
167
167
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
package/src/root.ts CHANGED
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { basename, dirname, join, relative, resolve } from 'node:path';
14
14
  import {
15
- NO_SECRETS_CHECK, authStrategyFor, currentSealer, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, observeAppends, readRoot, registerStateSystem, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
15
+ NO_SECRETS_CHECK, authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, observeAppends, readRoot, registerStateSystem, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
16
16
  } from '@volter/world-core';
17
17
  import { loadWorldConfig, writeWorldConfig } from './configs.ts';
18
18
  import { changesetReadiness, collectObservations, type Observation, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding, userKek, type DeployReport, type ObserveReport } from '@volter/world-core';
@@ -37,17 +37,121 @@ export { userKek, userKekPath } from '@volter/world-core';
37
37
  export function credentialPath(worldRoot: string, vendor: string): string {
38
38
  return join(resolve(worldRoot), stateDirName(), 'credentials', `${vendor}.json`);
39
39
  }
40
- export async function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at: string = new Date().toISOString()): Promise<{ placedAt: string; fingerprint: string }> {
41
- const sealed = await currentSealer().seal(payload, at, vendor);
40
+ /** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
41
+ * for that destination only, so pointing the root or link elsewhere never sends it there. */
42
+ export async function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at: string | undefined, bound: string): Promise<{ placedAt: string; fingerprint: string }> {
43
+ if (!/^origin=https?:\/\/[^/]+$/.test(bound)) throw new Error(`a credential is sealed for a destination (destinationOf its root's or link's origin), not ${JSON.stringify(bound)}`);
44
+ const sealed = await currentSealer().seal(payload, at ?? new Date().toISOString(), vendor, bound);
42
45
  getActiveWorldStore().write(credentialPath(worldRoot, vendor), `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
43
46
  return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint };
44
47
  }
45
- export function sealedCredentialInfo(worldRoot: string, vendor: string): { placedAt: string; fingerprint: string; sealedBy: 'vault' | 'key' } | null {
48
+ /** The inventory of credentials sealed before bindings, kept sealed beside them (`.legacy-inventory.json`, its own
49
+ * slot): `pending` maps each such credential to the destination it is bound to, or null when it had none and so is
50
+ * refused until set again. Sealed, so no one who can write the World's files can plant one; kept once made, as the
51
+ * record that the first inventory happened. */
52
+ const INVENTORY_SLOT = 'credential-inventory';
53
+ type Inventory = { pending: Record<string, string | null> };
54
+
55
+ /** Bind each credential sealed before bindings to its destination, keeping its placedAt, fingerprint and rotatedAt,
56
+ * and only over the record that was read; and move every record still under the host's own key (v1) to the vault,
57
+ * keeping its binding. Run by the World's host as it wakes, before it serves or refreshes
58
+ * (WorldDoors.bindLegacyCredentials). At its first run on this code it takes the inventory: every credential file
59
+ * beside the World, each one sealed before bindings with the destination `current()` names for it (the root's or
60
+ * link's URL, as no request has changed it since) or none. Every later run binds from that sealed inventory, never
61
+ * from a root or link changed since. `ready` is false when the inventory could not be kept (the vault down): the host
62
+ * admits nothing until it is. */
63
+ export async function bindLegacyCredentials(worldRoot: string, current: () => Record<string, string>): Promise<{ bound: string[]; failed: string[]; refused: string[]; ready: boolean }> {
64
+ const store = getActiveWorldStore();
65
+ const dir = dirname(credentialPath(worldRoot, 'x'));
66
+ const inventoryPath = join(dir, '.legacy-inventory.json');
67
+ const names = store.list(dir).filter((f) => /^[a-z0-9][a-z0-9_-]*\.json$/.test(f)).map((f) => f.replace(/\.json$/, ''));
68
+ if (names.length === 0) return { bound: [], failed: [], refused: [], ready: true };
69
+ const sealedAt = (name: string): SealedCredential | null => { const text = store.read(credentialPath(worldRoot, name)); return text === null ? null : (JSON.parse(text) as SealedCredential); };
70
+ // a snapshot from an earlier design names destinations no current code trusts: nothing is admitted until an operator
71
+ // has bound those credentials (or set them again) and removed it
72
+ if (store.read(join(dir, '.legacy-destinations.json')) !== null) {
73
+ console.error('[credential] an earlier destination snapshot is beside the World: an operator resolves it before it serves');
74
+ return { bound: [], failed: [], refused: [], ready: false };
75
+ }
76
+ let inventory: Inventory | null = null;
77
+ const heldInventory = store.read(inventoryPath);
78
+ if (heldInventory !== null) {
79
+ // once taken, the inventory is the only authority: one that does not open (the vault down, or not this host's)
80
+ // admits nothing until it opens again, and is never taken again from roots a request may have changed
81
+ try { inventory = (await currentSealer().open(JSON.parse(heldInventory) as SealedCredential, INVENTORY_SLOT)) as unknown as Inventory; }
82
+ catch (error) {
83
+ console.error(`[credential] the inventory beside the World does not open (${(error as Error).message}): nothing is admitted until it does`);
84
+ return { bound: [], failed: [], refused: [], ready: false };
85
+ }
86
+ }
87
+ const keep = async (next: Inventory): Promise<boolean> => {
88
+ try {
89
+ const sealed = await currentSealer().seal({ headers: {}, ...next } as unknown as CredentialPayload, new Date().toISOString(), INVENTORY_SLOT);
90
+ store.write(inventoryPath, `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
91
+ return true;
92
+ } catch (error) {
93
+ console.error(`[credential] the inventory could not be kept (${(error as Error).message})`);
94
+ return false;
95
+ }
96
+ };
97
+ if (inventory === null) {
98
+ const destinations = current();
99
+ const pending: Record<string, string | null> = {};
100
+ for (const name of names) if (sealedAt(name)?.bound === undefined) pending[name] = destinations[name] ?? null;
101
+ inventory = { pending };
102
+ if (!(await keep(inventory))) return { bound: [], failed: Object.keys(pending), refused: [], ready: false };
103
+ }
104
+ const bound: string[] = []; const failed: string[] = []; const refused: string[] = [];
105
+ let changed = false;
106
+ for (const [name, url] of Object.entries(inventory.pending)) {
107
+ const path = credentialPath(worldRoot, name);
108
+ const text = store.read(path);
109
+ const sealed = text === null ? null : (JSON.parse(text) as SealedCredential);
110
+ // set again since (bound by its door), or removed: nothing left to bind
111
+ if (sealed === null || sealed.bound !== undefined) { delete inventory.pending[name]; changed = true; continue; }
112
+ if (url === null) { refused.push(name); continue; }
113
+ try {
114
+ const payload = await currentSealer().open(sealed, name);
115
+ const resealed = await currentSealer().seal(payload, sealed.placedAt, name, destinationOf(url));
116
+ if (store.read(path) !== text) { failed.push(name); continue; }
117
+ store.write(path, `${JSON.stringify({ ...resealed, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
118
+ delete inventory.pending[name]; changed = true;
119
+ bound.push(name);
120
+ } catch (error) {
121
+ console.warn(`[credential] ${name}: not bound to its destination (${(error as Error).message})`);
122
+ failed.push(name);
123
+ }
124
+ }
125
+ if (changed) await keep(inventory);
126
+ // a record already bound but still under the host's own key (v1) moves to the vault, keeping its binding: after a
127
+ // wake every bound record is the vault's, and the deployment's key opens nothing more
128
+ if (sealsThroughVault()) {
129
+ for (const name of names) {
130
+ const path = credentialPath(worldRoot, name);
131
+ const text = store.read(path);
132
+ if (text === null) continue;
133
+ const sealed = JSON.parse(text) as SealedCredential;
134
+ if (sealed.v !== 1 || sealed.bound === undefined) continue;
135
+ try {
136
+ const payload = await currentSealer().open(sealed, name);
137
+ const moved = await currentSealer().seal(payload, sealed.placedAt, name, sealed.bound);
138
+ if (store.read(path) !== text) { failed.push(name); continue; }
139
+ store.write(path, `${JSON.stringify({ ...moved, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
140
+ bound.push(name);
141
+ } catch (error) {
142
+ console.warn(`[credential] ${name}: not moved to the vault (${(error as Error).message})`);
143
+ failed.push(name);
144
+ }
145
+ }
146
+ }
147
+ return { bound, failed, refused, ready: true };
148
+ }
149
+ export function sealedCredentialInfo(worldRoot: string, vendor: string): { placedAt: string; fingerprint: string; sealedBy: 'vault' | 'key'; boundTo?: string } | null {
46
150
  const text = getActiveWorldStore().read(credentialPath(worldRoot, vendor));
47
151
  if (text === null) return null;
48
152
  const sealed = JSON.parse(text) as SealedCredential;
49
153
  // what wraps it: a vault's transit key (v: 2) or a key the host holds (v: 1), so an operator sees what still needs one
50
- return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key' };
154
+ return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key', ...(sealed.bound ? { boundTo: sealed.bound.replace(/^origin=/, '') } : {}) };
51
155
  }
52
156
  /** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
53
157
  export async function twinSigningSecret(worldRoot: string, vendor: string): Promise<string | null> {
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
29
- import { blobDigest, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
29
+ import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
30
30
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
@@ -39,7 +39,7 @@ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChange
39
39
  import { fetchFromOrigin } from './origin.ts';
40
40
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
41
41
  import { loadWorldConfig } from './configs.ts';
42
- import { adaptersFor, credentialPath, packMirror, type PackMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from './root.ts';
42
+ import { adaptersFor, credentialPath, packMirror, type PackMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from './root.ts';
43
43
  import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from './runtime.ts';
44
44
  import type { WorldInstance } from './schema.ts';
45
45
 
@@ -177,9 +177,15 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
177
177
  let booted: WorldInstance | null = null;
178
178
  try {
179
179
  booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
180
- instance = booted;
181
180
  // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
182
181
  materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
182
+ // credentials sealed before bindings are bound to where they go before any request is let in (instance unset,
183
+ // the doors answer "booting") or any refresh is scheduled
184
+ const legacy = await world.bindLegacyCredentials(booted);
185
+ if (!legacy.ready) throw new Error('the credentials beside this World could not be inventoried (is its vault reachable?)');
186
+ if (legacy.failed.length) process.stderr.write(`credentials not bound to a destination yet (refused until bound): ${legacy.failed.join(', ')}\n`);
187
+ if (legacy.refused.length) process.stderr.write(`credentials sealed before bindings with no root or link (refused until set again): ${legacy.refused.join(', ')}\n`);
188
+ instance = booted;
183
189
  timers = await scheduleRefreshes(name, worldRoot, booted);
184
190
  } catch (error) {
185
191
  for (const t of timers) clearInterval(t);
@@ -534,6 +540,22 @@ export class WorldDoors {
534
540
  twins(): string[] { return Object.keys(this.up().services); }
535
541
  private up(): WorldLayout { return this.host.layout(); }
536
542
 
543
+ /** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
544
+ * World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
545
+ * keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
546
+ * where it went. */
547
+ async bindLegacyCredentials(layout: { services: Record<string, unknown>; dirs: { data: string } } = this.up()): Promise<{ bound: string[]; failed: string[]; refused: string[]; ready: boolean }> {
548
+ return bindLegacyCredentials(this.worldRoot, () => {
549
+ const destinations: Record<string, string> = {};
550
+ for (const [name, link] of Object.entries(this.links())) destinations[name] = link.origin;
551
+ for (const vendor of Object.keys(layout.services)) {
552
+ const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
553
+ if (url) destinations[vendor] = url;
554
+ }
555
+ return destinations;
556
+ });
557
+ }
558
+
537
559
  /** The world's token, presented the way the app's SDK presents a credential: the world's own header,
538
560
  * the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
539
561
  * GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
@@ -817,9 +839,11 @@ export class WorldDoors {
817
839
  * token removed and the sealed credential's headers replacing any of the same name. The vendor's
818
840
  * answer (status, headers, body) is streamed back as it arrives. */
819
841
  private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
820
- const credential = await openSealedAt(credentialPath(this.worldRoot, name), name);
821
- if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
822
842
  const base = validateRemoteOrigin(origin);
843
+ // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
844
+ let credential: Awaited<ReturnType<typeof openSealedAt>>;
845
+ try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 409 }); }
846
+ if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
823
847
  const target = new URL(`${base.origin}/${rest}${url.search}`);
824
848
  if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
825
849
  const headers = new Headers(request.headers);
@@ -1129,7 +1153,11 @@ export class WorldDoors {
1129
1153
  if (verb === 'credential' && request.method === 'PUT') {
1130
1154
  const text = await request.text();
1131
1155
  if (text.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
1132
- const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name));
1156
+ // sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
1157
+ // nowhere it may go, so nothing is sealed (set the root or the link first)
1158
+ const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
1159
+ if (!destination) return Response.json({ error: `the ${name} twin has no root and there is no ${name} link: set where its credential goes first` }, { status: 409 });
1160
+ const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
1133
1161
  return Response.json({ twin: name, sealed: true, ...info });
1134
1162
  }
1135
1163
  if (verb === 'credential' && request.method === 'GET') return Response.json({ twin: name, credential: sealedCredentialInfo(this.worldRoot, name) });
@@ -1436,6 +1464,9 @@ export class WorldDoors {
1436
1464
  * door and the path transport both come here.
1437
1465
  */
1438
1466
  export async function landChangeset(name: string, worldRoot: string, instance: WorldLayout, changeset: Changeset, expected: Record<string, HistoryReference> = {}): Promise<Array<{ actionId: string; twin: string } & Receipt>> {
1467
+ // the name addresses the file the changeset is kept in: a name with a separator or a traversal would write anywhere in
1468
+ // the World's store (its token, its credentials), so it is refused before anything lands
1469
+ assertSafeChangesetName(typeof changeset.name === 'string' ? changeset.name : '');
1439
1470
  const receipts: Array<{ actionId: string; twin: string } & Receipt> = [];
1440
1471
  const checks = await loadWorldChecks(worldRoot);
1441
1472
  const stateOf = (twin: string): string => stateServiceOf(join(instance.dirs.data, twin), twin);
@@ -1481,7 +1512,9 @@ export async function landChangeset(name: string, worldRoot: string, instance: W
1481
1512
  for (const id of landedIds) receipts.push({ actionId: id, twin, status: 'landed', at: worldNow() });
1482
1513
  }
1483
1514
  }
1484
- const path = join(worldChangesetsDir(name, worldRoot), `${changeset.name}.json`);
1515
+ const dir = worldChangesetsDir(name, worldRoot);
1516
+ const path = join(dir, `${changeset.name}.json`);
1517
+ if (dirname(path) !== dir) throw new Error(`Invalid changeset name: ${JSON.stringify(changeset.name)}`);
1485
1518
  const store = getActiveWorldStore();
1486
1519
  if (!store.exists(path)) store.write(path, `${JSON.stringify(changeset, null, 2)}\n`);
1487
1520
  return receipts;