@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 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);
@@ -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<{
@@ -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
- return { org, world, name: m.served, base: `${url}/${m.served}`, token: m.token, readToken: m.readToken, twins };
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
- const m = /^\/-\/worlds(?:\/([^/]+)\/([^/]+)(?:\/(rotate))?)?$/.exec(path);
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 path = new URL(request.url).pathname;
175
- const moved = consoleRedirect(request, consoleUrl ? consoleUrl.slice(0, -`${CONSOLE_BASE}/`.length) : null);
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 internal = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${server.port}`;
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.1",
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-core": "2.0.1",
30
- "@volter/world-runtime": "2.0.1"
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.1"
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.1"
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);
@@ -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
- return { org, world, name: m.served, base: `${url}/${m.served}`, token: m.token, readToken: m.readToken, twins };
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
- const m = /^\/-\/worlds(?:\/([^/]+)\/([^/]+)(?:\/(rotate))?)?$/.exec(path);
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 path = new URL(request.url).pathname;
169
- const moved = consoleRedirect(request, consoleUrl ? consoleUrl.slice(0, -`${CONSOLE_BASE}/`.length) : null);
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
- if (path === '/-/ping') return json({ ok: true, worlds: [...byServed.keys()], ...(consoleUrl ? { console: consoleUrl } : {}) });
172
- if (path === '/-/worlds' || path.startsWith('/-/worlds/')) return admin(request, 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 internal = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${server.port}`;
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
  }