@volter/world-runtime 2.0.30 → 2.0.32

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.
package/dist/src/cli.js CHANGED
@@ -111,10 +111,10 @@ function printHelp() {
111
111
  # merged, occurredAt-ordered feed of the twins' action ledgers — one
112
112
  # \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
113
113
  # follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
114
- # --requests: also merge the opt-in request journals — reads/404s,
114
+ # --requests: also merge the request journals — reads/404s,
115
115
  # plus WHICH credential arrived in WHICH header/query param, named +
116
- # fingerprinted, never the value — written by twins under
117
- # VOLTER_TWIN_REQUEST_JOURNAL=1)
116
+ # fingerprinted, never the value — kept by every twin and link
117
+ # unless VOLTER_TWIN_REQUEST_JOURNAL=0)
118
118
  volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
119
119
  # pull from the placeholder remote: the seed entry runs with every twin's
120
120
  # window open, so the defaults land as observed state (diff empty, nothing pending)
@@ -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). */
@@ -299,10 +314,11 @@ export declare class WorldDoors {
299
314
  origin: string;
300
315
  }>;
301
316
  private link;
302
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
303
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
317
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
318
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
304
319
  * answer (status, headers, body) is streamed back as it arrives. */
305
320
  private forward;
321
+ private forwardSealed;
306
322
  /** The instant of the twin's last completed refresh (its marker's `observedAt`), when it has a root. */
307
323
  private observedAt;
308
324
  /** The world's own doors under `/-/<org>/<world>/`. */
@@ -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 { assertSafeChangesetName, blobDigest, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
29
+ import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, 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) {
@@ -269,7 +278,8 @@ export async function serveWorld(name, opts = {}) {
269
278
  // doors answer 503.
270
279
  let consoleOrigin = null;
271
280
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
272
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
281
+ // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
282
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
273
283
  const url = `http://${host}:${server.port}`;
274
284
  try {
275
285
  await mounted.boot(url);
@@ -589,6 +599,23 @@ export class WorldDoors {
589
599
  /** The world's twins, by vendor, once booted. */
590
600
  twins() { return Object.keys(this.up().services); }
591
601
  up() { return this.host.layout(); }
602
+ /** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
603
+ * World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
604
+ * keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
605
+ * where it went. */
606
+ async bindLegacyCredentials(layout = this.up()) {
607
+ return bindLegacyCredentials(this.worldRoot, () => {
608
+ const destinations = {};
609
+ for (const [name, link] of Object.entries(this.links()))
610
+ destinations[name] = link.origin;
611
+ for (const vendor of Object.keys(layout.services)) {
612
+ const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
613
+ if (url)
614
+ destinations[vendor] = url;
615
+ }
616
+ return destinations;
617
+ });
618
+ }
592
619
  /** The world's token, presented the way the app's SDK presents a credential: the world's own header,
593
620
  * the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
594
621
  * GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
@@ -824,7 +851,7 @@ export class WorldDoors {
824
851
  return foreign;
825
852
  const reads = request.method === 'GET' || request.method === 'HEAD';
826
853
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
827
- // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
854
+ // the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
828
855
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
829
856
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
830
857
  const link = this.link(vendor);
@@ -908,20 +935,49 @@ export class WorldDoors {
908
935
  return held === null ? {} : JSON.parse(held);
909
936
  }
910
937
  link(name) { return this.links()[name] ?? null; }
911
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
912
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
938
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
939
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
913
940
  * answer (status, headers, body) is streamed back as it arrives. */
914
941
  async forward(request, url, rest, name, origin) {
915
- const credential = await openSealedAt(credentialPath(this.worldRoot, name), name);
942
+ // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
943
+ // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
944
+ const caller = new Headers(request.headers);
945
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
946
+ caller.delete(h);
947
+ // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
948
+ const authorization = caller.get('authorization');
949
+ const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
950
+ if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued())))
951
+ caller.delete('authorization');
952
+ // an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
953
+ // name, with the credential shape of what the caller sent on
954
+ const startedAt = Date.now();
955
+ let status = 500;
956
+ try {
957
+ const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
958
+ status = answer.status;
959
+ return answer;
960
+ }
961
+ finally {
962
+ journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
963
+ }
964
+ }
965
+ async forwardSealed(request, caller, url, rest, name, origin) {
966
+ const base = validateRemoteOrigin(origin);
967
+ // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
968
+ let credential;
969
+ try {
970
+ credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin));
971
+ }
972
+ catch (error) {
973
+ return Response.json({ error: error.message }, { status: 409 });
974
+ }
916
975
  if (credential === null)
