@volter/world-runtime 3.0.11 → 3.0.13

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.
@@ -920,11 +920,15 @@ export async function main() {
920
920
  caFile = join(mkdtempSync(join(tmpdir(), 'volter-attach-')), 'ca.pem');
921
921
  writeFileSync(caFile, manifest.ca);
922
922
  }
923
+ // the World's twins, each a loopback listener here that adds its key, so a command with its own HTTP stack reaches
924
+ // them as it would a local World's (the manifest pointed at the listeners before its env is made)
925
+ const { bridgeTwins } = createRequire(import.meta.url)('@volter/world-core/http-bridge');
926
+ await bridgeTwins(manifest, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '', manifest.ca ?? undefined);
923
927
  const injectPath = createRequire(import.meta.url).resolve('@volter/world-core/inject');
924
928
  const env = remoteAttachEnv(manifest, { injectPath, ...(caFile === undefined ? {} : { caFile }) });
925
929
  // the world's byte-stream doors, bridged to loopback listeners here, beside a command in any language
926
930
  const { bridgeStreams, drainStreams } = createRequire(import.meta.url)('@volter/world-core/stream-bridge');
927
- const bridged = manifest.streams ? await bridgeStreams(manifest.streams, attachToken ?? manifest.env.VOLTER_TWINS_KEY ?? '') : { env: {} };
931
+ const bridged = manifest.streams ? await bridgeStreams(manifest.streams, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '') : { env: {} };
928
932
  // VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
929
933
  const commandEnv = withoutVaultSignIn({ ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref });
930
934
  commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
@@ -44,7 +44,7 @@ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCreden
44
44
  export type { DeployTwinOutcome, MaterializedRoot } from './root.js';
45
45
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.js';
46
46
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.js';
47
- export { LocalBranches } from './local-branches.js';
47
+ export { followParentPins, LocalBranches } from './local-branches.js';
48
48
  export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.js';
49
49
  export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.js';
50
50
  export type { ViewConsole, WorldView } from './world-view.js';
package/dist/src/index.js CHANGED
@@ -31,7 +31,7 @@ export { fetchFromOrigin, worldOrigin } from "./origin.js";
31
31
  export { branchWorld, checkoutWorld } from "./branch.js";
32
32
  export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCredentials, 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
- export { LocalBranches } from "./local-branches.js";
34
+ export { followParentPins, LocalBranches } from "./local-branches.js";
35
35
  export { serveWorldView, VIEW_CONSOLE_BASE } from "./world-view.js";
36
36
  export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
37
37
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor } from "./console-apart.js";
@@ -1,4 +1,15 @@
1
1
  import { type BranchDoors, type BranchRow, type MountedWorld } from './served-world.js';
2
+ /** A branch mounted again beside its parent takes the parent's twin versions: it is the parent's config under another
3
+ * name and runs on the packages the parent's root installs, so a parent moved to a new twin version (its pin and its
4
+ * install, together) never leaves a branch pinning the old one and failing the next boot. */
5
+ export declare function followParentPins(parentRoot: string, branchRoot: string): void;
6
+ /** A write key of a branch, named for the key whose holder made it (the branch's own keys door): what that holder gets in
7
+ * place of the branch's token, so the branch refuses it what a key is refused (seals, checks, keys, rules, sessions). */
8
+ export declare function keyOfBranch(branch: {
9
+ served: string;
10
+ token: string;
11
+ handle: (request: Request) => Promise<Response>;
12
+ }, origin: string, maker: string): Promise<string>;
2
13
  export type LocalBranchesOptions = {
3
14
  /** where branches live: `<dir>/<org>/<world>/` */
4
15
  dir: string;
@@ -19,6 +19,37 @@ function renamed(config, id, name) {
19
19
  }
20
20
  return { ...config, id, bare: { ...config.bare, name } };
21
21
  }
22
+ /** A branch mounted again beside its parent takes the parent's twin versions: it is the parent's config under another
23
+ * name and runs on the packages the parent's root installs, so a parent moved to a new twin version (its pin and its
24
+ * install, together) never leaves a branch pinning the old one and failing the next boot. */
25
+ export function followParentPins(parentRoot, branchRoot) {
26
+ const file = join(branchRoot, stateDirName(), 'world.json');
27
+ const pinOf = (s) => s.source?.version ?? s.version;
28
+ const parent = JSON.parse(readFileSync(join(parentRoot, stateDirName(), 'world.json'), 'utf8'));
29
+ const branch = JSON.parse(readFileSync(file, 'utf8'));
30
+ const pins = new Map((parent.services ?? []).flatMap((s) => { const pin = pinOf(s); return s.id && pin ? [[s.id, pin]] : []; }));
31
+ let moved = false;
32
+ for (const s of branch.services ?? []) {
33
+ const pin = s.id ? pins.get(s.id) : undefined;
34
+ if (!pin || pinOf(s) === pin)
35
+ continue;
36
+ if (s.source && typeof s.source === 'object')
37
+ s.source.version = pin;
38
+ else
39
+ s.version = pin;
40
+ moved = true;
41
+ }
42
+ if (moved)
43
+ writeFileSync(file, `${JSON.stringify(branch, null, 2)}\n`);
44
+ }
45
+ /** A write key of a branch, named for the key whose holder made it (the branch's own keys door): what that holder gets in
46
+ * place of the branch's token, so the branch refuses it what a key is refused (seals, checks, keys, rules, sessions). */
47
+ export async function keyOfBranch(branch, origin, maker) {
48
+ const made = await branch.handle(new Request(`${origin}/-/${branch.served}/keys`, { method: 'POST', headers: { [TOKEN_HEADER]: branch.token, 'content-type': 'application/json' }, body: JSON.stringify({ name: `${maker} (made this branch)`.slice(0, 100), scope: 'write' }) }));
49
+ if (!made.ok)
50
+ throw new Error(`a key of ${branch.served} for ${maker}: ${await made.text()}`);
51
+ return (await made.json()).key;
52
+ }
22
53
  export class LocalBranches {
23
54
  o;
24
55
  sweeper = null;
@@ -50,15 +81,25 @@ export class LocalBranches {
50
81
  doorsFor(parent) {
51
82
  return {
52
83
  list: async () => this.list(parent),
53
- create: (at, ttl, _origin, parentKey) => this.create(parent, at, ttl, parentKey),
84
+ create: (at, ttl, _origin, parentKey, byKey) => this.create(parent, at, ttl, parentKey, byKey),
54
85
  remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent)
55
86
  return false; await this.remove(name); return true; },
87
+ // a branch of this parent only, served in this process beside it
88
+ show: async (name, request, vendor, rest, prefix) => {
89
+ const w = this.o.worlds.get(name);
90
+ const r = w ? this.record(w) : null;
91
+ if (!w || r?.from !== parent)
92
+ return Response.json({ error: `no branch ${name} of ${parent}` }, { status: 404 });
93
+ if (!r.at?.instant || r.at.label || r.at.live)
94
+ return Response.json({ error: `${name} is not a copy as of an instant: its pages are shown at its own address` }, { status: 403 });
95
+ return await w.shown(request, vendor, rest, prefix);
96
+ },
56
97
  };
57
98
  }
