@volter/world-host 2.0.1 → 2.0.2
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.js +6 -3
- package/dist/src/twins-host.d.ts +13 -1
- package/dist/src/twins-host.js +134 -14
- package/package.json +6 -5
- package/src/cli.ts +6 -3
- package/src/twins-host.test.ts +70 -0
- package/src/twins-host.ts +104 -17
package/dist/src/cli.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// volter-host — worlds under one URL (see twins-host.ts).
|
|
3
3
|
//
|
|
4
|
-
// volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>]
|
|
4
|
+
// volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>] [--trust <platform origin>]… [--world-origins <domain>]
|
|
5
5
|
//
|
|
6
6
|
// `<dir>/<org>/<world>/` is a bare world (`volter world init --bare <org>/<world>`). Each world's
|
|
7
7
|
// token is in its own `.volter/token`; each world's serve record names this host's URL. The admin
|
|
8
8
|
// token (`<dir>/.volter-host/admin`) opens the host's own doors — `/-/worlds` — and is printed once.
|
|
9
|
+
// Each `--trust` names a platform whose passes open these Worlds for a person (its keys at /.well-known/jwks.json).
|
|
10
|
+
// `--world-origins <domain>` gives each World an origin of its own under it (`<world>--<org>.<domain>`), for a host reached at --url.
|
|
9
11
|
import { createWorldHost } from "./twins-host.js";
|
|
10
12
|
const cmd = process.argv[2];
|
|
11
13
|
const rest = process.argv.slice(3);
|
|
12
14
|
const value = (flag) => { const i = rest.indexOf(flag); return i >= 0 ? rest[i + 1] : undefined; };
|
|
15
|
+
const values = (flag) => rest.flatMap((a, i) => (a === flag && rest[i + 1] ? [rest[i + 1]] : []));
|
|
13
16
|
if (cmd === 'serve') {
|
|
14
17
|
const dir = value('--dir');
|
|
15
18
|
if (!dir) {
|
|
16
|
-
process.stderr.write('volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>]\n');
|
|
19
|
+
process.stderr.write('volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>] [--trust <platform origin>]… [--world-origins <domain>]\n');
|
|
17
20
|
process.exit(2);
|
|
18
21
|
}
|
|
19
22
|
// the console mounts when it is installed beside the host; a host without it serves the doors alone
|
|
@@ -26,7 +29,7 @@ if (cmd === 'serve') {
|
|
|
26
29
|
return undefined;
|
|
27
30
|
}
|
|
28
31
|
})();
|
|
29
|
-
const host = await createWorldHost({ dir, ...(value('--host') ? { host: value('--host') } : {}), ...(value('--port') ? { port: Number(value('--port')) } : {}), ...(value('--url') ? { advertise: value('--url') } : {}), ...(value('--console-port') ? { consolePort: Number(value('--console-port')) } : {}), ...(value('--console-url') ? { consoleAdvertise: value('--console-url') } : {}), ...(console ? { console } : {}), announce: (line) => process.stdout.write(`${line}\n`) });
|
|
32
|
+
const host = await createWorldHost({ dir, ...(value('--host') ? { host: value('--host') } : {}), ...(value('--port') ? { port: Number(value('--port')) } : {}), ...(value('--url') ? { advertise: value('--url') } : {}), ...(value('--console-port') ? { consolePort: Number(value('--console-port')) } : {}), ...(value('--console-url') ? { consoleAdvertise: value('--console-url') } : {}), ...(console ? { console } : {}), ...(rest.includes('--no-origins') ? { origins: false } : {}), ...(values('--trust').length ? { trust: values('--trust') } : {}), ...(value('--world-origins') ? { worldOrigins: value('--world-origins') } : {}), announce: (line) => process.stdout.write(`${line}\n`) });
|
|
30
33
|
process.stdout.write(`host ready ${host.url} ${host.worlds.length} world${host.worlds.length === 1 ? '' : 's'}\nadmin token ${host.adminToken}\n${host.console ? `console ${host.console}\n` : ''}`);
|
|
31
34
|
const shutdown = async () => { await host.stop(); process.exit(0); };
|
|
32
35
|
process.on('SIGTERM', shutdown);
|
package/dist/src/twins-host.d.ts
CHANGED
|
@@ -18,6 +18,16 @@ export type WorldHostOptions = {
|
|
|
18
18
|
* 0.0.0.0 behind a port map, a host behind a proxy. Every world's `base`, its serve record and the
|
|
19
19
|
* printed URLs use it; without it the bind address stands (loopback runs). */
|
|
20
20
|
advertise?: string;
|
|
21
|
+
/** each World a browser steps into at an origin of its own, `<world>--<org>.localhost` (on by default
|
|
22
|
+
* on a loopback host that is not advertised under another URL) */
|
|
23
|
+
origins?: boolean;
|
|
24
|
+
/** The platforms (their origins) whose passes open this host's Worlds for a person: each publishes its keys at
|
|
25
|
+
* `/.well-known/jwks.json` (docs/contributing/architecture.md, "The hosted product"). None, and only tokens do. */
|
|
26
|
+
trust?: string[];
|
|
27
|
+
/** A host reached under its own domain (`advertise`): each World at `<world>--<org>.<worldOrigins>` (a wildcard name
|
|
28
|
+
* and certificate pointing here), the origin its browser session and a platform's passes live at. Loopback hosts
|
|
29
|
+
* have theirs under `localhost` already. */
|
|
30
|
+
worldOrigins?: string;
|
|
21
31
|
};
|
|
22
32
|
export { CONSOLE_BASE, type ConsoleMount };
|
|
23
33
|
export type WorldHostHandle = {
|
|
@@ -36,7 +46,9 @@ export type WorldInventoryRow = {
|
|
|
36
46
|
org: string;
|
|
37
47
|
world: string;
|
|
38
48
|
name: string;
|
|
39
|
-
base: string;
|
|
49
|
+
base: string; /** the World's own origin, where a browser opens it */
|
|
50
|
+
origin?: string; /** the org that owns it, as the platform that made or claimed it names the org (architecture, "Every World has an owner from its making") */
|
|
51
|
+
owner?: string;
|
|
40
52
|
token: string;
|
|
41
53
|
readToken: string;
|
|
42
54
|
twins: Array<{
|
package/dist/src/twins-host.js
CHANGED
|
@@ -15,11 +15,29 @@
|
|
|
15
15
|
// Nothing of the vendor lives here — no arms, no keys, no folds: the host is the shell; the kernel
|
|
16
16
|
// serves. The v1 host (namespaces, links, the push flip, the R2 shell) left with v1.
|
|
17
17
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { assertStateRemovable, serveHttp, withAncestryLock, withStateRemoval } from '@volter/world-core';
|
|
18
|
+
import { assertStateRemovable, serveHttp, stateDirName, withAncestryLock, withStateRemoval } from '@volter/world-core';
|
|
19
19
|
import { dirname, join, resolve } from 'node:path';
|
|
20
|
-
import { assertWorldStateRemovable, CONSOLE_BASE, consoleRedirect, initWorld, loadWorldConfig, mountWorld, serveConsoleApart, TOKEN_HEADER } from '@volter/world-runtime';
|
|
20
|
+
import { assertWorldStateRemovable, resolveCatalog, CONSOLE_BASE, consoleRedirect, initWorld, LocalBranches, loadWorldConfig, localWorldOrigin, mountWorld, pathOfWorld, worldOriginLabel, serveConsoleApart, worldDoorUnderConsole, TOKEN_HEADER, worldOfHost } from '@volter/world-runtime';
|
|
21
|
+
import { issuerKeys, trustableIssuer } from '@volter/world-access';
|
|
21
22
|
export { CONSOLE_BASE };
|
|
22
23
|
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
24
|
+
/** Public suffixes of two labels, under which a registrable domain has three (the common ones: a site-level warning, not a PSL). */
|
|
25
|
+
const MULTI_LABEL_SUFFIXES = new Set(['co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'com.au', 'net.au', 'org.au', 'co.nz', 'org.nz', 'co.jp', 'ne.jp', 'or.jp', 'co.in', 'co.za', 'com.br', 'com.cn', 'com.mx', 'com.sg', 'com.tr', 'co.kr', 'com.tw', 'github.io', 'gitlab.io', 'pages.dev', 'workers.dev', 'vercel.app', 'netlify.app', 'herokuapp.com', 'fly.dev', 'onrender.com', 'web.app', 'firebaseapp.com', 'azurewebsites.net', 'cloudfront.net', 'amazonaws.com', 'trycloudflare.com', 'ngrok.app', 'ngrok.io']);
|
|
26
|
+
/** An owner as a platform names an org: its id, letters, digits, `_` and `-`. */
|
|
27
|
+
const OWNER = /^[A-Za-z0-9_-]{1,80}$/;
|
|
28
|
+
/** The owner recorded beside a World (`.volter/owner`), or null. */
|
|
29
|
+
const ownerOf = (root) => { try {
|
|
30
|
+
const o = readFileSync(join(root, stateDirName(), 'owner'), 'utf8').trim();
|
|
31
|
+
return OWNER.test(o) ? o : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
} };
|
|
36
|
+
const recordOwner = (root, owner) => {
|
|
37
|
+
mkdirSync(join(root, stateDirName()), { recursive: true });
|
|
38
|
+
writeFileSync(join(root, stateDirName(), 'owner'), `${owner}
|
|
39
|
+
`);
|
|
40
|
+
};
|
|
23
41
|
/** The bare worlds under a directory: `<dir>/<org>/<world>/.volter/world.json`. */
|
|
24
42
|
export function worldsUnder(dir) {
|
|
25
43
|
const out = [];
|
|
@@ -59,11 +77,57 @@ export async function createWorldHost(options) {
|
|
|
59
77
|
if (found.length === 0)
|
|
60
78
|
throw new Error(`no worlds under ${dir} — a world is \`<dir>/<org>/<world>/\` with \`.volter/world.json\` (\`volter world init --bare <org>/<world>\` makes one)`);
|
|
61
79
|
const adminToken = adminTokenFor(dir);
|
|
80
|
+
const byServed = new Map();
|
|
81
|
+
let loopback = '';
|
|
82
|
+
let port = 0;
|
|
83
|
+
const bound = options.host ?? '127.0.0.1';
|
|
84
|
+
const originBase = options.worldOrigins?.replace(/^\.+|\.+$/g, '') || undefined;
|
|
85
|
+
const origins = options.origins ?? (originBase !== undefined || (!options.advertise && (bound === '127.0.0.1' || bound === 'localhost' || bound === '::1')));
|
|
86
|
+
// off by default (an advertised URL, or a bind a browser cannot reach as *.localhost): say so, since every World
|
|
87
|
+
// then shares one origin and a browser session there is only path-scoped (an explicit --no-origins is a choice)
|
|
88
|
+
if (options.origins === undefined && !origins)
|
|
89
|
+
options.announce?.(`origins off (${options.advertise ? `advertised at ${options.advertise}` : `bound to ${bound}`}): every World shares one origin, where a browser session is only path-scoped; serve together only Worlds whose pages you trust, or bind loopback without --url for an origin per World`);
|
|
90
|
+
// under a domain of its own: the advertised URL's scheme and port, each World a label under the domain
|
|
91
|
+
const advertised = options.advertise ? new URL(options.advertise) : null;
|
|
92
|
+
const loopbackName = (host) => host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1' || host === '[::1]';
|
|
93
|
+
// a host reached over plain http on a public name: its Worlds' session cookies cannot be `__Host-`, and travel in clear
|
|
94
|
+
if (advertised?.protocol === 'http:' && !loopbackName(advertised.hostname))
|
|
95
|
+
throw new Error(`--url ${options.advertise}: a host is reached over https (or http on this machine's loopback)`);
|
|
96
|
+
// over plain http a World's session cookie cannot be `__Host-`, so sibling Worlds could toss it, and it travels in clear
|
|
97
|
+
if (originBase !== undefined && advertised?.protocol === 'http:' && !loopbackName(originBase))
|
|
98
|
+
throw new Error(`--world-origins ${originBase}: Worlds get origins of their own over https (or http on this machine's loopback); --url is ${options.advertise}`);
|
|
99
|
+
// a World's pages on the same registrable domain as a platform this host trusts are same-site with it: say so (the
|
|
100
|
+
// platform refuses a same-site page's changes, but a domain of the Worlds' own is the layout that needs no such care)
|
|
101
|
+
// the registrable domain: the last two labels, three under a public suffix of two (co.uk, com.au, github.io…); when
|
|
102
|
+
// unsure the check errs toward warning (a longer shared suffix is still compared)
|
|
103
|
+
const siteOf = (host) => { const labels = host.toLowerCase().split('.'); const two = labels.slice(-2).join('.'); return labels.slice(MULTI_LABEL_SUFFIXES.has(two) ? -3 : -2).join('.'); };
|
|
104
|
+
if (originBase !== undefined)
|
|
105
|
+
for (const t of options.trust ?? []) {
|
|
106
|
+
try {
|
|
107
|
+
const h = new URL(t).hostname;
|
|
108
|
+
if (!loopbackName(h) && siteOf(h) === siteOf(originBase))
|
|
109
|
+
options.announce?.(`origins ${originBase} shares a site with the platform ${t}: give the Worlds a registrable domain of their own (as github.com uses githubusercontent.com)`);
|
|
110
|
+
}
|
|
111
|
+
catch { /* the trust check below says why */ }
|
|
112
|
+
}
|
|
113
|
+
const browserOrigin = !origins ? undefined : originBase
|
|
114
|
+
? (served) => { const label = worldOriginLabel(served); return label ? `${advertised?.protocol ?? 'https:'}//${label}.${originBase}${advertised?.port ? `:${advertised.port}` : ''}` : null; }
|
|
115
|
+
: (served) => (port ? localWorldOrigin(served, port) : null);
|
|
116
|
+
// one key reader per trusted platform, shared by every World here (each caches its platform's key set)
|
|
117
|
+
// https only, or http on this machine: keys read over plain http elsewhere could be swapped, and a swapped key forges passes
|
|
118
|
+
const passIssuers = (options.trust ?? []).map((origin) => {
|
|
119
|
+
const issuer = trustableIssuer(origin);
|
|
120
|
+
if (!issuer)
|
|
121
|
+
throw new Error(`--trust ${origin}: a platform is trusted over https (or http on this machine's loopback)`);
|
|
122
|
+
return { issuer, keys: issuerKeys(issuer) };
|
|
123
|
+
});
|
|
124
|
+
// a World's branches ("as of" views) live beside it under the host's directory, and reach their
|
|
125
|
+
// parent through this host's own listener
|
|
126
|
+
const branches = new LocalBranches({ dir, origin: () => loopback, worlds: byServed, mount: (root) => mount({ name: '', root }), ...(options.announce ? { announce: options.announce } : {}) });
|
|
62
127
|
const mount = async (w) => {
|
|
63
128
|
const config = loadWorldConfig(join(w.root, '.volter', 'world.json'), w.root).config;
|
|
64
|
-
return mountWorld(config.id, { root: w.root });
|
|
129
|
+
return mountWorld(config.id, { root: w.root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(passIssuers.length ? { passIssuers } : {}) });
|
|
65
130
|
};
|
|
66
|
-
const byServed = new Map();
|
|
67
131
|
for (const w of found)
|
|
68
132
|
byServed.set(w.name, await mount(w));
|
|
69
133
|
const host = options.host ?? '127.0.0.1';
|
|
@@ -78,7 +142,11 @@ export async function createWorldHost(options) {
|
|
|
78
142
|
const status = res.ok ? (await res.json()) : {};
|
|
79
143
|
twins.push({ vendor, protocol: status.protocol ?? null, root: status.root ?? null });
|
|
80
144
|
}
|
|
81
|
-
|
|
145
|
+
// `origin`: where a browser steps into this World (its page, its session), when it has an origin of its own
|
|
146
|
+
// only where this host serves the World's page there (the console): a pass sent anywhere else lands on nothing
|
|
147
|
+
const origin = options.console ? browserOrigin?.(m.served) ?? null : null;
|
|
148
|
+
const owner = ownerOf(m.root);
|
|
149
|
+
return { org, world, name: m.served, base: `${url}/${m.served}`, ...(origin ? { origin } : {}), ...(owner ? { owner } : {}), token: m.token, readToken: m.readToken, twins };
|
|
82
150
|
};
|
|
83
151
|
const checkAncestry = (path) => {
|
|
84
152
|
for (const world of worldsUnder(dir))
|
|
@@ -88,7 +156,16 @@ export async function createWorldHost(options) {
|
|
|
88
156
|
const admin = async (request, path) => {
|
|
89
157
|
if (request.headers.get(TOKEN_HEADER) !== adminToken)
|
|
90
158
|
return json({ error: 'the host\'s doors open to the admin token' }, 401);
|
|
91
|
-
|
|
159
|
+
// the twins this host can make a World with: those installed beside its Worlds (a platform's picker offers these)
|
|
160
|
+
if (path === '/-/vendors' && request.method === 'GET') {
|
|
161
|
+
let vendors = [];
|
|
162
|
+
try {
|
|
163
|
+
vendors = resolveCatalog(dir).vendors();
|
|
164
|
+
}
|
|
165
|
+
catch { /* none installed */ }
|
|
166
|
+
return json({ vendors });
|
|
167
|
+
}
|
|
168
|
+
const m = /^\/-\/worlds(?:\/([^/]+)\/([^/]+)(?:\/(rotate|owner))?)?$/.exec(path);
|
|
92
169
|
if (!m)
|
|
93
170
|
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
94
171
|
const [, org, world, verb] = m;
|
|
@@ -105,8 +182,10 @@ export async function createWorldHost(options) {
|
|
|
105
182
|
body = (await request.json());
|
|
106
183
|
}
|
|
107
184
|
catch {
|
|
108
|
-
return json({ error: 'provision: { org, world, vendors }' }, 400);
|
|
185
|
+
return json({ error: 'provision: { org, world, vendors, owner? }' }, 400);
|
|
109
186
|
}
|
|
187
|
+
if (body.owner !== undefined && (typeof body.owner !== 'string' || !OWNER.test(body.owner)))
|
|
188
|
+
return json({ error: 'owner: the owning org as its platform names it (letters, digits, _ and -)' }, 400);
|
|
110
189
|
const vendors = Array.isArray(body.vendors) ? body.vendors.filter((v) => typeof v === 'string' && NAME.test(v)) : [];
|
|
111
190
|
if (!body.org || !body.world || !NAME.test(body.org) || !NAME.test(body.world) || vendors.length === 0)
|
|
112
191
|
return json({ error: 'provision: { org, world, vendors: [vendor, …] } — names are letters, digits, . _ -' }, 400);
|
|
@@ -120,6 +199,8 @@ export async function createWorldHost(options) {
|
|
|
120
199
|
const result = initWorld(body.world, root, { root, vendors, bare: name });
|
|
121
200
|
if (!result.ok)
|
|
122
201
|
throw new Error(`init refused: ${JSON.stringify(result.plan)}`);
|
|
202
|
+
if (body.owner)
|
|
203
|
+
recordOwner(root, body.owner);
|
|
123
204
|
const mounted = await mount({ name, root });
|
|
124
205
|
acquired = mounted;
|
|
125
206
|
await mounted.boot(url);
|
|
@@ -150,7 +231,26 @@ export async function createWorldHost(options) {
|
|
|
150
231
|
return json({ error: `no world ${name} here` }, 404);
|
|
151
232
|
if (verb === 'rotate' && request.method === 'POST')
|
|
152
233
|
return json({ name, ...mounted.rotate() });
|
|
234
|
+
// a World made before its platform is claimed once: its owner is recorded then, and never silently replaced
|
|
235
|
+
if (verb === 'owner' && request.method === 'PUT') {
|
|
236
|
+
let body;
|
|
237
|
+
try {
|
|
238
|
+
body = (await request.json());
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return json({ error: 'owner: { owner }' }, 400);
|
|
242
|
+
}
|
|
243
|
+
if (typeof body.owner !== 'string' || !OWNER.test(body.owner))
|
|
244
|
+
return json({ error: 'owner: { owner } — the owning org as its platform names it' }, 400);
|
|
245
|
+
const held = ownerOf(mounted.root);
|
|
246
|
+
if (held && held !== body.owner)
|
|
247
|
+
return json({ error: `${name} is owned by ${held}` }, 409);
|
|
248
|
+
recordOwner(mounted.root, body.owner);
|
|
249
|
+
return json({ name, owner: body.owner });
|
|
250
|
+
}
|
|
153
251
|
if (!verb && request.method === 'DELETE') {
|
|
252
|
+
if (branches.hasBranches(name))
|
|
253
|
+
return json({ error: `${name} has branches: ${branches.list(name).map((b) => b.name).join(', ')}` }, 409);
|
|
154
254
|
try {
|
|
155
255
|
withAncestryLock(() => checkAncestry(mounted.root));
|
|
156
256
|
await mounted.stop();
|
|
@@ -171,13 +271,30 @@ export async function createWorldHost(options) {
|
|
|
171
271
|
// here misattributes requests and can recreate a removed World after its response.
|
|
172
272
|
twinRequestJournal: false,
|
|
173
273
|
async fetch(request) {
|
|
174
|
-
const
|
|
175
|
-
const
|
|
274
|
+
const at = new URL(request.url);
|
|
275
|
+
const path = at.pathname;
|
|
276
|
+
// a World's own origin serves that World (and the ping and the console) and no other, nor the admin doors
|
|
277
|
+
const own = origins ? worldOfHost(at.hostname, byServed.keys(), originBase) : undefined;
|
|
278
|
+
if (own === null)
|
|
279
|
+
return json({ error: `no world at ${at.host}` }, 404);
|
|
280
|
+
if (own && !pathOfWorld(path, own, CONSOLE_BASE))
|
|
281
|
+
return json({ error: `this origin serves ${own} only` }, 404);
|
|
282
|
+
// the console's pages of a World live at the World's own origin, beside its session and its mirrors
|
|
283
|
+
// (the console's home, the list of Worlds, lives at the console's own origin: consoleRedirect below)
|
|
284
|
+
// a World named console/<world> keeps its doors (/-/console/<world>/…): they are never the console's pages
|
|
285
|
+
const worldDoor = worldDoorUnderConsole(path, (n) => byServed.has(n), CONSOLE_BASE);
|
|
286
|
+
if (own && options.console && !worldDoor && (path === CONSOLE_BASE || path.startsWith(`${CONSOLE_BASE}/`))) {
|
|
287
|
+
const answered = await options.console.handle(request, CONSOLE_BASE, { worlds: at.origin, worldOrigin: true });
|
|
288
|
+
if (answered)
|
|
289
|
+
return answered;
|
|
290
|
+
}
|
|
291
|
+
const moved = worldDoor ? null : consoleRedirect(request, consoleUrl ? consoleUrl.slice(0, -`${CONSOLE_BASE}/`.length) : null);
|
|
176
292
|
if (moved)
|
|
177
293
|
return moved;
|
|
294
|
+
// on a World's own origin the ping names that World only (a sibling's name is none of its business)
|
|
178
295
|
if (path === '/-/ping')
|
|
179
|
-
return json({ ok: true, worlds: [...byServed.keys()], ...(consoleUrl ? { console: consoleUrl } : {}) });
|
|
180
|
-
if (path === '/-/worlds' || path.startsWith('/-/worlds/'))
|
|
296
|
+
return json({ ok: true, worlds: own ? [own] : [...byServed.keys()], ...(consoleUrl ? { console: consoleUrl } : {}) });
|
|
297
|
+
if (path === '/-/worlds' || path.startsWith('/-/worlds/') || path === '/-/vendors')
|
|
181
298
|
return admin(request, path);
|
|
182
299
|
const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
|
|
183
300
|
const world = m ? byServed.get(`${m[1]}/${m[2]}`) : undefined;
|
|
@@ -187,6 +304,8 @@ export async function createWorldHost(options) {
|
|
|
187
304
|
},
|
|
188
305
|
});
|
|
189
306
|
url = options.advertise ? options.advertise.replace(/\/+$/, '') : `http://${host}:${server.port}`;
|
|
307
|
+
port = server.port ?? 0;
|
|
308
|
+
loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
|
|
190
309
|
let apart = null;
|
|
191
310
|
try {
|
|
192
311
|
for (const m of byServed.values()) {
|
|
@@ -195,8 +314,7 @@ export async function createWorldHost(options) {
|
|
|
195
314
|
}
|
|
196
315
|
if (options.console) {
|
|
197
316
|
// the console reaches the doors at this host's own address; the pages it serves name the advertised one
|
|
198
|
-
const
|
|
199
|
-
const serveApart = (port) => serveConsoleApart(options.console, { upstream: internal, worlds: url, hostname: host, port });
|
|
317
|
+
const serveApart = (port) => serveConsoleApart(options.console, { upstream: loopback, worlds: url, hostname: host, port });
|
|
200
318
|
if (options.consolePort !== undefined)
|
|
201
319
|
apart = await serveApart(options.consolePort);
|
|
202
320
|
else {
|
|
@@ -211,15 +329,17 @@ export async function createWorldHost(options) {
|
|
|
211
329
|
}
|
|
212
330
|
}
|
|
213
331
|
catch (error) {
|
|
332
|
+
await apart?.server.stop(true);
|
|
214
333
|
await server.stop(true);
|
|
215
334
|
for (const m of byServed.values())
|
|
216
335
|
await m.stop().catch(() => undefined);
|
|
217
336
|
throw error;
|
|
218
337
|
}
|
|
338
|
+
await branches.resume();
|
|
219
339
|
return {
|
|
220
340
|
url, port: server.port ?? 0, adminToken, console: consoleUrl,
|
|
221
341
|
get worlds() { return [...byServed.values()].map((m) => ({ name: m.served, base: `${url}/${m.served}` })); },
|
|
222
|
-
stop: async () => { await apart?.server.stop(true); await server.stop(true); for (const m of byServed.values())
|
|
342
|
+
stop: async () => { branches.stop(); await apart?.server.stop(true); await server.stop(true); for (const m of byServed.values())
|
|
223
343
|
await m.stop(); },
|
|
224
344
|
};
|
|
225
345
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-host",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "The hosted remote: worlds under one URL, each holding the canonical history of a real vendor account and its sealed credential — what a local world clones from and pushes to.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
"postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@volter/world-
|
|
30
|
-
"@volter/world-
|
|
29
|
+
"@volter/world-access": "2.0.0",
|
|
30
|
+
"@volter/world-core": "2.0.2",
|
|
31
|
+
"@volter/world-runtime": "2.0.2"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
|
-
"@volter/world-console": "2.0.
|
|
34
|
+
"@volter/world-console": "2.0.2"
|
|
34
35
|
},
|
|
35
36
|
"peerDependenciesMeta": {
|
|
36
37
|
"@volter/world-console": {
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
}
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@volter/world-console": "2.0.
|
|
42
|
+
"@volter/world-console": "2.0.2"
|
|
42
43
|
},
|
|
43
44
|
"exports": {
|
|
44
45
|
".": {
|
package/src/cli.ts
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// volter-host — worlds under one URL (see twins-host.ts).
|
|
3
3
|
//
|
|
4
|
-
// volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>]
|
|
4
|
+
// volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>] [--trust <platform origin>]… [--world-origins <domain>]
|
|
5
5
|
//
|
|
6
6
|
// `<dir>/<org>/<world>/` is a bare world (`volter world init --bare <org>/<world>`). Each world's
|
|
7
7
|
// token is in its own `.volter/token`; each world's serve record names this host's URL. The admin
|
|
8
8
|
// token (`<dir>/.volter-host/admin`) opens the host's own doors — `/-/worlds` — and is printed once.
|
|
9
|
+
// Each `--trust` names a platform whose passes open these Worlds for a person (its keys at /.well-known/jwks.json).
|
|
10
|
+
// `--world-origins <domain>` gives each World an origin of its own under it (`<world>--<org>.<domain>`), for a host reached at --url.
|
|
9
11
|
import { createWorldHost, type ConsoleMount } from './twins-host.ts';
|
|
10
12
|
|
|
11
13
|
const cmd = process.argv[2];
|
|
12
14
|
const rest = process.argv.slice(3);
|
|
13
15
|
const value = (flag: string): string | undefined => { const i = rest.indexOf(flag); return i >= 0 ? rest[i + 1] : undefined; };
|
|
16
|
+
const values = (flag: string): string[] => rest.flatMap((a, i) => (a === flag && rest[i + 1] ? [rest[i + 1]!] : []));
|
|
14
17
|
|
|
15
18
|
if (cmd === 'serve') {
|
|
16
19
|
const dir = value('--dir');
|
|
17
|
-
if (!dir) { process.stderr.write('volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>]\n'); process.exit(2); }
|
|
20
|
+
if (!dir) { process.stderr.write('volter-host serve --dir <worlds> [--host H] [--port P] [--url <origin reached at>] [--console-port C] [--console-url <console origin reached at; same site as --url, or its mirror links are refused>] [--trust <platform origin>]… [--world-origins <domain>]\n'); process.exit(2); }
|
|
18
21
|
// the console mounts when it is installed beside the host; a host without it serves the doors alone
|
|
19
22
|
const console = await (async (): Promise<ConsoleMount | undefined> => {
|
|
20
23
|
try { const mod = await import('@volter/world-console') as { createConsole: () => ConsoleMount }; return mod.createConsole(); } catch { return undefined; }
|
|
21
24
|
})();
|
|
22
|
-
const host = await createWorldHost({ dir, ...(value('--host') ? { host: value('--host')! } : {}), ...(value('--port') ? { port: Number(value('--port')) } : {}), ...(value('--url') ? { advertise: value('--url')! } : {}), ...(value('--console-port') ? { consolePort: Number(value('--console-port')) } : {}), ...(value('--console-url') ? { consoleAdvertise: value('--console-url')! } : {}), ...(console ? { console } : {}), announce: (line) => process.stdout.write(`${line}\n`) });
|
|
25
|
+
const host = await createWorldHost({ dir, ...(value('--host') ? { host: value('--host')! } : {}), ...(value('--port') ? { port: Number(value('--port')) } : {}), ...(value('--url') ? { advertise: value('--url')! } : {}), ...(value('--console-port') ? { consolePort: Number(value('--console-port')) } : {}), ...(value('--console-url') ? { consoleAdvertise: value('--console-url')! } : {}), ...(console ? { console } : {}), ...(rest.includes('--no-origins') ? { origins: false } : {}), ...(values('--trust').length ? { trust: values('--trust') } : {}), ...(value('--world-origins') ? { worldOrigins: value('--world-origins')! } : {}), announce: (line) => process.stdout.write(`${line}\n`) });
|
|
23
26
|
process.stdout.write(`host ready ${host.url} ${host.worlds.length} world${host.worlds.length === 1 ? '' : 's'}\nadmin token ${host.adminToken}\n${host.console ? `console ${host.console}\n` : ''}`);
|
|
24
27
|
const shutdown = async () => { await host.stop(); process.exit(0); };
|
|
25
28
|
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
|
package/src/twins-host.test.ts
CHANGED
|
@@ -113,6 +113,76 @@ describe("the hosted product's doors", () => {
|
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
test('World origins on the host\'s own front: each serves its World and the console\'s pages of it, never the admin doors; --no-origins turns them off', async () => {
|
|
117
|
+
const base = mkdtempSync(join(tmpdir(), 'world-host-front-'));
|
|
118
|
+
mkdirSync(join(base, 'node_modules', '@volter'), { recursive: true }); symlinkSync(join(PACKS, 'github'), join(base, 'node_modules', '@volter', 'twin-github'), 'junction');
|
|
119
|
+
for (const name of ['acme/one', 'acme/two', 'console/lab']) {
|
|
120
|
+
const at = join(base, ...name.split('/')); mkdirSync(at, { recursive: true });
|
|
121
|
+
const made = spawnSync(process.execPath, [CLI, 'world', 'init', '--bare', name, '--twins', 'github'], { cwd: at, encoding: 'utf8', windowsHide: true });
|
|
122
|
+
if (made.status !== 0) throw new Error(made.stderr);
|
|
123
|
+
}
|
|
124
|
+
// a console mount that says what the host asked of it
|
|
125
|
+
const asked: Array<{ path: string; opts: unknown }> = [];
|
|
126
|
+
const console = { handle: async (request: Request, _base: string, opts?: unknown) => { asked.push({ path: new URL(request.url).pathname, opts: opts ?? null }); return new Response('console page', { headers: { 'content-type': 'text/html' } }); } };
|
|
127
|
+
const front = await createWorldHost({ dir: base, console });
|
|
128
|
+
try {
|
|
129
|
+
const port = new URL(front.url).port;
|
|
130
|
+
const token = readFileSync(join(base, 'acme', 'one', '.volter', 'token'), 'utf8').trim();
|
|
131
|
+
// an origin reached as a browser reaches it: this listener, the origin's name as Host
|
|
132
|
+
const at = (host: string, path: string, init: RequestInit = {}) => fetch(`${front.url}${path}`, { ...init, redirect: 'manual', headers: { ...(init.headers as Record<string, string> | undefined), host } });
|
|
133
|
+
const one = `one--acme.localhost:${port}`;
|
|
134
|
+
expect((await at(one, '/-/acme/one/clock', { headers: { 'x-volter-token': token } })).status).toBe(200);
|
|
135
|
+
expect((await at(one, '/-/acme/two/clock', { headers: { 'x-volter-token': token } })).status).toBe(404); // another World's doors
|
|
136
|
+
expect((await at(one, '/-/worlds', { headers: { 'x-volter-token': front.adminToken } })).status).toBe(404); // the admin doors, never on a World origin
|
|
137
|
+
expect(((await (await at(one, '/-/ping')).json()) as { worlds: string[] }).worlds).toEqual(['acme/one']); // this World only, not its siblings
|
|
138
|
+
// the console's pages of a World are served on its own origin, flagged as such; on the shared origin they move to the console's
|
|
139
|
+
expect(await (await at(one, '/-/console/acme/one')).text()).toBe('console page');
|
|
140
|
+
expect(asked.at(-1)).toMatchObject({ path: '/-/console/acme/one', opts: { worldOrigin: true } });
|
|
141
|
+
const shared = await fetch(`${front.url}/-/console/acme/one`, { redirect: 'manual' });
|
|
142
|
+
expect(shared.status).toBe(302);
|
|
143
|
+
expect(shared.headers.get('location')).toStartWith(front.console!.replace(/\/-\/console\/$/, ''));
|
|
144
|
+
// a World whose org is named like the console keeps its doors, on its origin and on the shared one; its console
|
|
145
|
+
// pages are still the console's
|
|
146
|
+
const labToken = readFileSync(join(base, 'console', 'lab', '.volter', 'token'), 'utf8').trim();
|
|
147
|
+
const lab = `lab--console.localhost:${port}`;
|
|
148
|
+
const labClock = await at(lab, '/-/console/lab/clock', { headers: { 'x-volter-token': labToken } });
|
|
149
|
+
expect(labClock.status).toBe(200);
|
|
150
|
+
expect(labClock.headers.get('content-type')).toContain('json');
|
|
151
|
+
expect((await fetch(`${front.url}/-/console/lab/clock`, { redirect: 'manual', headers: { 'x-volter-token': labToken } })).status).toBe(200);
|
|
152
|
+
expect(await (await at(lab, '/-/console/console/lab')).text()).toBe('console page');
|
|
153
|
+
} finally { await front.stop(); }
|
|
154
|
+
// --no-origins: a World's name as Host is nothing special; the shared origin serves every World by path
|
|
155
|
+
const off = await createWorldHost({ dir: base, origins: false });
|
|
156
|
+
try {
|
|
157
|
+
const token = readFileSync(join(base, 'acme', 'two', '.volter', 'token'), 'utf8').trim();
|
|
158
|
+
const port = new URL(off.url).port;
|
|
159
|
+
expect((await fetch(`${off.url}/-/acme/two/clock`, { headers: { host: `one--acme.localhost:${port}`, 'x-volter-token': token } })).status).toBe(200);
|
|
160
|
+
} finally { await off.stop(); rmSync(base, { recursive: true, force: true }); }
|
|
161
|
+
}, 180_000);
|
|
162
|
+
|
|
163
|
+
test('a host whose default turns World origins off says so; an explicit choice and the loopback default do not', async () => {
|
|
164
|
+
const empty = mkdtempSync(join(tmpdir(), 'world-host-origins-'));
|
|
165
|
+
// one bare World of its own (a host serves at least one), its twin resolved above it as the suite's are
|
|
166
|
+
mkdirSync(join(empty, 'node_modules', '@volter'), { recursive: true }); symlinkSync(join(PACKS, 'github'), join(empty, 'node_modules', '@volter', 'twin-github'), 'junction');
|
|
167
|
+
const solo = join(empty, 'acme', 'solo'); mkdirSync(solo, { recursive: true });
|
|
168
|
+
const made = spawnSync(process.execPath, [CLI, 'world', 'init', '--bare', 'acme/solo', '--twins', 'github'], { cwd: solo, encoding: 'utf8', windowsHide: true });
|
|
169
|
+
if (made.status !== 0) throw new Error(made.stderr);
|
|
170
|
+
const said = async (options: Parameters<typeof createWorldHost>[0]): Promise<string[]> => {
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
const h = await createWorldHost({ ...options, dir: empty, announce: (line) => lines.push(line) });
|
|
173
|
+
await h.stop();
|
|
174
|
+
return lines.filter((l) => l.startsWith('origins'));
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
const advertised = await said({ dir: empty, advertise: 'https://worlds.example' });
|
|
178
|
+
expect(advertised).toHaveLength(1);
|
|
179
|
+
expect(advertised[0]).toContain('advertised at https://worlds.example');
|
|
180
|
+
expect(advertised[0]).toContain('path-scoped');
|
|
181
|
+
expect(await said({ dir: empty, advertise: 'https://worlds.example', origins: false })).toEqual([]);
|
|
182
|
+
expect(await said({ dir: empty })).toEqual([]);
|
|
183
|
+
} finally { rmSync(empty, { recursive: true, force: true }); }
|
|
184
|
+
});
|
|
185
|
+
|
|
116
186
|
test('host deletion refuses a local dependent branch while keeping the parent served', async () => {
|
|
117
187
|
const headers = { 'x-volter-token': host.adminToken, 'content-type': 'application/json' };
|
|
118
188
|
const child = mkdtempSync(join(tmpdir(), 'host-lineage-child-'));
|
package/src/twins-host.ts
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
// Nothing of the vendor lives here — no arms, no keys, no folds: the host is the shell; the kernel
|
|
16
16
|
// serves. The v1 host (namespaces, links, the push flip, the R2 shell) left with v1.
|
|
17
17
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { assertStateRemovable, serveHttp, withAncestryLock, withStateRemoval } from '@volter/world-core';
|
|
18
|
+
import { assertStateRemovable, serveHttp, stateDirName, withAncestryLock, withStateRemoval } from '@volter/world-core';
|
|
19
19
|
import { dirname, join, resolve } from 'node:path';
|
|
20
|
-
import { assertWorldStateRemovable, CONSOLE_BASE, consoleRedirect, initWorld, loadWorldConfig, mountWorld, serveConsoleApart, TOKEN_HEADER, type ConsoleMount, type MountedWorld } from '@volter/world-runtime';
|
|
20
|
+
import { assertWorldStateRemovable, resolveCatalog, CONSOLE_BASE, consoleRedirect, initWorld, LocalBranches, loadWorldConfig, localWorldOrigin, mountWorld, pathOfWorld, worldOriginLabel, serveConsoleApart, worldDoorUnderConsole, TOKEN_HEADER, worldOfHost, type ConsoleMount, type MountedWorld } from '@volter/world-runtime';
|
|
21
|
+
|
|
22
|
+
import { issuerKeys, trustableIssuer, type TrustedIssuer } from '@volter/world-access';
|
|
21
23
|
|
|
22
24
|
export type WorldHostOptions = {
|
|
23
25
|
/** the directory of worlds: `<dir>/<org>/<world>/` is a bare world */
|
|
@@ -38,14 +40,33 @@ export type WorldHostOptions = {
|
|
|
38
40
|
* 0.0.0.0 behind a port map, a host behind a proxy. Every world's `base`, its serve record and the
|
|
39
41
|
* printed URLs use it; without it the bind address stands (loopback runs). */
|
|
40
42
|
advertise?: string;
|
|
43
|
+
/** each World a browser steps into at an origin of its own, `<world>--<org>.localhost` (on by default
|
|
44
|
+
* on a loopback host that is not advertised under another URL) */
|
|
45
|
+
origins?: boolean;
|
|
46
|
+
/** The platforms (their origins) whose passes open this host's Worlds for a person: each publishes its keys at
|
|
47
|
+
* `/.well-known/jwks.json` (docs/contributing/architecture.md, "The hosted product"). None, and only tokens do. */
|
|
48
|
+
trust?: string[];
|
|
49
|
+
/** A host reached under its own domain (`advertise`): each World at `<world>--<org>.<worldOrigins>` (a wildcard name
|
|
50
|
+
* and certificate pointing here), the origin its browser session and a platform's passes live at. Loopback hosts
|
|
51
|
+
* have theirs under `localhost` already. */
|
|
52
|
+
worldOrigins?: string;
|
|
41
53
|
};
|
|
42
54
|
export { CONSOLE_BASE, type ConsoleMount };
|
|
43
55
|
export type WorldHostHandle = { url: string; port: number; adminToken: string; console: string | null; worlds: Array<{ name: string; base: string }>; stop: () => Promise<void> };
|
|
44
56
|
/** One row of the inventory: what the platform records and what the console is handed. */
|
|
45
|
-
export type WorldInventoryRow = { org: string; world: string; name: string; base: string; token: string; readToken: string; twins: Array<{ vendor: string; protocol: unknown; root: unknown }> };
|
|
57
|
+
export type WorldInventoryRow = { org: string; world: string; name: string; base: string; /** the World's own origin, where a browser opens it */ origin?: string; /** the org that owns it, as the platform that made or claimed it names the org (architecture, "Every World has an owner from its making") */ owner?: string; token: string; readToken: string; twins: Array<{ vendor: string; protocol: unknown; root: unknown }> };
|
|
46
58
|
|
|
47
59
|
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
48
60
|
|
|
61
|
+
/** Public suffixes of two labels, under which a registrable domain has three (the common ones: a site-level warning, not a PSL). */
|
|
62
|
+
const MULTI_LABEL_SUFFIXES = new Set(['co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'com.au', 'net.au', 'org.au', 'co.nz', 'org.nz', 'co.jp', 'ne.jp', 'or.jp', 'co.in', 'co.za', 'com.br', 'com.cn', 'com.mx', 'com.sg', 'com.tr', 'co.kr', 'com.tw', 'github.io', 'gitlab.io', 'pages.dev', 'workers.dev', 'vercel.app', 'netlify.app', 'herokuapp.com', 'fly.dev', 'onrender.com', 'web.app', 'firebaseapp.com', 'azurewebsites.net', 'cloudfront.net', 'amazonaws.com', 'trycloudflare.com', 'ngrok.app', 'ngrok.io']);
|
|
63
|
+
/** An owner as a platform names an org: its id, letters, digits, `_` and `-`. */
|
|
64
|
+
const OWNER = /^[A-Za-z0-9_-]{1,80}$/;
|
|
65
|
+
/** The owner recorded beside a World (`.volter/owner`), or null. */
|
|
66
|
+
const ownerOf = (root: string): string | null => { try { const o = readFileSync(join(root, stateDirName(), 'owner'), 'utf8').trim(); return OWNER.test(o) ? o : null; } catch { return null; } };
|
|
67
|
+
const recordOwner = (root: string, owner: string): void => { mkdirSync(join(root, stateDirName()), { recursive: true }); writeFileSync(join(root, stateDirName(), 'owner'), `${owner}
|
|
68
|
+
`); };
|
|
69
|
+
|
|
49
70
|
/** The bare worlds under a directory: `<dir>/<org>/<world>/.volter/world.json`. */
|
|
50
71
|
export function worldsUnder(dir: string): Array<{ name: string; root: string }> {
|
|
51
72
|
const out: Array<{ name: string; root: string }> = [];
|
|
@@ -77,11 +98,44 @@ export async function createWorldHost(options: WorldHostOptions): Promise<WorldH
|
|
|
77
98
|
const found = worldsUnder(dir);
|
|
78
99
|
if (found.length === 0) throw new Error(`no worlds under ${dir} — a world is \`<dir>/<org>/<world>/\` with \`.volter/world.json\` (\`volter world init --bare <org>/<world>\` makes one)`);
|
|
79
100
|
const adminToken = adminTokenFor(dir);
|
|
101
|
+
const byServed = new Map<string, MountedWorld>();
|
|
102
|
+
let loopback = ''; let port = 0;
|
|
103
|
+
const bound = options.host ?? '127.0.0.1';
|
|
104
|
+
const originBase = options.worldOrigins?.replace(/^\.+|\.+$/g, '') || undefined;
|
|
105
|
+
const origins = options.origins ?? (originBase !== undefined || (!options.advertise && (bound === '127.0.0.1' || bound === 'localhost' || bound === '::1')));
|
|
106
|
+
// off by default (an advertised URL, or a bind a browser cannot reach as *.localhost): say so, since every World
|
|
107
|
+
// then shares one origin and a browser session there is only path-scoped (an explicit --no-origins is a choice)
|
|
108
|
+
if (options.origins === undefined && !origins) options.announce?.(`origins off (${options.advertise ? `advertised at ${options.advertise}` : `bound to ${bound}`}): every World shares one origin, where a browser session is only path-scoped; serve together only Worlds whose pages you trust, or bind loopback without --url for an origin per World`);
|
|
109
|
+
// under a domain of its own: the advertised URL's scheme and port, each World a label under the domain
|
|
110
|
+
const advertised = options.advertise ? new URL(options.advertise) : null;
|
|
111
|
+
const loopbackName = (host: string): boolean => host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1' || host === '[::1]';
|
|
112
|
+
// a host reached over plain http on a public name: its Worlds' session cookies cannot be `__Host-`, and travel in clear
|
|
113
|
+
if (advertised?.protocol === 'http:' && !loopbackName(advertised.hostname)) throw new Error(`--url ${options.advertise}: a host is reached over https (or http on this machine's loopback)`);
|
|
114
|
+
// over plain http a World's session cookie cannot be `__Host-`, so sibling Worlds could toss it, and it travels in clear
|
|
115
|
+
if (originBase !== undefined && advertised?.protocol === 'http:' && !loopbackName(originBase)) throw new Error(`--world-origins ${originBase}: Worlds get origins of their own over https (or http on this machine's loopback); --url is ${options.advertise}`);
|
|
116
|
+
// a World's pages on the same registrable domain as a platform this host trusts are same-site with it: say so (the
|
|
117
|
+
// platform refuses a same-site page's changes, but a domain of the Worlds' own is the layout that needs no such care)
|
|
118
|
+
// the registrable domain: the last two labels, three under a public suffix of two (co.uk, com.au, github.io…); when
|
|
119
|
+
// unsure the check errs toward warning (a longer shared suffix is still compared)
|
|
120
|
+
const siteOf = (host: string): string => { const labels = host.toLowerCase().split('.'); const two = labels.slice(-2).join('.'); return labels.slice(MULTI_LABEL_SUFFIXES.has(two) ? -3 : -2).join('.'); };
|
|
121
|
+
if (originBase !== undefined) for (const t of options.trust ?? []) { try { const h = new URL(t).hostname; if (!loopbackName(h) && siteOf(h) === siteOf(originBase)) options.announce?.(`origins ${originBase} shares a site with the platform ${t}: give the Worlds a registrable domain of their own (as github.com uses githubusercontent.com)`); } catch { /* the trust check below says why */ } }
|
|
122
|
+
const browserOrigin = !origins ? undefined : originBase
|
|
123
|
+
? (served: string) => { const label = worldOriginLabel(served); return label ? `${advertised?.protocol ?? 'https:'}//${label}.${originBase}${advertised?.port ? `:${advertised.port}` : ''}` : null; }
|
|
124
|
+
: (served: string) => (port ? localWorldOrigin(served, port) : null);
|
|
125
|
+
// one key reader per trusted platform, shared by every World here (each caches its platform's key set)
|
|
126
|
+
// https only, or http on this machine: keys read over plain http elsewhere could be swapped, and a swapped key forges passes
|
|
127
|
+
const passIssuers: TrustedIssuer[] = (options.trust ?? []).map((origin) => {
|
|
128
|
+
const issuer = trustableIssuer(origin);
|
|
129
|
+
if (!issuer) throw new Error(`--trust ${origin}: a platform is trusted over https (or http on this machine's loopback)`);
|
|
130
|
+
return { issuer, keys: issuerKeys(issuer) };
|
|
131
|
+
});
|
|
132
|
+
// a World's branches ("as of" views) live beside it under the host's directory, and reach their
|
|
133
|
+
// parent through this host's own listener
|
|
134
|
+
const branches: LocalBranches = new LocalBranches({ dir, origin: () => loopback, worlds: byServed, mount: (root) => mount({ name: '', root }), ...(options.announce ? { announce: options.announce } : {}) });
|
|
80
135
|
const mount = async (w: { name: string; root: string }): Promise<MountedWorld> => {
|
|
81
136
|
const config = loadWorldConfig(join(w.root, '.volter', 'world.json'), w.root).config;
|
|
82
|
-
return mountWorld(config.id, { root: w.root });
|
|
137
|
+
return mountWorld(config.id, { root: w.root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(passIssuers.length ? { passIssuers } : {}) });
|
|
83
138
|
};
|
|
84
|
-
const byServed = new Map<string, MountedWorld>();
|
|
85
139
|
for (const w of found) byServed.set(w.name, await mount(w));
|
|
86
140
|
const host = options.host ?? '127.0.0.1';
|
|
87
141
|
let url = '';
|
|
@@ -96,7 +150,11 @@ export async function createWorldHost(options: WorldHostOptions): Promise<WorldH
|
|
|
96
150
|
const status = res.ok ? (await res.json()) as { protocol?: unknown; root?: unknown } : {};
|
|
97
151
|
twins.push({ vendor, protocol: status.protocol ?? null, root: status.root ?? null });
|
|
98
152
|
}
|
|
99
|
-
|
|
153
|
+
// `origin`: where a browser steps into this World (its page, its session), when it has an origin of its own
|
|
154
|
+
// only where this host serves the World's page there (the console): a pass sent anywhere else lands on nothing
|
|
155
|
+
const origin = options.console ? browserOrigin?.(m.served) ?? null : null;
|
|
156
|
+
const owner = ownerOf(m.root);
|
|
157
|
+
return { org, world, name: m.served, base: `${url}/${m.served}`, ...(origin ? { origin } : {}), ...(owner ? { owner } : {}), token: m.token, readToken: m.readToken, twins };
|
|
100
158
|
};
|
|
101
159
|
|
|
102
160
|
const checkAncestry = (path: string): void => {
|
|
@@ -106,14 +164,17 @@ export async function createWorldHost(options: WorldHostOptions): Promise<WorldH
|
|
|
106
164
|
|
|
107
165
|
const admin = async (request: Request, path: string): Promise<Response> => {
|
|
108
166
|
if (request.headers.get(TOKEN_HEADER) !== adminToken) return json({ error: 'the host\'s doors open to the admin token' }, 401);
|
|
109
|
-
|
|
167
|
+
// the twins this host can make a World with: those installed beside its Worlds (a platform's picker offers these)
|
|
168
|
+
if (path === '/-/vendors' && request.method === 'GET') { let vendors: string[] = []; try { vendors = resolveCatalog(dir).vendors(); } catch { /* none installed */ } return json({ vendors }); }
|
|
169
|
+
const m = /^\/-\/worlds(?:\/([^/]+)\/([^/]+)(?:\/(rotate|owner))?)?$/.exec(path);
|
|
110
170
|
if (!m) return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
111
171
|
const [, org, world, verb] = m;
|
|
112
172
|
if (!org) {
|
|
113
173
|
if (request.method === 'GET') { const rows: WorldInventoryRow[] = []; for (const w of byServed.values()) rows.push(await row(w)); return json({ worlds: rows }); }
|
|
114
174
|
if (request.method === 'POST') {
|
|
115
|
-
let body: { org?: string; world?: string; vendors?: string[] };
|
|
116
|
-
try { body = (await request.json()) as typeof body; } catch { return json({ error: 'provision: { org, world, vendors }' }, 400); }
|
|
175
|
+
let body: { org?: string; world?: string; vendors?: string[]; owner?: string };
|
|
176
|
+
try { body = (await request.json()) as typeof body; } catch { return json({ error: 'provision: { org, world, vendors, owner? }' }, 400); }
|
|
177
|
+
if (body.owner !== undefined && (typeof body.owner !== 'string' || !OWNER.test(body.owner))) return json({ error: 'owner: the owning org as its platform names it (letters, digits, _ and -)' }, 400);
|
|
117
178
|
const vendors = Array.isArray(body.vendors) ? body.vendors.filter((v): v is string => typeof v === 'string' && NAME.test(v)) : [];
|
|
118
179
|
if (!body.org || !body.world || !NAME.test(body.org) || !NAME.test(body.world) || vendors.length === 0) return json({ error: 'provision: { org, world, vendors: [vendor, …] } — names are letters, digits, . _ -' }, 400);
|
|
119
180
|
const name = `${body.org}/${body.world}`; const root = join(dir, body.org, body.world);
|
|
@@ -123,6 +184,7 @@ export async function createWorldHost(options: WorldHostOptions): Promise<WorldH
|
|
|
123
184
|
mkdirSync(root, { recursive: true });
|
|
124
185
|
const result = initWorld(body.world, root, { root, vendors, bare: name });
|
|
125
186
|
if (!result.ok) throw new Error(`init refused: ${JSON.stringify(result.plan)}`);
|
|
187
|
+
if (body.owner) recordOwner(root, body.owner);
|
|
126
188
|
const mounted = await mount({ name, root });
|
|
127
189
|
acquired = mounted;
|
|
128
190
|
await mounted.boot(url);
|
|
@@ -146,7 +208,17 @@ export async function createWorldHost(options: WorldHostOptions): Promise<WorldH
|
|
|
146
208
|
const name = `${org}/${world}`; const mounted = byServed.get(name);
|
|
147
209
|
if (!mounted) return json({ error: `no world ${name} here` }, 404);
|
|
148
210
|
if (verb === 'rotate' && request.method === 'POST') return json({ name, ...mounted.rotate() });
|
|
211
|
+
// a World made before its platform is claimed once: its owner is recorded then, and never silently replaced
|
|
212
|
+
if (verb === 'owner' && request.method === 'PUT') {
|
|
213
|
+
let body: { owner?: string }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'owner: { owner }' }, 400); }
|
|
214
|
+
if (typeof body.owner !== 'string' || !OWNER.test(body.owner)) return json({ error: 'owner: { owner } — the owning org as its platform names it' }, 400);
|
|
215
|
+
const held = ownerOf(mounted.root);
|
|
216
|
+
if (held && held !== body.owner) return json({ error: `${name} is owned by ${held}` }, 409);
|
|
217
|
+
recordOwner(mounted.root, body.owner);
|
|
218
|
+
return json({ name, owner: body.owner });
|
|
219
|
+
}
|
|
149
220
|
if (!verb && request.method === 'DELETE') {
|
|
221
|
+
if (branches.hasBranches(name)) return json({ error: `${name} has branches: ${branches.list(name).map((b) => b.name).join(', ')}` }, 409);
|
|
150
222
|
try {
|
|
151
223
|
withAncestryLock(() => checkAncestry(mounted.root));
|
|
152
224
|
await mounted.stop();
|
|
@@ -165,11 +237,24 @@ export async function createWorldHost(options: WorldHostOptions): Promise<WorldH
|
|
|
165
237
|
// here misattributes requests and can recreate a removed World after its response.
|
|
166
238
|
twinRequestJournal: false,
|
|
167
239
|
async fetch(request) {
|
|
168
|
-
const
|
|
169
|
-
|
|
240
|
+
const at = new URL(request.url); const path = at.pathname;
|
|
241
|
+
// a World's own origin serves that World (and the ping and the console) and no other, nor the admin doors
|
|
242
|
+
const own = origins ? worldOfHost(at.hostname, byServed.keys(), originBase) : undefined;
|
|
243
|
+
if (own === null) return json({ error: `no world at ${at.host}` }, 404);
|
|
244
|
+
if (own && !pathOfWorld(path, own, CONSOLE_BASE)) return json({ error: `this origin serves ${own} only` }, 404);
|
|
245
|
+
// the console's pages of a World live at the World's own origin, beside its session and its mirrors
|
|
246
|
+
// (the console's home, the list of Worlds, lives at the console's own origin: consoleRedirect below)
|
|
247
|
+
// a World named console/<world> keeps its doors (/-/console/<world>/…): they are never the console's pages
|
|
248
|
+
const worldDoor = worldDoorUnderConsole(path, (n) => byServed.has(n), CONSOLE_BASE);
|
|
249
|
+
if (own && options.console && !worldDoor && (path === CONSOLE_BASE || path.startsWith(`${CONSOLE_BASE}/`))) {
|
|
250
|
+
const answered = await options.console.handle(request, CONSOLE_BASE, { worlds: at.origin, worldOrigin: true });
|
|
251
|
+
if (answered) return answered;
|
|
252
|
+
}
|
|
253
|
+
const moved = worldDoor ? null : consoleRedirect(request, consoleUrl ? consoleUrl.slice(0, -`${CONSOLE_BASE}/`.length) : null);
|
|
170
254
|
if (moved) return moved;
|
|
171
|
-
|
|
172
|
-
if (path === '/-/
|
|
255
|
+
// on a World's own origin the ping names that World only (a sibling's name is none of its business)
|
|
256
|
+
if (path === '/-/ping') return json({ ok: true, worlds: own ? [own] : [...byServed.keys()], ...(consoleUrl ? { console: consoleUrl } : {}) });
|
|
257
|
+
if (path === '/-/worlds' || path.startsWith('/-/worlds/') || path === '/-/vendors') return admin(request, path);
|
|
173
258
|
const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
|
|
174
259
|
const world = m ? byServed.get(`${m[1]}/${m[2]}`) : undefined;
|
|
175
260
|
if (!world) return json({ error: `no world at ${path}: this host serves ${[...byServed.keys()].join(', ')}` }, 404);
|
|
@@ -177,21 +262,23 @@ export async function createWorldHost(options: WorldHostOptions): Promise<WorldH
|
|
|
177
262
|
},
|
|
178
263
|
});
|
|
179
264
|
url = options.advertise ? options.advertise.replace(/\/+$/, '') : `http://${host}:${server.port}`;
|
|
265
|
+
port = server.port ?? 0;
|
|
266
|
+
loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
|
|
180
267
|
let apart: Awaited<ReturnType<typeof serveConsoleApart>> | null = null;
|
|
181
268
|
try {
|
|
182
269
|
for (const m of byServed.values()) { await m.boot(url); options.announce?.(`serving ${m.served} ${url}/${m.served}`); }
|
|
183
270
|
if (options.console) {
|
|
184
271
|
// the console reaches the doors at this host's own address; the pages it serves name the advertised one
|
|
185
|
-
const
|
|
186
|
-
const serveApart = (port: number) => serveConsoleApart(options.console!, { upstream: internal, worlds: url, hostname: host, port });
|
|
272
|
+
const serveApart = (port: number) => serveConsoleApart(options.console!, { upstream: loopback, worlds: url, hostname: host, port });
|
|
187
273
|
if (options.consolePort !== undefined) apart = await serveApart(options.consolePort);
|
|
188
274
|
else { try { apart = await serveApart((server.port ?? 0) ? (server.port ?? 0) + 1 : 0); } catch { apart = await serveApart(0); } }
|
|
189
275
|
consoleUrl = `${(options.consoleAdvertise ?? apart.url).replace(/\/+$/, '')}${CONSOLE_BASE}/`;
|
|
190
276
|
}
|
|
191
|
-
} catch (error) { await server.stop(true); for (const m of byServed.values()) await m.stop().catch(() => undefined); throw error; }
|
|
277
|
+
} catch (error) { await apart?.server.stop(true); await server.stop(true); for (const m of byServed.values()) await m.stop().catch(() => undefined); throw error; }
|
|
278
|
+
await branches.resume();
|
|
192
279
|
return {
|
|
193
280
|
url, port: server.port ?? 0, adminToken, console: consoleUrl,
|
|
194
281
|
get worlds() { return [...byServed.values()].map((m) => ({ name: m.served, base: `${url}/${m.served}` })); },
|
|
195
|
-
stop: async () => { await apart?.server.stop(true); await server.stop(true); for (const m of byServed.values()) await m.stop(); },
|
|
282
|
+
stop: async () => { branches.stop(); await apart?.server.stop(true); await server.stop(true); for (const m of byServed.values()) await m.stop(); },
|
|
196
283
|
};
|
|
197
284
|
}
|