917
976
  return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
918
- const base = validateRemoteOrigin(origin);
919
977
  const target = new URL(`${base.origin}/${rest}${url.search}`);
920
978
  if (target.origin !== base.origin)
921
979
  return Response.json({ error: 'the path left the link origin' }, { status: 400 });
922
- const headers = new Headers(request.headers);
923
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
924
- headers.delete(h);
980
+ const headers = new Headers(caller);
925
981
  for (const [k, v] of Object.entries(credential.headers))
926
982
  headers.set(k, v);
927
983
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
@@ -1267,11 +1323,11 @@ export class WorldDoors {
1267
1323
  }
1268
1324
  if (kind === 'requests' && !name && request.method === 'GET') {
1269
1325
  // the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
1270
- // requests by twin and status class, the response time's p50/p95, the top routes
1326
+ // requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
1271
1327
  const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000;
1272
1328
  const since = Date.now() - sinceMs;
1273
1329
  const twins = [];
1274
- for (const vendor of Object.keys(this.up().services)) {
1330
+ for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
1275
1331
  const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
1276
1332
  const byStatus = {};
1277
1333
  const routes = new Map();
@@ -1314,7 +1370,12 @@ export class WorldDoors {
1314
1370
  const text = await request.text();
1315
1371
  if (text.length > MAX_BODY)
1316
1372
  return Response.json({ error: 'too large' }, { status: 413 });
1317
- const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name));
1373
+ // sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
1374
+ // nowhere it may go, so nothing is sealed (set the root or the link first)
1375
+ const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
1376
+ if (!destination)
1377
+ return Response.json({ error: `the ${name} twin has no root and there is no ${name} link: set where its credential goes first` }, { status: 409 });
1378
+ const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
1318
1379
  return Response.json({ twin: name, sealed: true, ...info });
1319
1380
  }
1320
1381
  if (verb === 'credential' && request.method === 'GET')