58
- async create(parent, at, ttlSeconds, parentKey) {
99
+ async create(parent, at, ttlSeconds, parentKey, byKey) {
59
100
  this.making.set(parent, (this.making.get(parent) ?? 0) + 1);
60
101
  try {
61
- return await this.make(parent, at, ttlSeconds, parentKey);
102
+ return await this.make(parent, at, ttlSeconds, parentKey, byKey);
62
103
  }
63
104
  finally {
64
105
  const left = (this.making.get(parent) ?? 1) - 1;
@@ -68,7 +109,7 @@ export class LocalBranches {
68
109
  this.making.delete(parent);
69
110
  }
70
111
  }
71
- async make(parent, at, ttlSeconds, parentKey) {
112
+ async make(parent, at, ttlSeconds, parentKey, byKey) {
72
113
  const from = this.o.worlds.get(parent);
73
114
  if (!from)
74
115
  throw new Error(`no world ${parent} here`);
@@ -129,9 +170,11 @@ export class LocalBranches {
129
170
  }
130
171
  const createdAt = new Date().toISOString();
131
172
  const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
132
- writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: at.instant ? { instant: at.instant } : null, createdAt, expiresAt }, null, 2)}\n`);
173
+ // what it was made as (a pull request's preview is labelled and may be live): what may be shown of it rests on that
174
+ const made = { ...(at.instant ? { instant: at.instant } : {}), ...(at.label ? { label: at.label } : {}), ...(at.live ? { live: true } : {}) };
175
+ writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: Object.keys(made).length ? made : null, createdAt, expiresAt }, null, 2)}\n`);
133
176
  this.o.announce?.(`branch ${name} of ${parent}${at.instant ? ` as of ${at.instant}` : ''}${expiresAt ? `, until ${expiresAt}` : ''}`);
134
- return { name, token: mounted.token, readToken: mounted.readToken, expiresAt };
177
+ return { name, token: byKey ? await keyOfBranch(mounted, origin, byKey) : mounted.token, readToken: mounted.readToken, expiresAt };
135
178
  }
136
179
  catch (error) {
137
180
  this.o.worlds.delete(name);
@@ -57,7 +57,8 @@ export type MountedWorld = {
57
57
  readToken: string;
58
58
  };
59
59
  handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */
60
- site: (request: Request, host: string) => Promise<Response>;
60
+ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.shown */
61
+ shown: (request: Request, vendor: string, rest: string, prefix: string) => Promise<Response>;
61
62
  boot: (url: string) => Promise<void>;
62
63
  stop: () => Promise<void>;
63
64
  };
@@ -164,13 +165,17 @@ export type BranchDoors = {
164
165
  instant?: string;
165
166
  live?: boolean;
166
167
  label?: string;
167
- }, ttlSeconds: number | null, origin: string, parentKey?: string) => Promise<{
168
+ }, ttlSeconds: number | null, origin: string, parentKey?: string, /** the name of the key that made it: whoever holds a key gets a key of the branch (named for it),
169
+ * never the branch's own token, so the branch's doors refuse it what a key is refused */ byKey?: string) => Promise<{
168
170
  name: string;
169
171
  token: string;
170
172
  readToken: string;
171
173
  expiresAt: string | null;
172
174
  }>;
173
175
  remove: (name: string) => Promise<boolean>;
176
+ /** a page of a branch, read as its parent shows it (a vendor's screen at `prefix`, the parent's own path for it): where
177
+ * the host holds the branch beside its parent; absent, a branch's pages are seen only at the branch's own address */
178
+ show?: (name: string, request: Request, vendor: string, rest: string, prefix: string) => Promise<Response>;
174
179
  };
