@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.
- package/dist/src/cli-commands.js +5 -1
- 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 +93 -4
- package/dist/src/world-view.js +2 -1
- package/package.json +4 -4
- package/src/cli-commands.ts +5 -1
- package/src/index.ts +1 -1
- package/src/local-branches.ts +45 -6
- package/src/served-world.ts +86 -6
- package/src/world-view.ts +2 -1
package/dist/src/cli-commands.js
CHANGED
|
@@ -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,
|
|
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';
|
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
|
@@ -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)
|
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/cli-commands.ts
CHANGED
|
@@ -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,
|
|
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';
|
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
|
@@ -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
|
|
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);
|