@@ -6,9 +6,9 @@ export type TailWorldOptions = {
6
6
  follow?: boolean;
7
7
  /** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
8
8
  json?: boolean;
9
- /** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
10
- * by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
11
- * status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
9
+ /** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
10
+ * twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
11
+ * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
12
12
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
13
13
  requests?: boolean;
14
14
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
package/dist/src/tail.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // this just watches what the twins already record. A ledger dir may not exist until a service
7
7
  // records its first action, so discovery re-runs on every poll and a missing ledger is a
8
8
  // notice, never an error.
9
- import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
9
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { stateDirName } from '@volter/world-core';
12
12
  import { statusWorld } from "./runtime.js";
@@ -22,6 +22,15 @@ function serviceLedgers(dataDir, service, file = 'actions.jsonl') {
22
22
  .map((entry) => join(stateRoot, entry.name, file))
23
23
  .sort();
24
24
  }
25
+ /** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
26
+ function linkNames(root) {
27
+ try {
28
+ return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
25
34
  /** Read the complete lines appended to `path` since the recorded offset, advancing the offset
26
35
  * only past the last newline — a half-written trailing line stays unconsumed until the writer
27
36
  * finishes it, so a poll can never emit a torn row. */
@@ -105,9 +114,11 @@ function sleep(ms, signal) {
105
114
  export async function tailWorldActions(name, options = {}) {
106
115
  const root = options.root ?? process.cwd();
107
116
  const instance = statusWorld(name, root);
108
- const known = Object.keys(instance.services);
117
+ // a link (an opened seal) journals its requests as a twin does, under its own name
118
+ const links = linkNames(root);
119
+ const known = [...new Set([...Object.keys(instance.services), ...links])];
109
120
  for (const service of options.services ?? []) {
110
- if (!instance.services[service]) {
121
+ if (!known.includes(service)) {
111
122
  throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
112
123
  }
113
124
  }
@@ -143,6 +154,8 @@ export async function tailWorldActions(name, options = {}) {
143
154
  };
144
155
  emit(collect());
145
156
  for (const service of services) {
157
+ if (links.includes(service) && !instance.services[service])
158
+ continue; // a link records requests, never actions
146
159
  if (serviceLedgers(instance.dirs.data, service).length === 0) {
147
160
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
148
161
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.30",
3
+ "version": "2.0.32",
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",
71
- "@volter/world-console": "2.0.10"
70
+ "@volter/world-core": "2.0.19",
71
+ "@volter/world-console": "2.0.11"
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.19",
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.19"
88
88
  }
89
89
  }
package/src/cli.ts CHANGED
@@ -112,10 +112,10 @@ function printHelp(): void {
112
112
  # merged, occurredAt-ordered feed of the twins' action ledgers — one
113
113
  # \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
114
114
  # follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
115
- # --requests: also merge the opt-in request journals — reads/404s,
115
+ # --requests: also merge the request journals — reads/404s,
116
116
  # plus WHICH credential arrived in WHICH header/query param, named +
117
- # fingerprinted, never the value — written by twins under
118
- # VOLTER_TWIN_REQUEST_JOURNAL=1)
117
+ # fingerprinted, never the value — kept by every twin and link
118
+ # unless VOLTER_TWIN_REQUEST_JOURNAL=0)
119
119
  volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
120
120
  # pull from the placeholder remote: the seed entry runs with every twin's
121
121
  # window open, so the defaults land as observed state (diff empty, nothing pending)
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 { assertSafeChangesetName, 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, journalTwinRequest, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, 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);
@@ -246,7 +252,8 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
246
252
  // doors answer 503.
247
253
  let consoleOrigin: string | null = null;
248
254
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
249
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
255
+ // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
256
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
250
257
  const url = `http://${host}:${server.port}`;
251
258
  try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
252
259
  // the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
@@ -534,6 +541,22 @@ export class WorldDoors {
534
541
  twins(): string[] { return Object.keys(this.up().services); }
535
542
  private up(): WorldLayout { return this.host.layout(); }
536
543
 
544
+ /** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
545
+ * World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
546
+ * keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
547
+ * where it went. */
548
+ async bindLegacyCredentials(layout: { services: Record<string, unknown>; dirs: { data: string } } = this.up()): Promise<{ bound: string[]; failed: string[]; refused: string[]; ready: boolean }> {
549
+ return bindLegacyCredentials(this.worldRoot, () => {
550
+ const destinations: Record<string, string> = {};
551
+ for (const [name, link] of Object.entries(this.links())) destinations[name] = link.origin;
552
+ for (const vendor of Object.keys(layout.services)) {
553
+ const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
554
+ if (url) destinations[vendor] = url;
555
+ }
556
+ return destinations;
557
+ });
558
+ }
559
+
537
560
  /** The world's token, presented the way the app's SDK presents a credential: the world's own header,
538
561
  * the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
539
562
  * GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
@@ -743,7 +766,7 @@ export class WorldDoors {
743
766
  if (foreign) return foreign;
744
767
  const reads = request.method === 'GET' || request.method === 'HEAD';
745
768
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
746
- // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
769
+ // the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
747
770
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
748
771
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
749
772
  const link = this.link(vendor!);
@@ -813,17 +836,39 @@ export class WorldDoors {
813
836
  }
814
837
  private link(name: string): { origin: string } | null { return this.links()[name] ?? null; }
815
838
 
816
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
817
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
839
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
840
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
818
841
  * answer (status, headers, body) is streamed back as it arrives. */
819
842
  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 });
843
+ // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
844
+ // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
845
+ const caller = new Headers(request.headers);
846
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
847
+ // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
848
+ const authorization = caller.get('authorization');
849
+ const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
850
+ if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued()))) caller.delete('authorization');
851
+ // an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
852
+ // name, with the credential shape of what the caller sent on
853
+ const startedAt = Date.now(); let status = 500;
854
+ try {
855
+ const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
856
+ status = answer.status;
857
+ return answer;
858
+ } finally {
859
+ journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
860
+ }
861
+ }
862
+
863
+ private async forwardSealed(request: Request, caller: Headers, url: URL, rest: string, name: string, origin: string): Promise<Response> {
822
864
  const base = validateRemoteOrigin(origin);
865
+ // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
866
+ let credential: Awaited<ReturnType<typeof openSealedAt>>;
867
+ try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 409 }); }
868
+ if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
823
869
  const target = new URL(`${base.origin}/${rest}${url.search}`);
824
870
  if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