175
180
  /** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
176
181
  * label: lowercase letters, digits and single hyphens, 63 at most). A host maps a label back by the
@@ -303,6 +308,11 @@ export declare class WorldDoors {
303
308
  vendor: string;
304
309
  hosts: string[];
305
310
  }>>;
311
+ /** A vendor's page of this World as another World shows it at `prefix` (a parent showing its branch, the comparison's
312
+ * "before"): read with this World's read token (a twin that enforces the read scope refuses any write), its links
313
+ * minted under `prefix`.
314
+ * The host calls it for the parent, which has checked its own caller; nothing of this World's session is involved. */
315
+ shown(request: Request, vendor: string, rest: string, prefix: string): Promise<Response>;
306
316
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
307
317
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
308
318
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, 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 { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
29
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
30
30
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { packFacts } from "./pack-facts.js";
32
32
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
@@ -213,6 +213,7 @@ export async function mountWorld(name, opts = {}) {
213
213
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
214
214
  handle: (request) => world.handle(request),
215
215
  site: (request, host) => world.site(request, host),
216
+ shown: (request, vendor, rest, prefix) => world.shown(request, vendor, rest, prefix),
216
217
  async boot(url) {
217
218
  keep();
218
219
  const port = Number(new URL(url).port) || 0;
@@ -816,6 +817,31 @@ export class WorldDoors {
816
817
  }
817
818
  return out;
818
819
  }
820
+ /** A vendor's page of this World as another World shows it at `prefix` (a parent showing its branch, the comparison's
821
+ * "before"): read with this World's read token (a twin that enforces the read scope refuses any write), its links
822
+ * minted under `prefix`.
823
+ * The host calls it for the parent, which has checked its own caller; nothing of this World's session is involved. */
824
+ async shown(request, vendor, rest, prefix) {
825
+ if (request.method !== 'GET' && request.method !== 'HEAD')
826
+ return Response.json({ error: "a copy's pages are read: GET and HEAD" }, { status: 405 });
827
+ if (!this.host.ready())
828
+ return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
829
+ // a twin's pages only: a link forwards to the vendor itself, never something shown on another World's origin
830
+ if (this.link(vendor) || !this.host.twinFetch(vendor))
831
+ return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
832
+ const url = new URL(request.url);
833
+ // only what a browser's read of a page says, as a site's read (site()): never a caller's credential, cookie, or
834
+ // the signed-address headers a session may not name
835
+ const headers = new Headers();
836
+ for (const name of ['accept', 'accept-language', 'if-none-match', 'if-modified-since', 'range', 'user-agent', 'sec-fetch-dest']) {
837
+ const v = request.headers.get(name);
838
+ if (v !== null)
839
+ headers.set(name, v);
840
+ }
841
+ headers.set(TOKEN_HEADER, this.readToken);
842
+ const at = new URL(`${url.origin}/${this.served}/${vendor}/${rest}${url.search}`);
843
+ return await this.wire(new Request(at, { method: request.method, headers, redirect: 'manual' }), at, `${vendor}/${rest}`, prefix);
844
+ }
819
845
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
820
846
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
821
847
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -946,7 +972,7 @@ export class WorldDoors {
946
972
  return enforced;
947
973
  }
948
974
  /** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
949
- async wire(request, url, rest) {
975
+ async wire(request, url, rest, /** the path its twin's pages are shown at, when not this World's own */ shownAt) {
950
976
  const [vendor, ...tail] = rest.split('/');
951
977
  const { scope, via } = this.credential(request);
952
978
  const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
@@ -982,6 +1008,12 @@ export class WorldDoors {
982
1008
  // the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
983
1009
  // request's log entries carry, so each entry is joined to its caller by an id no caller chose
984
1010
  headers.delete(CALLER_HEADER);
1011
+ // the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
1012
+ // credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
1013
+ if (via === 'session') {
1014
+ headers.delete(ORIGINAL_PATH_HEADER);
1015
+ headers.delete('x-volter-twin-original-host');
1016
+ }
985
1017
  const who = this.callerOf(request);
986
1018
  if (who)
987
1019
  headers.set(CALLER_HEADER, callerForHeader(who));
@@ -997,7 +1029,7 @@ export class WorldDoors {
997
1029
  else
998
1030
  headers.delete('cookie');
999
1031
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
1000
- headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
1032
+ headers.set(TWIN_PREFIX_HEADER, shownAt ?? `/${this.served}/${vendor}`);
1001
1033
  headers.set('x-forwarded-host', url.host);
1002
1034
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1003
1035
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
@@ -1144,6 +1176,35 @@ export class WorldDoors {
1144
1176
  // ending a browser's session needs no token: it only clears the caller's own cookie, which never
1145
1177
  // reaches these /-/ paths, so a page holding just the session could not otherwise sign out
1146
1178
  const sessionPath = path.replace(/\/+$/, '');
1179
+ // THE HAND-OFF: a page of no UI that opens this World's session from the token in its fragment, then goes to the page
1180
+ // of this World it names (`land`): how a page elsewhere links to this World's own pages (the console's comparison
1181
+ // opening a vendor's page in a copy of the World, read only). The token never reaches the server in a URL; its script
1182
+ // is a file of this origin, so the page keeps a strict policy. It is never framed: a session opened in another
1183
+ // site's frame is not this World's own page's
1184
+ if (sessionPath === 'session/hand-off' && request.method === 'GET') {
1185
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="/-/${this.served}/session/hand-off.js"></script></body>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
1186
+ }
1187
+ if (sessionPath === 'session/hand-off.js' && request.method === 'GET') {
1188
+ const script = `(() => {
1189
+ const world = document.body.dataset.world;
1190
+ const carried = new URLSearchParams(location.hash.slice(1));
1191
+ history.replaceState(null, '', location.pathname);
1192
+ const token = carried.get('token') || '';
1193
+ const land = carried.get('land') || '';
1194
+ // resolved as the browser will (encoded dots too), on this origin and under this World
1195
+ let to = null;
1196
+ try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
1197
+ const open = () => fetch('/-/' + world + '/session', { method: 'POST', headers: { 'x-volter-token': token } });
1198
+ if (!token || !to) { document.body.textContent = 'Nothing to open.'; return; }
1199
+ // a session this browser already holds here is kept: a page elsewhere cannot replace, downgrade or fix it
1200
+ fetch('/-/' + world + '/session')
1201
+ .then((held) => (held.ok ? held : open()))
1202
+ .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
1203
+ .catch(() => { document.body.textContent = 'This World did not answer.'; });
1204
+ })();
1205
+ `;
1206
+ return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
1207
+ }
1147
1208
  // a person signing in from a page (the console's form, posted to this origin): the token in the
1148
1209
  // form body, the session set here, then on to a page of this World
1149
1210
  // a form or beacon from another site never opens or ends a session here (the console, on this host's
@@ -1221,6 +1282,34 @@ export class WorldDoors {
1221
1282
  return this.session(request, url, scope, via === 'key' ? this.presentedKey(request) : undefined);
1222
1283
  if (kind === 'keys')
1223
1284
  return this.keysDoor(request, name, scope, via, this.credential(request).session);
1285
+ // A COPY'S PAGES SHOWN HERE: `copies/<branch>/<vendor>/…`, a vendor's page in a branch of this World, read on this
1286
+ // World's origin under its caller's credential (the comparison frames its "before" beside "now" this way: the
1287
+ // branch's own session opens only on the branch's own pages). Only a copy as of an instant, which only a writer of
1288
+ // this World makes, so its pages run here with nothing a stranger wrote (a labelled or live branch, a pull request's
1289
+ // preview written by the app it tests, is shown at its own address). Read only; no cookie the copy sets is kept
1290
+ if (kind === 'copies' && name && verb) {
1291
+ const show = this.host.branches?.show;
1292
+ if (!show)
1293
+ return Response.json({ error: "this host shows a copy's pages only at the copy's own address" }, { status: 501 });
1294
+ const [org] = this.served.split('/');
1295
+ // the path after the vendor as it came, empty segments and a trailing slash kept (an object key may hold `//`)
1296
+ const full = `${org}/${name}`;
1297
+ // made by the World's token or a person's session: a key's copy is written by whoever holds the key, and its pages
1298
+ // would run here under the viewer's session
1299
+ const madeBy = this.keys().find((k) => k.branch === full && (!k.expiresAt || Date.parse(k.expiresAt) > Date.now()))?.createdBy?.via;
1300
+ if (madeBy !== 'token' && madeBy !== 'session')
1301
+ return Response.json({ error: `${full} was not made by this World's token or a person here: its pages are shown at its own address` }, { status: 403 });
1302
+ // the path after the vendor as it came (empty segments and a trailing slash kept: an object key may hold `//`)
1303
+ const exact = /^copies\/[^/]+\/[^/]+(?:\/(.*))?$/.exec(path);
1304
+ if (!exact)
1305
+ return Response.json({ error: 'copies/<branch>/<vendor>/<path>' }, { status: 404 });
1306
+ const answer = await show(full, request, verb, exact[1] ?? '', `/-/${this.served}/copies/${name}/${verb}`);
1307
+ if (answer.status < 200)
1308
+ return Response.json({ error: "a copy's pages are read, never upgraded" }, { status: 400 });
1309
+ const out = new Headers(answer.headers);
1310
+ out.delete('set-cookie');
1311
+ return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
1312
+ }
1224
1313
  if (scope === 'read' && request.method !== 'GET')
1225
1314
  return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1226
1315
  if (kind === 'clock')
@@ -1861,7 +1950,7 @@ export class WorldDoors {
1861
1950
  const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
1862
1951
  this.keepKeys([...this.keys(), { id: keyId, name: 'a branch (being made)', scope: 'write', hash: keyHash(parentKey), createdAt: new Date(now).toISOString(), createdBy, ...(person ? { for: person } : {}), ...(expiresAt ? { expiresAt } : {}), ...(maker ? { madeWith: maker.id } : {}) }]);
1863
1952
  try {
1864
- const made = await branches.create(typeof instant === 'string' ? { instant: new Date(Date.parse(instant)).toISOString() } : { ...(body?.live === true ? { live: true } : {}), ...(typeof body?.label === 'string' ? { label: body.label } : {}) }, ttl, url.origin, parentKey);
1953
+ const made = await branches.create(typeof instant === 'string' ? { instant: new Date(Date.parse(instant)).toISOString() } : { ...(body?.live === true ? { live: true } : {}), ...(typeof body?.label === 'string' ? { label: body.label } : {}) }, ttl, url.origin, parentKey, maker ? maker.name : undefined);
1865
1954
  this.keepKeys(this.keys().map((k) => (k.id === keyId ? { ...k, name: `the branch ${made.name}`, branch: made.name, ...(made.expiresAt ? { expiresAt: made.expiresAt } : {}) } : k)));
1866
1955
  // a browser's session is answered the branch's read token only: a page views a branch, and the parent's
1867
1956
  // session removes it, so no write token ever reaches a page's script (or a frame beside it)
@@ -6,7 +6,7 @@ import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { loadWorldConfig } from "./configs.js";
7
7
  import { join, resolve } from 'node:path';
8
8
  import { serveHttp, stateDirName } from '@volter/world-core/runtime';
9
- import { LocalBranches } from "./local-branches.js";
9
+ import { followParentPins, LocalBranches } from "./local-branches.js";
10
10
  import { mountWorld } from "./served-world.js";
11
11
  import { localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
12
12
  export const VIEW_CONSOLE_BASE = '/-/console';
@@ -158,6 +158,7 @@ export async function serveWorldView(name, opts = {}) {
158
158
  await world.boot(url);
159
159
  // branches an earlier view made: mounted again (the sweep then removes those whose time ran out)
160
160
  for (const at of branchRoots(join(root, stateDirName(), 'branches'))) {
161
+ followParentPins(root, at);
161
162
  const b = await mountAt(at);
162
163
  await b.boot(url);
163
164
  worlds.set(b.served, b);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.11",
3
+ "version": "3.0.13",
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",
@@ -68,14 +68,14 @@
68
68
  "node": ">=22.3"
69
69
  },
70
70
  "peerDependencies": {
71
- "@volter/world-core": "3.0.11",
72
- "@volter/world-console": "3.0.6"
71
+ "@volter/world-core": "3.0.13",
72
+ "@volter/world-console": "3.0.8"
73
73
  },
74
74
  "dependencies": {
75
75
  "@electric-sql/pglite": "0.5.8",
76
76
  "@electric-sql/pglite-pgvector": "0.0.9",
77
77
  "@volter/world-access": "3.0.1",
78
- "@volter/world-core": "3.0.11",
78
+ "@volter/world-core": "3.0.13",
79
79
  "pg-gateway": "0.3.0-beta.4",
80
80
  "smol-toml": "^1.8.0",
81
81
  "yaml": "2.9.0"
@@ -802,11 +802,15 @@ export async function main(): Promise<void> {
802
802
  caFile = join(mkdtempSync(join(tmpdir(), 'volter-attach-')), 'ca.pem');
803
803
  writeFileSync(caFile, manifest.ca);
804
804
  }
805
+ // the World's twins, each a loopback listener here that adds its key, so a command with its own HTTP stack reaches
806
+ // them as it would a local World's (the manifest pointed at the listeners before its env is made)
807
+ const { bridgeTwins } = createRequire(import.meta.url)('@volter/world-core/http-bridge') as { bridgeTwins: (manifest: WorldManifest, key: string, ca?: string) => Promise<unknown[]> };
808
+ await bridgeTwins(manifest, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '', manifest.ca ?? undefined);
805
809
  const injectPath = createRequire(import.meta.url).resolve('@volter/world-core/inject');
806
810
  const env = remoteAttachEnv(manifest, { injectPath, ...(caFile === undefined ? {} : { caFile }) });
807
811
  // the world's byte-stream doors, bridged to loopback listeners here, beside a command in any language
808
812
  const { bridgeStreams, drainStreams } = createRequire(import.meta.url)('@volter/world-core/stream-bridge') as { bridgeStreams: (streams: WorldManifest['streams'], token: string) => Promise<{ env: Record<string, string> }>; drainStreams: (ms?: number) => Promise<void> };
809
- const bridged = manifest.streams ? await bridgeStreams(manifest.streams, attachToken ?? manifest.env.VOLTER_TWINS_KEY ?? '') : { env: {} };
813
+ const bridged = manifest.streams ? await bridgeStreams(manifest.streams, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '') : { env: {} };
810
814
  // VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
811
815
  const commandEnv: Record<string, string | undefined> = withoutVaultSignIn({ ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref });
812
816
  commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
package/src/index.ts CHANGED
@@ -166,7 +166,7 @@ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCreden
166
166
  export type { DeployTwinOutcome, MaterializedRoot } from './root.ts';
167
167
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.ts';
168
168
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
169
- export { LocalBranches } from './local-branches.ts';
169
+ export { followParentPins, LocalBranches } from './local-branches.ts';
170
170
  export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.ts';
171
171
  export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
172
172
  export type { ViewConsole, WorldView } from './world-view.ts';
@@ -24,6 +24,35 @@ function renamed(config: Record<string, unknown>, id: string, name: string): Rec
24
24
  return { ...config, id, bare: { ...(config.bare as Record<string, unknown> | undefined), name } };
25
25
  }
26
26
 
27
+ /** A branch mounted again beside its parent takes the parent's twin versions: it is the parent's config under another
28
+ * name and runs on the packages the parent's root installs, so a parent moved to a new twin version (its pin and its
29
+ * install, together) never leaves a branch pinning the old one and failing the next boot. */
30
+ export function followParentPins(parentRoot: string, branchRoot: string): void {
31
+ const file = join(branchRoot, stateDirName(), 'world.json');
32
+ // a twin's pin: format 2 keeps it on the service's source, format 1 on the service itself
33
+ type Service = { id?: string; version?: string; source?: { version?: string } };
34
+ const pinOf = (s: Service): string | undefined => s.source?.version ?? s.version;
35
+ const parent = JSON.parse(readFileSync(join(parentRoot, stateDirName(), 'world.json'), 'utf8')) as { services?: Service[] };
36
+ const branch = JSON.parse(readFileSync(file, 'utf8')) as { services?: Service[] };
37
+ const pins = new Map((parent.services ?? []).flatMap((s) => { const pin = pinOf(s); return s.id && pin ? [[s.id, pin] as const] : []; }));
38
+ let moved = false;
39
+ for (const s of branch.services ?? []) {
40
+ const pin = s.id ? pins.get(s.id) : undefined;
41
+ if (!pin || pinOf(s) === pin) continue;
42
+ if (s.source && typeof s.source === 'object') s.source.version = pin; else s.version = pin;
43
+ moved = true;
44
+ }
45
+ if (moved) writeFileSync(file, `${JSON.stringify(branch, null, 2)}\n`);
46
+ }
47
+
48
+ /** A write key of a branch, named for the key whose holder made it (the branch's own keys door): what that holder gets in
49
+ * place of the branch's token, so the branch refuses it what a key is refused (seals, checks, keys, rules, sessions). */
50
+ export async function keyOfBranch(branch: { served: string; token: string; handle: (request: Request) => Promise<Response> }, origin: string, maker: string): Promise<string> {
51
+ const made = await branch.handle(new Request(`${origin}/-/${branch.served}/keys`, { method: 'POST', headers: { [TOKEN_HEADER]: branch.token, 'content-type': 'application/json' }, body: JSON.stringify({ name: `${maker} (made this branch)`.slice(0, 100), scope: 'write' }) }));
52
+ if (!made.ok) throw new Error(`a key of ${branch.served} for ${maker}: ${await made.text()}`);
53
+ return ((await made.json()) as { key: string }).key;
54
+ }
55
+
27
56
  export type LocalBranchesOptions = {
28
57
  /** where branches live: `<dir>/<org>/<world>/` */
29
58
  dir: string;
@@ -62,19 +91,27 @@ export class LocalBranches {
62
91
  doorsFor(parent: string): BranchDoors {
63
92
  return {
64
93
  list: async () => this.list(parent),
65
- create: (at, ttl, _origin, parentKey) => this.create(parent, at, ttl, parentKey),
94
+ create: (at, ttl, _origin, parentKey, byKey) => this.create(parent, at, ttl, parentKey, byKey),
66
95
  remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent) return false; await this.remove(name); return true; },
96
+ // a branch of this parent only, served in this process beside it
97
+ show: async (name, request, vendor, rest, prefix) => {
98
+ const w = this.o.worlds.get(name);
99
+ const r = w ? this.record(w) : null;
100
+ if (!w || r?.from !== parent) return Response.json({ error: `no branch ${name} of ${parent}` }, { status: 404 });
101
+ if (!r.at?.instant || r.at.label || r.at.live) return Response.json({ error: `${name} is not a copy as of an instant: its pages are shown at its own address` }, { status: 403 });
102
+ return await w.shown(request, vendor, rest, prefix);
103
+ },
67
104
  };
68
105
  }
69
106
 
70
- private async create(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
107
+ private async create(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string, byKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
71
108
  this.making.set(parent, (this.making.get(parent) ?? 0) + 1);
72
- try { return await this.make(parent, at, ttlSeconds, parentKey); } finally {
109
+ try { return await this.make(parent, at, ttlSeconds, parentKey, byKey); } finally {
73
110
  const left = (this.making.get(parent) ?? 1) - 1;
74
111
  if (left > 0) this.making.set(parent, left); else this.making.delete(parent);
75
112
  }
76
113
  }
77
- private async make(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
114
+ private async make(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string, byKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
78
115
  const from = this.o.worlds.get(parent);
79
116
  if (!from) throw new Error(`no world ${parent} here`);
80
117
  const [org, world] = parent.split('/') as [string, string];
@@ -124,9 +161,11 @@ export class LocalBranches {
124
161
  if (clock) { const file = clockFile(root, id); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${clock}\n`); }
125
162
  const createdAt = new Date().toISOString();
126
163
  const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
127
- writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: at.instant ? { instant: at.instant } : null, createdAt, expiresAt } satisfies BranchRecord, null, 2)}\n`);
164
+ // what it was made as (a pull request's preview is labelled and may be live): what may be shown of it rests on that
165
+ const made = { ...(at.instant ? { instant: at.instant } : {}), ...(at.label ? { label: at.label } : {}), ...(at.live ? { live: true } : {}) };
166
+ writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: Object.keys(made).length ? made : null, createdAt, expiresAt } satisfies BranchRecord, null, 2)}\n`);
128
167
  this.o.announce?.(`branch ${name} of ${parent}${at.instant ? ` as of ${at.instant}` : ''}${expiresAt ? `, until ${expiresAt}` : ''}`);
129
- return { name, token: mounted.token, readToken: mounted.readToken, expiresAt };
168
+ return { name, token: byKey ? await keyOfBranch(mounted, origin, byKey) : mounted.token, readToken: mounted.readToken, expiresAt };
130
169
  } catch (error) {
131
170
  this.o.worlds.delete(name);
132
171
  try { await mounted?.stop(); } catch { /* reported below */ }
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, 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 { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
29
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, ORIGINAL_PATH_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
30
30
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
31
31
  import { packFacts } from './pack-facts.ts';
32
32
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
@@ -159,7 +159,7 @@ function configRefFor(worldRoot: string, name: string): string {
159
159
  /** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
160
160
  * `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
161
161
  * with the call — and keeps them beside the world like `boot` does. */
162
- export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
162
+ export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.shown */ shown: (request: Request, vendor: string, rest: string, prefix: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
163
163
  /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
164
164
  type HeldSession = { scope: 'read' | 'write'; until: number; /** opened by this machine's own page (localSession) */ local?: true; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
165
165
 
@@ -215,6 +215,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
215
215
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
216
216
  handle: (request) => world.handle(request),
217
217
  site: (request, host) => world.site(request, host),
218
+ shown: (request, vendor, rest, prefix) => world.shown(request, vendor, rest, prefix),
218
219
  async boot(url) {
219
220
  keep();
220
221
  const port = Number(new URL(url).port) || 0;
@@ -373,8 +374,12 @@ export type BranchRow = { name: string; from: string; at: { instant?: string; li
373
374
  export type BranchDoors = {
374
375
  list: () => Promise<BranchRow[]>;
375
376
  /** `parentKey`: the key the branch reaches this World with (never this World's own token), made for whoever asked */
376
- create: (at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, origin: string, parentKey?: string) => Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }>;
377
+ create: (at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, origin: string, parentKey?: string, /** the name of the key that made it: whoever holds a key gets a key of the branch (named for it),
378
+ * never the branch's own token, so the branch's doors refuse it what a key is refused */ byKey?: string) => Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }>;
377
379
  remove: (name: string) => Promise<boolean>;
380
+ /** a page of a branch, read as its parent shows it (a vendor's screen at `prefix`, the parent's own path for it): where
381
+ * the host holds the branch beside its parent; absent, a branch's pages are seen only at the branch's own address */
382
+ show?: (name: string, request: Request, vendor: string, rest: string, prefix: string) => Promise<Response>;
378
383
  };
379
384
 
380
385
  /** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
@@ -734,6 +739,25 @@ export class WorldDoors {
734
739
  return out;
735
740
  }
736
741
 
742
+ /** A vendor's page of this World as another World shows it at `prefix` (a parent showing its branch, the comparison's
743
+ * "before"): read with this World's read token (a twin that enforces the read scope refuses any write), its links
744
+ * minted under `prefix`.
745
+ * The host calls it for the parent, which has checked its own caller; nothing of this World's session is involved. */
746
+ async shown(request: Request, vendor: string, rest: string, prefix: string): Promise<Response> {
747
+ if (request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: "a copy's pages are read: GET and HEAD" }, { status: 405 });
748
+ if (!this.host.ready()) return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
749
+ // a twin's pages only: a link forwards to the vendor itself, never something shown on another World's origin
750
+ if (this.link(vendor) || !this.host.twinFetch(vendor)) return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
751
+ const url = new URL(request.url);
752
+ // only what a browser's read of a page says, as a site's read (site()): never a caller's credential, cookie, or
753
+ // the signed-address headers a session may not name
754
+ const headers = new Headers();
755
+ for (const name of ['accept', 'accept-language', 'if-none-match', 'if-modified-since', 'range', 'user-agent', 'sec-fetch-dest']) { const v = request.headers.get(name); if (v !== null) headers.set(name, v); }
756
+ headers.set(TOKEN_HEADER, this.readToken);
757
+ const at = new URL(`${url.origin}/${this.served}/${vendor}/${rest}${url.search}`);
758
+ return await this.wire(new Request(at, { method: request.method, headers, redirect: 'manual' }), at, `${vendor}/${rest}`, prefix);
759
+ }
760
+
737
761
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
738
762
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
739
763
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -841,7 +865,7 @@ export class WorldDoors {
841
865
  }
842
866
 
843
867
  /** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
844
- private async wire(request: Request, url: URL, rest: string): Promise<Response> {
868
+ private async wire(request: Request, url: URL, rest: string, /** the path its twin's pages are shown at, when not this World's own */ shownAt?: string): Promise<Response> {
845
869
  const [vendor, ...tail] = rest.split('/');
846
870
  const { scope, via } = this.credential(request);
847
871
  const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
@@ -869,6 +893,9 @@ export class WorldDoors {
869
893
  // the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
870
894
  // request's log entries carry, so each entry is joined to its caller by an id no caller chose
871
895
  headers.delete(CALLER_HEADER);
896
+ // the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
897
+ // credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
898
+ if (via === 'session') { headers.delete(ORIGINAL_PATH_HEADER); headers.delete('x-volter-twin-original-host'); }
872
899
  const who = this.callerOf(request); if (who) headers.set(CALLER_HEADER, callerForHeader(who));
873
900
  headers.set('traceparent', traceparentForDelivery(headers.get('traceparent')) ?? newTraceparent());
874
901
  // at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
@@ -878,7 +905,7 @@ export class WorldDoors {
878
905
  const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_world=/.test(c));
879
906
  if (cookies.length) headers.set('cookie', cookies.join('; ')); else headers.delete('cookie');
880
907
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
881
- headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
908
+ headers.set(TWIN_PREFIX_HEADER, shownAt ?? `/${this.served}/${vendor}`);
882
909
  headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
883
910
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
884
911
  // origin on this machine (siteOfHost); a World without one shows none
@@ -997,6 +1024,36 @@ export class WorldDoors {
997
1024
  // ending a browser's session needs no token: it only clears the caller's own cookie, which never
998
1025
  // reaches these /-/ paths, so a page holding just the session could not otherwise sign out
999
1026
  const sessionPath = path.replace(/\/+$/, '');
1027
+ // THE HAND-OFF: a page of no UI that opens this World's session from the token in its fragment, then goes to the page
1028
+ // of this World it names (`land`): how a page elsewhere links to this World's own pages (the console's comparison
1029
+ // opening a vendor's page in a copy of the World, read only). The token never reaches the server in a URL; its script
1030
+ // is a file of this origin, so the page keeps a strict policy. It is never framed: a session opened in another
1031
+ // site's frame is not this World's own page's
1032
+ if (sessionPath === 'session/hand-off' && request.method === 'GET') {
1033
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="/-/${this.served}/session/hand-off.js"></script></body>`,
1034
+ { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
1035
+ }
1036
+ if (sessionPath === 'session/hand-off.js' && request.method === 'GET') {
1037
+ const script = `(() => {
1038
+ const world = document.body.dataset.world;
1039
+ const carried = new URLSearchParams(location.hash.slice(1));
1040
+ history.replaceState(null, '', location.pathname);
1041
+ const token = carried.get('token') || '';
1042
+ const land = carried.get('land') || '';
1043
+ // resolved as the browser will (encoded dots too), on this origin and under this World
1044
+ let to = null;
1045
+ try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
1046
+ const open = () => fetch('/-/' + world + '/session', { method: 'POST', headers: { 'x-volter-token': token } });
1047
+ if (!token || !to) { document.body.textContent = 'Nothing to open.'; return; }
1048
+ // a session this browser already holds here is kept: a page elsewhere cannot replace, downgrade or fix it
1049
+ fetch('/-/' + world + '/session')
1050
+ .then((held) => (held.ok ? held : open()))
1051
+ .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
1052
+ .catch(() => { document.body.textContent = 'This World did not answer.'; });
1053
+ })();
1054
+ `;
1055
+ return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
1056
+ }
1000
1057
  // a person signing in from a page (the console's form, posted to this origin): the token in the
1001
1058
  // form body, the session set here, then on to a page of this World
1002
1059
  // a form or beacon from another site never opens or ends a session here (the console, on this host's
@@ -1056,6 +1113,29 @@ export class WorldDoors {
1056
1113
  if (kind === 'session' && !name && request.method === 'POST' && via === 'key' && scope !== 'read') return Response.json({ error: 'a key opens no browser session: open the World with its token, or from the platform' }, { status: 403 });
1057
1114
  if (kind === 'session' && !name && (request.method === 'POST' || request.method === 'DELETE')) return this.session(request, url, scope, via === 'key' ? this.presentedKey(request) : undefined);
1058
1115
  if (kind === 'keys') return this.keysDoor(request, name, scope, via, this.credential(request).session);
1116
+ // A COPY'S PAGES SHOWN HERE: `copies/<branch>/<vendor>/…`, a vendor's page in a branch of this World, read on this
1117
+ // World's origin under its caller's credential (the comparison frames its "before" beside "now" this way: the
1118
+ // branch's own session opens only on the branch's own pages). Only a copy as of an instant, which only a writer of
1119
+ // this World makes, so its pages run here with nothing a stranger wrote (a labelled or live branch, a pull request's
1120
+ // preview written by the app it tests, is shown at its own address). Read only; no cookie the copy sets is kept
1121
+ if (kind === 'copies' && name && verb) {
1122
+ const show = this.host.branches?.show;
1123
+ if (!show) return Response.json({ error: "this host shows a copy's pages only at the copy's own address" }, { status: 501 });
1124
+ const [org] = this.served.split('/');
1125
+ // the path after the vendor as it came, empty segments and a trailing slash kept (an object key may hold `//`)
1126
+ const full = `${org}/${name}`;
1127
+ // made by the World's token or a person's session: a key's copy is written by whoever holds the key, and its pages
1128
+ // would run here under the viewer's session
1129
+ const madeBy = this.keys().find((k) => k.branch === full && (!k.expiresAt || Date.parse(k.expiresAt) > Date.now()))?.createdBy?.via;
1130
+ if (madeBy !== 'token' && madeBy !== 'session') return Response.json({ error: `${full} was not made by this World's token or a person here: its pages are shown at its own address` }, { status: 403 });
1131
+ // the path after the vendor as it came (empty segments and a trailing slash kept: an object key may hold `//`)
1132
+ const exact = /^copies\/[^/]+\/[^/]+(?:\/(.*))?$/.exec(path);
1133
+ if (!exact) return Response.json({ error: 'copies/<branch>/<vendor>/<path>' }, { status: 404 });
1134
+ const answer = await show(full, request, verb, exact[1] ?? '', `/-/${this.served}/copies/${name}/${verb}`);
1135
+ if (answer.status < 200) return Response.json({ error: "a copy's pages are read, never upgraded" }, { status: 400 });
1136
+ const out = new Headers(answer.headers); out.delete('set-cookie');
1137
+ return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
1138
+ }
1059
1139
  if (scope === 'read' && request.method !== 'GET') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1060
1140
 
1061
1141
  if (kind === 'clock') return await this.clockDoor(request, name);
@@ -1549,7 +1629,7 @@ export class WorldDoors {
1549
1629
  const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
1550
1630
  this.keepKeys([...this.keys(), { id: keyId, name: 'a branch (being made)', scope: 'write', hash: keyHash(parentKey), createdAt: new Date(now).toISOString(), createdBy, ...(person ? { for: person } : {}), ...(expiresAt ? { expiresAt } : {}), ...(maker ? { madeWith: maker.id } : {}) }]);
1551
1631
  try {
1552
- const made = await branches.create(typeof instant === 'string' ? { instant: new Date(Date.parse(instant)).toISOString() } : { ...(body?.live === true ? { live: true } : {}), ...(typeof body?.label === 'string' ? { label: body.label } : {}) }, ttl, url.origin, parentKey);
1632
+ const made = await branches.create(typeof instant === 'string' ? { instant: new Date(Date.parse(instant)).toISOString() } : { ...(body?.live === true ? { live: true } : {}), ...(typeof body?.label === 'string' ? { label: body.label } : {}) }, ttl, url.origin, parentKey, maker ? maker.name : undefined);
1553
1633
  this.keepKeys(this.keys().map((k) => (k.id === keyId ? { ...k, name: `the branch ${made.name}`, branch: made.name, ...(made.expiresAt ? { expiresAt: made.expiresAt } : {}) } : k)));
1554
1634
  // a browser's session is answered the branch's read token only: a page views a branch, and the parent's
1555
1635
  // session removes it, so no write token ever reaches a page's script (or a frame beside it)
package/src/world-view.ts CHANGED
@@ -6,7 +6,7 @@ import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { loadWorldConfig } from './configs.ts';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { serveHttp, stateDirName } from '@volter/world-core/runtime';
9
- import { LocalBranches } from './local-branches.ts';
9
+ import { followParentPins, LocalBranches } from './local-branches.ts';
10
10
  import { mountWorld, type MountedWorld } from './served-world.ts';
11
11
  import { localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
12
12
 
@@ -138,6 +138,7 @@ export async function serveWorldView(name: string, opts: { /** the person signed
138
138
  await world.boot(url);
139
139
  // branches an earlier view made: mounted again (the sweep then removes those whose time ran out)
140
140
  for (const at of branchRoots(join(root, stateDirName(), 'branches'))) {
141
+ followParentPins(root, at);
141
142
  const b = await mountAt(at);
142
143
  await b.boot(url);
143
144
  worlds.set(b.served, b);