@volter/world-runtime 3.0.12 → 3.0.14
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 +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +1 -0
- package/dist/src/local-branches.d.ts +11 -0
- package/dist/src/local-branches.js +44 -6
- package/dist/src/served-world.d.ts +61 -3
- package/dist/src/served-world.js +199 -35
- package/dist/src/world-origins.d.ts +9 -0
- package/dist/src/world-origins.js +21 -0
- package/dist/src/world-view.js +11 -4
- package/package.json +4 -4
- package/src/index.ts +2 -2
- package/src/init.ts +1 -0
- package/src/local-branches.ts +42 -6
- package/src/served-world.ts +186 -39
- package/src/world-origins.ts +19 -0
- package/src/world-view.ts +10 -4
|
@@ -10,6 +10,27 @@ export function localWorldOrigin(served, port, base = LOCAL_ORIGIN_BASE) {
|
|
|
10
10
|
const label = worldOriginLabel(served);
|
|
11
11
|
return label ? `http://${label}.${base}:${port}` : null;
|
|
12
12
|
}
|
|
13
|
+
/** THE CONTENT PLANE's label beside a World's own: `<world>--<org>--pages`, where a browser sees the World's twins'
|
|
14
|
+
* pages apart from its doors and its session. A World of an org named `pages` (`<world>--pages`) is no content plane:
|
|
15
|
+
* its label names no World once the suffix is taken off, so it falls through to the World it is. */
|
|
16
|
+
export const PAGES_LABEL_SUFFIX = '--pages';
|
|
17
|
+
/** The content plane's origin for `served` on a host whose World origins are `<label>.<base>`, or null when its names
|
|
18
|
+
* make no label. */
|
|
19
|
+
export function pagesOriginOf(served, protocol, base, port) {
|
|
20
|
+
const label = worldOriginLabel(served);
|
|
21
|
+
return label && label.length + PAGES_LABEL_SUFFIX.length <= 63 ? `${protocol}//${label}${PAGES_LABEL_SUFFIX}.${base}${port ? `:${port}` : ''}` : null;
|
|
22
|
+
}
|
|
23
|
+
/** Which World's content plane a request's host names, or undefined for any other host. */
|
|
24
|
+
export function pagesOfHost(hostname, served, base = LOCAL_ORIGIN_BASE) {
|
|
25
|
+
const suffix = `${PAGES_LABEL_SUFFIX}.${base}`;
|
|
26
|
+
if (!hostname.endsWith(suffix))
|
|
27
|
+
return undefined;
|
|
28
|
+
const label = hostname.slice(0, -suffix.length);
|
|
29
|
+
for (const name of served)
|
|
30
|
+
if (worldOriginLabel(name) === label)
|
|
31
|
+
return name;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
13
34
|
/** Which World a request's host names: undefined for a host that is not a World's origin (the host's
|
|
14
35
|
* own, path-addressed), null for a World-origin-shaped host naming no World served here. */
|
|
15
36
|
export function worldOfHost(hostname, served, base = LOCAL_ORIGIN_BASE) {
|
package/dist/src/world-view.js
CHANGED
|
@@ -6,9 +6,9 @@ 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
|
-
import { localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
|
|
11
|
+
import { localWorldOrigin, pagesOfHost, pagesOriginOf, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
|
|
12
12
|
export const VIEW_CONSOLE_BASE = '/-/console';
|
|
13
13
|
/** The branch Worlds already under `dir` (`<dir>/<org>/<world>/.volter/world.json`). */
|
|
14
14
|
function branchRoots(dir) {
|
|
@@ -50,7 +50,9 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
50
50
|
const browserOrigin = origins ? (served) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
|
|
51
51
|
// sites are routed at each World's own *.localhost origin, never behind the proxy
|
|
52
52
|
const sitesOrigin = origins && !proxied ? (served) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
53
|
-
|
|
53
|
+
// the content plane beside each World's origin (`<world>--<org>--pages.localhost`): its twins' pages, apart from its doors
|
|
54
|
+
const pagesOrigin = origins && !proxied ? (served) => (port ? pagesOriginOf(served, 'http:', 'localhost', port) : null) : undefined;
|
|
55
|
+
const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
|
|
54
56
|
const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
55
57
|
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
56
58
|
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
@@ -92,7 +94,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
92
94
|
switching = false;
|
|
93
95
|
}
|
|
94
96
|
};
|
|
95
|
-
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
97
|
+
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
96
98
|
let world = await mountWorld(name, mountOpts);
|
|
97
99
|
base = world.served;
|
|
98
100
|
worlds.set(world.served, world);
|
|
@@ -116,6 +118,10 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
116
118
|
const site = origins && !proxied ? siteOfHost(at.hostname, worlds.keys()) : undefined;
|
|
117
119
|
if (site)
|
|
118
120
|
return worlds.get(site.world).site(request, site.site);
|
|
121
|
+
// a World's content plane serves that World's twins' pages and nothing else of this view's (no console)
|
|
122
|
+
const pagesFor = origins && !proxied ? pagesOfHost(at.hostname, worlds.keys()) : undefined;
|
|
123
|
+
if (pagesFor)
|
|
124
|
+
return path === '/-/ping' ? Response.json({ ok: true, worlds: [pagesFor] }) : worlds.get(pagesFor).handle(request);
|
|
119
125
|
const own = origins ? (proxied && at.host === proxied.host ? base : worldOfHost(at.hostname, worlds.keys())) : undefined;
|
|
120
126
|
if (own === null)
|
|
121
127
|
return Response.json({ error: `no world at ${at.host}` }, { status: 404 });
|
|
@@ -158,6 +164,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
158
164
|
await world.boot(url);
|
|
159
165
|
// branches an earlier view made: mounted again (the sweep then removes those whose time ran out)
|
|
160
166
|
for (const at of branchRoots(join(root, stateDirName(), 'branches'))) {
|
|
167
|
+
followParentPins(root, at);
|
|
161
168
|
const b = await mountAt(at);
|
|
162
169
|
await b.boot(url);
|
|
163
170
|
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.14",
|
|
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.14",
|
|
72
|
+
"@volter/world-console": "3.0.9"
|
|
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.14",
|
|
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,9 +166,9 @@ 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
|
-
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
|
|
171
|
+
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pagesOfHost, pagesOriginOf, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
|
|
172
172
|
export type { ViewConsole, WorldView } from './world-view.ts';
|
|
173
173
|
export type { LocalBranchesOptions } from './local-branches.ts';
|
|
174
174
|
export type { ServedWorld } from './served-world.ts';
|
package/src/init.ts
CHANGED
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,24 @@ 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: its own content plane's pass
|
|
97
|
+
pagesPass: async (name, scope, land, grant) => {
|
|
98
|
+
const w = this.o.worlds.get(name);
|
|
99
|
+
return w && this.record(w)?.from === parent ? w.pagesPass(scope, land, grant) : null;
|
|
100
|
+
},
|
|
67
101
|
};
|
|
68
102
|
}
|
|
69
103
|
|
|
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 }> {
|
|
104
|
+
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
105
|
this.making.set(parent, (this.making.get(parent) ?? 0) + 1);
|
|
72
|
-
try { return await this.make(parent, at, ttlSeconds, parentKey); } finally {
|
|
106
|
+
try { return await this.make(parent, at, ttlSeconds, parentKey, byKey); } finally {
|
|
73
107
|
const left = (this.making.get(parent) ?? 1) - 1;
|
|
74
108
|
if (left > 0) this.making.set(parent, left); else this.making.delete(parent);
|
|
75
109
|
}
|
|
76
110
|
}
|
|
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 }> {
|
|
111
|
+
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
112
|
const from = this.o.worlds.get(parent);
|
|
79
113
|
if (!from) throw new Error(`no world ${parent} here`);
|
|
80
114
|
const [org, world] = parent.split('/') as [string, string];
|
|
@@ -124,9 +158,11 @@ export class LocalBranches {
|
|
|
124
158
|
if (clock) { const file = clockFile(root, id); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${clock}\n`); }
|
|
125
159
|
const createdAt = new Date().toISOString();
|
|
126
160
|
const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
127
|
-
|
|
161
|
+
// what it was made as (a pull request's preview is labelled and may be live)
|
|
162
|
+
const made = { ...(at.instant ? { instant: at.instant } : {}), ...(at.label ? { label: at.label } : {}), ...(at.live ? { live: true } : {}) };
|
|
163
|
+
writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: Object.keys(made).length ? made : null, createdAt, expiresAt } satisfies BranchRecord, null, 2)}\n`);
|
|
128
164
|
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 };
|
|
165
|
+
return { name, token: byKey ? await keyOfBranch(mounted, origin, byKey) : mounted.token, readToken: mounted.readToken, expiresAt };
|
|
130
166
|
} catch (error) {
|
|
131
167
|
this.o.worlds.delete(name);
|
|
132
168
|
try { await mounted?.stop(); } catch { /* reported below */ }
|
package/src/served-world.ts
CHANGED
|
@@ -159,11 +159,17 @@ 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.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; 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
|
+
/** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
|
|
165
|
+
* session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
|
|
166
|
+
* is this machine's own page (attributed to the machine). */
|
|
167
|
+
type PagesGrant = { scope: 'read' | 'write'; session?: string; who?: string; framer?: string; local?: true; /** a parent World's session that asked
|
|
168
|
+
* for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
|
|
169
|
+
* cannot tell when it ends, so such a session lasts an hour */ asker?: string };
|
|
164
170
|
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
171
|
|
|
166
|
-
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */ sitesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
172
|
+
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */ sitesOrigin?: (served: string) => string | null; /** DoorHost.pagesOrigin */ pagesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
167
173
|
const worldRoot = resolve(opts.root ?? process.cwd());
|
|
168
174
|
const configRef = configRefFor(worldRoot, name);
|
|
169
175
|
if (loadWorldConfig(configRef, worldRoot).config.resources) {
|
|
@@ -192,6 +198,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
192
198
|
...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
|
|
193
199
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
|
|
194
200
|
...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin!(served) } : {}),
|
|
201
|
+
...(opts.pagesOrigin ? { pagesOrigin: () => opts.pagesOrigin!(served) } : {}),
|
|
195
202
|
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
196
203
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
197
204
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
|
|
@@ -215,6 +222,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
215
222
|
rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
|
|
216
223
|
handle: (request) => world.handle(request),
|
|
217
224
|
site: (request, host) => world.site(request, host),
|
|
225
|
+
pagesPass: (scope, land, grant) => world.pagesPass(scope, land, grant),
|
|
218
226
|
async boot(url) {
|
|
219
227
|
keep();
|
|
220
228
|
const port = Number(new URL(url).port) || 0;
|
|
@@ -343,6 +351,10 @@ export type DoorHost = {
|
|
|
343
351
|
* host gives each World one: its session lives only there (docs/contributing/architecture.md,
|
|
344
352
|
* "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
|
|
345
353
|
browserOrigin?: () => string | null;
|
|
354
|
+
/** THE CONTENT PLANE: the origin this World's twins' pages are shown at for a browser (`<world>--<org>--pages`), apart
|
|
355
|
+
* from the origin of its doors and its session (docs/contributing/architecture.md, "Viewing a World"): a twin's page
|
|
356
|
+
* runs there with a session that uses the twins and opens no door. Null, or absent, where Worlds share one origin. */
|
|
357
|
+
pagesOrigin?: () => string | null;
|
|
346
358
|
/** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
|
|
347
359
|
* (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
|
|
348
360
|
sitesOrigin?: () => string | null;
|
|
@@ -373,8 +385,12 @@ export type BranchRow = { name: string; from: string; at: { instant?: string; li
|
|
|
373
385
|
export type BranchDoors = {
|
|
374
386
|
list: () => Promise<BranchRow[]>;
|
|
375
387
|
/** `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
|
|
388
|
+
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),
|
|
389
|
+
* 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
390
|
remove: (name: string) => Promise<boolean>;
|
|
391
|
+
/** the address that opens a branch's content plane with a one-time pass at `scope`, landing on `land` (a path of the
|
|
392
|
+
* branch): how its parent's console frames the branch's pages beside its own (the comparison) */
|
|
393
|
+
pagesPass?: (name: string, scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => Promise<string | null>;
|
|
378
394
|
};
|
|
379
395
|
|
|
380
396
|
/** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
|
|
@@ -415,7 +431,133 @@ export class WorldDoors {
|
|
|
415
431
|
constructor(readonly name: string, readonly worldRoot: string, readonly configRef: string, readonly served: string, private readonly host: DoorHost, token: string, readToken: string) { this.token = token; this.readToken = readToken; }
|
|
416
432
|
/** New tokens; a request presenting the old ones is refused from the next call on. */
|
|
417
433
|
// new tokens end everything the old ones reached: every session and every named key
|
|
418
|
-
retoken(token: string, readToken: string): void { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); }
|
|
434
|
+
retoken(token: string, readToken: string): void { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); this.keepPagesSessions({}); this.pagePasses.clear(); }
|
|
435
|
+
|
|
436
|
+
/** THE CONTENT PLANE (DoorHost.pagesOrigin): a browser sees this World's twins' pages at an origin apart from its doors
|
|
437
|
+
* and its session, so a twin's page (another app's content, a pack's markup) runs with nothing that opens a door. A
|
|
438
|
+
* page there holds a session of its own, opened from a one-time pass the World's own origin gives its session or
|
|
439
|
+
* token: it uses the twins at its scope and nothing else. One per session that gave it a pass (every frame of a board
|
|
440
|
+
* shares it), kept in the World's store, ended with that session; framed only by the console that asked. */
|
|
441
|
+
private onPages(url: URL): boolean { const at = this.host.pagesOrigin?.() ?? null; return at !== null && url.host === new URL(at).host; }
|
|
442
|
+
private readonly pagePasses = new Map<string, PagesGrant & { until: number }>();
|
|
443
|
+
private pagesPath(): string { return join(this.worldRoot, stateDirName(), 'pages-sessions.json'); }
|
|
444
|
+
private pagesHeld: Record<string, PagesGrant & { until: number }> | null = null;
|
|
445
|
+
private pagesSessions(): Record<string, PagesGrant & { until: number }> {
|
|
446
|
+
if (this.pagesHeld === null) { try { this.pagesHeld = JSON.parse(getActiveWorldStore().read(this.pagesPath()) ?? '{}') as Record<string, PagesGrant & { until: number }>; } catch { this.pagesHeld = {}; } }
|
|
447
|
+
return this.pagesHeld;
|
|
448
|
+
}
|
|
449
|
+
private keepPagesSessions(all: Record<string, PagesGrant & { until: number }>): void {
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
this.pagesHeld = Object.fromEntries(Object.entries(all).filter(([, x]) => x.until > now).sort(([, a], [, b]) => b.until - a.until).slice(0, 2000));
|
|
452
|
+
getActiveWorldStore().write(this.pagesPath(), `${JSON.stringify(this.pagesHeld)}\n`, { secret: true });
|
|
453
|
+
// never committed, as a World's other sessions are not: a `.volter/.gitignore` written before this file existed names it
|
|
454
|
+
if (!this.pagesIgnored) {
|
|
455
|
+
this.pagesIgnored = true;
|
|
456
|
+
const ignore = join(this.worldRoot, stateDirName(), '.gitignore');
|
|
457
|
+
const held = getActiveWorldStore().read(ignore);
|
|
458
|
+
if (held !== null && !held.split('\n').includes('pages-sessions.json')) getActiveWorldStore().write(ignore, `${held.replace(/\n?$/, '\n')}pages-sessions.json\n`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
private pagesIgnored = false;
|
|
462
|
+
/** The origin of the World this one is a branch of, whose console frames its pages beside its own: none for a World
|
|
463
|
+
* that is no branch (its record, written by the host that made it). */
|
|
464
|
+
private parentOrigin(): string | null {
|
|
465
|
+
try { const from = (JSON.parse(getActiveWorldStore().read(join(this.worldRoot, stateDirName(), 'branch.json')) ?? 'null') as { from?: string } | null)?.from; return from ? this.host.originOf?.(from) ?? null : null; } catch { return null; }
|
|
466
|
+
}
|
|
467
|
+
/** Whether what gave a content session its pass still stands: the control session that asked (ended, signed out, or
|
|
468
|
+
* its shared link revoked, the content session ends with it), or this World's token (rotated, every one ends). */
|
|
469
|
+
private pagesParentLive(grant: PagesGrant): boolean { return grant.session === undefined || this.issued().session(grant.session) !== null; }
|
|
470
|
+
/** The address that opens this World's content plane for a minute's one-time pass and lands on `land` (a path of this
|
|
471
|
+
* World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
|
|
472
|
+
pagesPass(scope: 'read' | 'write', land: string, grant: Omit<PagesGrant, 'scope'> = {}): string | null {
|
|
473
|
+
const at = this.host.pagesOrigin?.() ?? null;
|
|
474
|
+
if (at === null) return null;
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
for (const [k, v] of this.pagePasses) if (v.until <= now) this.pagePasses.delete(k);
|
|
477
|
+
const askerOf = (g: Omit<PagesGrant, 'scope'>): string => g.session ?? g.asker ?? g.framer ?? 'token';
|
|
478
|
+
const asker = askerOf(grant);
|
|
479
|
+
if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000) return null;
|
|
480
|
+
const pass = mintToken('pass_');
|
|
481
|
+
this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
|
|
482
|
+
return `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}`;
|
|
483
|
+
}
|
|
484
|
+
private pagesCookie(url: URL): string { return url.protocol === 'https:' ? '__Host-volter_pages' : 'volter_pages'; }
|
|
485
|
+
private pageSession(request: Request, url: URL): ({ id: string } & PagesGrant) | null {
|
|
486
|
+
const id = new RegExp(`(?:^|;\\s*)${this.pagesCookie(url)}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
487
|
+
const held = id && Object.hasOwn(this.pagesSessions(), id) ? this.pagesSessions()[id]! : undefined;
|
|
488
|
+
if (!id || !held || held.until <= Date.now() || !this.pagesParentLive(held)) return null;
|
|
489
|
+
const { until: _until, ...grant } = held;
|
|
490
|
+
return { id, ...grant };
|
|
491
|
+
}
|
|
492
|
+
/** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
|
|
493
|
+
private async pagesPlane(request: Request, url: URL): Promise<Response> {
|
|
494
|
+
const doors = `/-/${this.served}/pages/`;
|
|
495
|
+
const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
|
|
496
|
+
const own = this.host.browserOrigin?.() ?? null;
|
|
497
|
+
if (door === 'open' && request.method === 'GET') {
|
|
498
|
+
// framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
|
|
499
|
+
// framed only by the console that asked
|
|
500
|
+
const consoles = [own, this.parentOrigin()].filter((x): x is string => Boolean(x)).join(' ') || "'none'";
|
|
501
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="${doors}open.js"></script></body>`,
|
|
502
|
+
{ 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 ${consoles}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
503
|
+
}
|
|
504
|
+
if (door === 'open.js' && request.method === 'GET') {
|
|
505
|
+
const script = `(() => {
|
|
506
|
+
const world = document.body.dataset.world;
|
|
507
|
+
const carried = new URLSearchParams(location.hash.slice(1));
|
|
508
|
+
history.replaceState(null, '', location.pathname);
|
|
509
|
+
const pass = carried.get('pass') || '';
|
|
510
|
+
const land = carried.get('land') || '';
|
|
511
|
+
// resolved as the browser will (encoded dots too), on this origin and under this World
|
|
512
|
+
let to = null;
|
|
513
|
+
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 {}
|
|
514
|
+
if (!pass || !to) { document.body.textContent = 'Nothing to open.'; return; }
|
|
515
|
+
fetch('/-/' + world + '/pages/session', { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
|
|
516
|
+
.then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
|
|
517
|
+
.catch(() => { document.body.textContent = 'This World did not answer.'; });
|
|
518
|
+
})();
|
|
519
|
+
`;
|
|
520
|
+
return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
521
|
+
}
|
|
522
|
+
if (door === 'session' && request.method === 'POST') {
|
|
523
|
+
// only this plane's own opening page asks (a browser sets the header; a page elsewhere cannot)
|
|
524
|
+
if (request.headers.get('sec-fetch-site') !== 'same-origin') return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
|
|
525
|
+
const pass = request.headers.get('x-volter-pages-pass') ?? '';
|
|
526
|
+
const given = this.pagePasses.get(pass);
|
|
527
|
+
this.pagePasses.delete(pass);
|
|
528
|
+
if (!given || given.until <= Date.now() || !this.pagesParentLive(given)) return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
|
|
529
|
+
const { until: _until, ...grant } = given;
|
|
530
|
+
// one content session per asker at a scope and framer: a board's frames opening together share it
|
|
531
|
+
const same = (x: PagesGrant): boolean => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who;
|
|
532
|
+
const now = Date.now();
|
|
533
|
+
const found = Object.entries(this.pagesSessions()).find(([, x]) => x.until > now && same(x))?.[0];
|
|
534
|
+
const id = found ?? mintToken('pgs_');
|
|
535
|
+
// twelve hours from its last opening while the session that asked stands; an hour where that cannot be known
|
|
536
|
+
this.keepPagesSessions({ ...this.pagesSessions(), [id]: { ...grant, until: now + (grant.session !== undefined || grant.asker === undefined ? 12 : 1) * 3_600_000 } });
|
|
537
|
+
return Response.json({ world: this.served, scope: grant.scope }, { headers: { 'set-cookie': `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned` } });
|
|
538
|
+
}
|
|
539
|
+
if (url.pathname.startsWith(`/${this.served}/`) && !url.pathname.startsWith(`/${this.served}/.well-known/`)) {
|
|
540
|
+
const answer = await this.wire(request, url, url.pathname.slice(`/${this.served}/`.length));
|
|
541
|
+
if (answer.status === 101) return answer;
|
|
542
|
+
const out = new Headers(answer.headers);
|
|
543
|
+
// framed by the console whose session asked, and by the page itself
|
|
544
|
+
const framer = this.pageSession(request, url)?.framer ?? own;
|
|
545
|
+
out.append('content-security-policy', `frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
|
|
546
|
+
// a twin's own sign-in kept in the frame a console shows it in, which may be another site (each *.localhost is):
|
|
547
|
+
// its cookies are partitioned by that console, as this plane's own session is
|
|
548
|
+
const cookies = answer.headers.getSetCookie();
|
|
549
|
+
if (cookies.length) {
|
|
550
|
+
out.delete('set-cookie');
|
|
551
|
+
for (const c of cookies) out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
|
|
552
|
+
}
|
|
553
|
+
return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
554
|
+
}
|
|
555
|
+
// a navigation elsewhere (a twin sending the person to its root, `/`) goes to the World's own origin, as it always did
|
|
556
|
+
if (own && request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '') && !url.pathname.startsWith('/-/')) {
|
|
557
|
+
return new Response(null, { status: 303, headers: { location: `${own}${url.pathname}${url.search}`, 'cache-control': 'no-store' } });
|
|
558
|
+
}
|
|
559
|
+
return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
|
|
560
|
+
}
|
|
419
561
|
|
|
420
562
|
/** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
|
|
421
563
|
* not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
|
|
@@ -608,6 +750,9 @@ export class WorldDoors {
|
|
|
608
750
|
};
|
|
609
751
|
}
|
|
610
752
|
private credential(request: Request): { scope: 'write' | 'read' | null; via: 'token' | 'key' | 'session' | null; token: string | null; /** the session's id, when the session opened it */ session?: string; /** who the session is */ who?: string } {
|
|
753
|
+
// on the content plane only its own session counts: a page there presents nothing that opens a door
|
|
754
|
+
const at = new URL(request.url);
|
|
755
|
+
if (this.onPages(at)) { const held = this.pageSession(request, at); return held ? { scope: held.scope, via: 'session', token: null, session: `pages:${held.id}`, ...(held.who ? { who: held.who } : {}) } : { scope: null, via: null, token: null }; }
|
|
611
756
|
const presented = presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER });
|
|
612
757
|
const name = this.sessionCookieName(new URL(request.url));
|
|
613
758
|
// the name is one of two fixed literals (sessionCookieName); the separator browsers use is "; "
|
|
@@ -653,7 +798,7 @@ export class WorldDoors {
|
|
|
653
798
|
const cred = this.credential(request);
|
|
654
799
|
if (cred.scope === null) return null;
|
|
655
800
|
// this machine's own page with no one signed in here is this machine; another session with no name is just that
|
|
656
|
-
if (cred.via === 'session') return cred.who ? `person:${cred.who}` : cred.session && this.sessions()[cred.session]?.local ? 'machine' : 'person';
|
|
801
|
+
if (cred.via === 'session') return cred.who ? `person:${cred.who}` : cred.session && (cred.session.startsWith('pages:') ? this.pagesSessions()[cred.session.slice(6)]?.local : this.sessions()[cred.session]?.local) ? 'machine' : 'person';
|
|
657
802
|
// a key acts as itself, for its person (docs/contributing/architecture.md, "Who acted is recorded")
|
|
658
803
|
if (cred.via === 'key') { const held = this.keyOf(cred.token); const person = held ? this.keyName(held) : undefined; return held ? `key:${held.name}${person ? ` for:${person}` : ''}` : 'key'; }
|
|
659
804
|
return cred.scope === 'read' ? 'token:read' : 'token';
|
|
@@ -768,6 +913,7 @@ export class WorldDoors {
|
|
|
768
913
|
const url = new URL(request.url);
|
|
769
914
|
const prefix = `/${this.served}/`;
|
|
770
915
|
if (url.pathname === '/-/ping') return Response.json({ ok: true, world: this.served, worlds: [this.served] });
|
|
916
|
+
if (this.onPages(url)) return await this.pagesPlane(request, url);
|
|
771
917
|
if (url.pathname.startsWith(`/-/${this.served}/`)) return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
|
|
772
918
|
// the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
|
|
773
919
|
// place under this World, and the token the injector carries to them — the presented one
|
|
@@ -845,15 +991,32 @@ export class WorldDoors {
|
|
|
845
991
|
const [vendor, ...tail] = rest.split('/');
|
|
846
992
|
const { scope, via } = this.credential(request);
|
|
847
993
|
const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
|
|
994
|
+
const browserAt = this.host.browserOrigin?.() ?? null;
|
|
995
|
+
if (scope === null && this.onPages(url) && browserAt && request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '')) {
|
|
996
|
+
return new Response(null, { status: 303, headers: { location: `${browserAt}${url.pathname}${url.search}`, 'cache-control': 'no-store' } });
|
|
997
|
+
}
|
|
848
998
|
if (scope === null && !manifestAsk) return Response.json({ error: 'token required' }, { status: 401 });
|
|
849
999
|
const foreign = this.crossOriginSession(request, via);
|
|
850
1000
|
if (foreign) return foreign;
|
|
851
1001
|
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
1002
|
+
// THE TWO PLANES: a browser's session never sees a twin's page on the World's own origin. Its navigation there goes to
|
|
1003
|
+
// the content plane with a pass (a frame or a tab alike); anything else its pages would send is refused. A page on the
|
|
1004
|
+
// content plane that has no session yet is sent back here to get one
|
|
1005
|
+
const navigation = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
1006
|
+
const pagesAt = this.host.pagesOrigin?.() ?? null;
|
|
1007
|
+
if (pagesAt !== null && !this.onPages(url) && via === 'session') {
|
|
1008
|
+
const asked = this.credential(request);
|
|
1009
|
+
const to = navigation ? this.pagesPass(scope!, `${url.pathname}${url.search}`, { ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin!()! } : {}), ...(asked.session && this.sessions()[asked.session]?.local ? { local: true as const } : {}) }) : null;
|
|
1010
|
+
if (to) return new Response(null, { status: 303, headers: { location: to, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
1011
|
+
return Response.json({ error: `${this.served}'s twins' pages are shown at ${pagesAt}` }, { status: 403 });
|
|
1012
|
+
}
|
|
852
1013
|
// A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
|
|
853
1014
|
// the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
|
|
854
1015
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
855
1016
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
856
1017
|
const link = this.link(vendor!);
|
|
1018
|
+
// a link spends the World's sealed vendor credential: never from the content plane, where any twin's markup runs
|
|
1019
|
+
if (link && this.onPages(url)) return Response.json({ error: `${vendor} is linked to the vendor itself: it is used through the World's own origin` }, { status: 404 });
|
|
857
1020
|
if (link) {
|
|
858
1021
|
if (scope === 'read' && !reads) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
859
1022
|
return await this.forward(request, url, tail.join('/'), vendor!, link.origin);
|
|
@@ -878,7 +1041,7 @@ export class WorldDoors {
|
|
|
878
1041
|
// that would write for its caller is refused too); at any other, its GET passes as it always has
|
|
879
1042
|
if (enforced) headers.set(READ_ONLY_HEADER, '1');
|
|
880
1043
|
// the browser's World session is the World's credential, never the twin's (a tunnel would hand it on)
|
|
881
|
-
const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?
|
|
1044
|
+
const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
|
|
882
1045
|
if (cookies.length) headers.set('cookie', cookies.join('; ')); else headers.delete('cookie');
|
|
883
1046
|
// the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
|
|
884
1047
|
headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
|
|
@@ -907,9 +1070,9 @@ export class WorldDoors {
|
|
|
907
1070
|
// above): caches keep them apart
|
|
908
1071
|
out.append('vary', 'Sec-Fetch-Dest');
|
|
909
1072
|
const setCookies = answer.headers.getSetCookie();
|
|
910
|
-
if (setCookies.some((c) => /^\s*(?:__Host-)?
|
|
1073
|
+
if (setCookies.some((c) => /^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))) {
|
|
911
1074
|
out.delete('set-cookie');
|
|
912
|
-
for (const c of setCookies) if (!/^\s*(?:__Host-)?
|
|
1075
|
+
for (const c of setCookies) if (!/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c)) out.append('set-cookie', c);
|
|
913
1076
|
}
|
|
914
1077
|
return new Response(answer.body, { status: answer.status, headers: out });
|
|
915
1078
|
}
|
|
@@ -1000,36 +1163,6 @@ export class WorldDoors {
|
|
|
1000
1163
|
// ending a browser's session needs no token: it only clears the caller's own cookie, which never
|
|
1001
1164
|
// reaches these /-/ paths, so a page holding just the session could not otherwise sign out
|
|
1002
1165
|
const sessionPath = path.replace(/\/+$/, '');
|
|
1003
|
-
// 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
|
|
1004
|
-
// of this World it names (`land`): how a page elsewhere links to this World's own pages (the console's comparison
|
|
1005
|
-
// opening a vendor's page in a copy of the World, read only). The token never reaches the server in a URL; its script
|
|
1006
|
-
// is a file of this origin, so the page keeps a strict policy. It is never framed: a session opened in another
|
|
1007
|
-
// site's frame is not this World's own page's
|
|
1008
|
-
if (sessionPath === 'session/hand-off' && request.method === 'GET') {
|
|
1009
|
-
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>`,
|
|
1010
|
-
{ 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' } });
|
|
1011
|
-
}
|
|
1012
|
-
if (sessionPath === 'session/hand-off.js' && request.method === 'GET') {
|
|
1013
|
-
const script = `(() => {
|
|
1014
|
-
const world = document.body.dataset.world;
|
|
1015
|
-
const carried = new URLSearchParams(location.hash.slice(1));
|
|
1016
|
-
history.replaceState(null, '', location.pathname);
|
|
1017
|
-
const token = carried.get('token') || '';
|
|
1018
|
-
const land = carried.get('land') || '';
|
|
1019
|
-
// resolved as the browser will (encoded dots too), on this origin and under this World
|
|
1020
|
-
let to = null;
|
|
1021
|
-
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 {}
|
|
1022
|
-
const open = () => fetch('/-/' + world + '/session', { method: 'POST', headers: { 'x-volter-token': token } });
|
|
1023
|
-
if (!token || !to) { document.body.textContent = 'Nothing to open.'; return; }
|
|
1024
|
-
// a session this browser already holds here is kept: a page elsewhere cannot replace, downgrade or fix it
|
|
1025
|
-
fetch('/-/' + world + '/session')
|
|
1026
|
-
.then((held) => (held.ok ? held : open()))
|
|
1027
|
-
.then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
|
|
1028
|
-
.catch(() => { document.body.textContent = 'This World did not answer.'; });
|
|
1029
|
-
})();
|
|
1030
|
-
`;
|
|
1031
|
-
return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1032
|
-
}
|
|
1033
1166
|
// a person signing in from a page (the console's form, posted to this origin): the token in the
|
|
1034
1167
|
// form body, the session set here, then on to a page of this World
|
|
1035
1168
|
// a form or beacon from another site never opens or ends a session here (the console, on this host's
|
|
@@ -1541,6 +1674,20 @@ export class WorldDoors {
|
|
|
1541
1674
|
try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
|
|
1542
1675
|
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
|
|
1543
1676
|
}
|
|
1677
|
+
// A BRANCH'S PAGES (`GET branches/<org>/<branch>/pages?land=<path>`): a navigation to the branch's own content plane
|
|
1678
|
+
// with a one-time read pass, for a person or the World's token: how the console frames a branch's page beside this
|
|
1679
|
+
// World's (its comparison); the branch's pages never run on this World's origin
|
|
1680
|
+
if (rest.length === 3 && rest[2] === 'pages' && request.method === 'GET') {
|
|
1681
|
+
if (via !== 'session' && via !== 'token') return Response.json({ error: "a branch's pages open for a person or the World's token" }, { status: 403 });
|
|
1682
|
+
const name = `${decodeURIComponent(rest[0]!)}/${decodeURIComponent(rest[1]!)}`;
|
|
1683
|
+
if (!(await branches.list()).some((b) => b.name === name)) return Response.json({ error: `no branch ${name} of ${this.served}` }, { status: 404 });
|
|
1684
|
+
const land = url.searchParams.get('land') ?? `/${name}/`;
|
|
1685
|
+
const asked = this.credential(request);
|
|
1686
|
+
const mark = asked.session ? createHash('sha256').update(`${this.served}\0${asked.session}`).digest('base64url').slice(0, 22) : 'token';
|
|
1687
|
+
const to = branches.pagesPass ? await branches.pagesPass(name, 'read', land, { asker: mark, ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin!()! } : {}) }) : null;
|
|
1688
|
+
if (!to) return Response.json({ error: `this host shows ${name}'s pages only at its own address` }, { status: 501 });
|
|
1689
|
+
return new Response(null, { status: 303, headers: { location: to, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
1690
|
+
}
|
|
1544
1691
|
// SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
|
|
1545
1692
|
if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
|
|
1546
1693
|
if (!this.host.serveBranch) return Response.json({ error: `this host serves ${this.served} on one branch` }, { status: 404 });
|
|
@@ -1582,7 +1729,7 @@ export class WorldDoors {
|
|
|
1582
1729
|
const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
|
|
1583
1730
|
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
1731
|
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);
|
|
1732
|
+
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
1733
|
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
1734
|
// a browser's session is answered the branch's read token only: a page views a branch, and the parent's
|
|
1588
1735
|
// session removes it, so no write token ever reaches a page's script (or a frame beside it)
|
package/src/world-origins.ts
CHANGED
|
@@ -13,6 +13,25 @@ export function localWorldOrigin(served: string, port: number, base = LOCAL_ORIG
|
|
|
13
13
|
return label ? `http://${label}.${base}:${port}` : null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** THE CONTENT PLANE's label beside a World's own: `<world>--<org>--pages`, where a browser sees the World's twins'
|
|
17
|
+
* pages apart from its doors and its session. A World of an org named `pages` (`<world>--pages`) is no content plane:
|
|
18
|
+
* its label names no World once the suffix is taken off, so it falls through to the World it is. */
|
|
19
|
+
export const PAGES_LABEL_SUFFIX = '--pages';
|
|
20
|
+
/** The content plane's origin for `served` on a host whose World origins are `<label>.<base>`, or null when its names
|
|
21
|
+
* make no label. */
|
|
22
|
+
export function pagesOriginOf(served: string, protocol: string, base: string, port?: number | string): string | null {
|
|
23
|
+
const label = worldOriginLabel(served);
|
|
24
|
+
return label && label.length + PAGES_LABEL_SUFFIX.length <= 63 ? `${protocol}//${label}${PAGES_LABEL_SUFFIX}.${base}${port ? `:${port}` : ''}` : null;
|
|
25
|
+
}
|
|
26
|
+
/** Which World's content plane a request's host names, or undefined for any other host. */
|
|
27
|
+
export function pagesOfHost(hostname: string, served: Iterable<string>, base = LOCAL_ORIGIN_BASE): string | undefined {
|
|
28
|
+
const suffix = `${PAGES_LABEL_SUFFIX}.${base}`;
|
|
29
|
+
if (!hostname.endsWith(suffix)) return undefined;
|
|
30
|
+
const label = hostname.slice(0, -suffix.length);
|
|
31
|
+
for (const name of served) if (worldOriginLabel(name) === label) return name;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
/** Which World a request's host names: undefined for a host that is not a World's origin (the host's
|
|
17
36
|
* own, path-addressed), null for a World-origin-shaped host naming no World served here. */
|
|
18
37
|
export function worldOfHost(hostname: string, served: Iterable<string>, base = LOCAL_ORIGIN_BASE): string | null | undefined {
|