825
- const headers = new Headers(request.headers);
826
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) headers.delete(h);
871
+ const headers = new Headers(caller);
827
872
  for (const [k, v] of Object.entries(credential.headers)) headers.set(k, v);
828
873
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
829
874
  const controller = new AbortController();
@@ -1096,10 +1141,10 @@ export class WorldDoors {
1096
1141
  }
1097
1142
  if (kind === 'requests' && !name && request.method === 'GET') {
1098
1143
  // the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
1099
- // requests by twin and status class, the response time's p50/p95, the top routes
1144
+ // requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
1100
1145
  const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000; const since = Date.now() - sinceMs;
1101
1146
  const twins: Array<{ twin: string; requests: number; byStatus: Record<string, number>; p50Ms: number | null; p95Ms: number | null; topRoutes: Array<{ route: string; requests: number }> }> = [];
1102
- for (const vendor of Object.keys(this.up().services)) {
1147
+ for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
1103
1148
  const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
1104
1149
  const byStatus: Record<string, number> = {}; const routes = new Map<string, number>(); const times: number[] = [];
1105
1150
  for (const e of entries) { const cls = `${String(e.status)[0]}xx`; byStatus[cls] = (byStatus[cls] ?? 0) + 1; const route = `${e.method} ${e.path.replace(/\/[0-9]+(?=\/|$)/g, '/{id}')}`; routes.set(route, (routes.get(route) ?? 0) + 1); if (typeof e.ms === 'number') times.push(e.ms); }
@@ -1129,7 +1174,11 @@ export class WorldDoors {
1129
1174
  if (verb === 'credential' && request.method === 'PUT') {
1130
1175
  const text = await request.text();
1131
1176
  if (text.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
1132
- const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name));
1177
+ // sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
1178
+ // nowhere it may go, so nothing is sealed (set the root or the link first)
1179
+ const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
1180
+ 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 });
1181
+ const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
1133
1182
  return Response.json({ twin: name, sealed: true, ...info });
1134
1183
  }
1135
1184
  if (verb === 'credential' && request.method === 'GET') return Response.json({ twin: name, credential: sealedCredentialInfo(this.worldRoot, name) });
package/src/tail.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // this just watches what the twins already record. A ledger dir may not exist until a service
7
7
  // records its first action, so discovery re-runs on every poll and a missing ledger is a
8
8
  // notice, never an error.
9
- import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
9
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { stateDirName } from '@volter/world-core';
12
12
  import { statusWorld } from './runtime.ts';
@@ -40,9 +40,9 @@ export type TailWorldOptions = {
40
40
  follow?: boolean;
41
41
  /** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
42
42
  json?: boolean;
43
- /** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
44
- * by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
45
- * status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
43
+ /** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
44
+ * twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
45
+ * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
46
46
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
47
47
  requests?: boolean;
48
48
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
@@ -68,6 +68,11 @@ function serviceLedgers(dataDir: string, service: string, file: 'actions.jsonl'
68
68
  .sort();
69
69
  }
70
70
 
71
+ /** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
72
+ function linkNames(root: string): string[] {
73
+ try { return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')) as Record<string, unknown>); } catch { return []; }
74
+ }
75
+
71
76
  /** Read the complete lines appended to `path` since the recorded offset, advancing the offset
72
77
  * only past the last newline — a half-written trailing line stays unconsumed until the writer
73
78
  * finishes it, so a poll can never emit a torn row. */
@@ -150,9 +155,11 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
150
155
  export async function tailWorldActions(name: string, options: TailWorldOptions = {}): Promise<void> {
151
156
  const root = options.root ?? process.cwd();
152
157
  const instance = statusWorld(name, root);
153
- const known = Object.keys(instance.services);
158
+ // a link (an opened seal) journals its requests as a twin does, under its own name
159
+ const links = linkNames(root);
160
+ const known = [...new Set([...Object.keys(instance.services), ...links])];
154
161
  for (const service of options.services ?? []) {
155
- if (!instance.services[service]) {
162
+ if (!known.includes(service)) {
156
163
  throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
157
164
  }
158
165
  }
@@ -189,6 +196,7 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
189
196
 
190
197
  emit(collect());
191
198
  for (const service of services) {
199
+ if (links.includes(service) && !instance.services[service]) continue; // a link records requests, never actions
192
200
  if (serviceLedgers(instance.dirs.data, service).length === 0) {
193
201
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
194
202
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'