@volter/world-runtime 3.0.12 → 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.
@@ -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. */
@@ -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';
@@ -1003,7 +1029,7 @@ export class WorldDoors {
1003
1029
  else
1004
1030
  headers.delete('cookie');
1005
1031
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
1006
- headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
1032
+ headers.set(TWIN_PREFIX_HEADER, shownAt ?? `/${this.served}/${vendor}`);
1007
1033
  headers.set('x-forwarded-host', url.host);
1008
1034
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1009
1035
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
@@ -1256,6 +1282,34 @@ export class WorldDoors {
1256
1282
  return this.session(request, url, scope, via === 'key' ? this.presentedKey(request) : undefined);
1257
1283
  if (kind === 'keys')
1258
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
+ }
1259
1313
  if (scope === 'read' && request.method !== 'GET')
1260
1314
  return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1261
1315
  if (kind === 'clock')
@@ -1896,7 +1950,7 @@ export class WorldDoors {
1896
1950
  const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
1897
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 } : {}) }]);
1898
1952
  try {
1899
- 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);
1900
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)));
1901
1955
  // a browser's session is answered the branch's read token only: a page views a branch, and the parent's
1902
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.12",
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.12",
72
- "@volter/world-console": "3.0.7"
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.12",
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"
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 */ }
@@ -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';
@@ -881,7 +905,7 @@ export class WorldDoors {
881
905
  const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_world=/.test(c));
882
906
  if (cookies.length) headers.set('cookie', cookies.join('; ')); else headers.delete('cookie');
883
907
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
884
- headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
908
+ headers.set(TWIN_PREFIX_HEADER, shownAt ?? `/${this.served}/${vendor}`);
885
909
  headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
886
910
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
887
911
  // origin on this machine (siteOfHost); a World without one shows none
@@ -1089,6 +1113,29 @@ export class WorldDoors {
1089
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 });
1090
1114
  if (kind === 'session' && !name && (request.method === 'POST' || request.method === 'DELETE')) return this.session(request, url, scope, via === 'key' ? this.presentedKey(request) : undefined);
1091
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
+ }
1092
1139
  if (scope === 'read' && request.method !== 'GET') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1093
1140
 
1094
1141
  if (kind === 'clock') return await this.clockDoor(request, name);
@@ -1582,7 +1629,7 @@ export class WorldDoors {
1582
1629
  const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
1583
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 } : {}) }]);
1584
1631
  try {
1585
- 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);
1586
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)));
1587
1634
  // a browser's session is answered the branch's read token only: a page views a branch, and the parent's
1588
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);