@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.
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/local-branches.d.ts +11 -0
- package/dist/src/local-branches.js +49 -6
- package/dist/src/served-world.d.ts +12 -2
- package/dist/src/served-world.js +57 -3
- package/dist/src/world-view.js +2 -1
- package/package.json +4 -4
- package/src/index.ts +1 -1
- package/src/local-branches.ts +45 -6
- package/src/served-world.ts +52 -5
- package/src/world-view.ts +2 -1
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -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)
|
package/dist/src/world-view.js
CHANGED
|
@@ -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.
|
|
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.
|
|
72
|
-
"@volter/world-console": "3.0.
|
|
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.
|
|
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';
|
package/src/local-branches.ts
CHANGED
|
@@ -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
|
-
|
|
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 */ }
|
package/src/served-world.ts
CHANGED
|
@@ -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
|
|
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);
|