@volter/world-host 2.0.0
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/README.md +15 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +38 -0
- package/dist/src/twins-host.d.ts +55 -0
- package/dist/src/twins-host.js +225 -0
- package/package.json +48 -0
- package/src/cli.ts +29 -0
- package/src/twins-host.test.ts +170 -0
- package/src/twins-host.ts +197 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @volter/world-host — worlds under one URL
|
|
2
|
+
|
|
3
|
+
The hosting shell mounts bare worlds from `<dir>/<org>/<world>/` under one origin. Each World
|
|
4
|
+
has its own tokens, serve record, vendor roots and credentials. A mounted World exposes the same
|
|
5
|
+
API as `volter world serve`.
|
|
6
|
+
|
|
7
|
+
`volter-host serve --dir <worlds>` starts the host. Its admin API inventories, provisions,
|
|
8
|
+
rotates tokens and removes worlds without restarting the host. The host admin token does not
|
|
9
|
+
open a World's API, and World tokens do not open the host admin API. When installed,
|
|
10
|
+
`@volter/world-console` supplies the console.
|
|
11
|
+
|
|
12
|
+
- [Host worlds for a team](../../docs/guides/host-worlds-for-a-team.md) — executed setup guide.
|
|
13
|
+
- [HTTP API](../../docs/reference/http-api.md#the-hosting-product) — host endpoints and tokens.
|
|
14
|
+
- [CLI reference](../../docs/reference/cli.md#the-hosting-product) — command and flags.
|
|
15
|
+
- [The model](../../docs/concepts/the-model.md) — the semantics every hosted World follows.
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// volter-host — worlds under one URL (see twins-host.ts).
|
|
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>]
|
|
5
|
+
//
|
|
6
|
+
// `<dir>/<org>/<world>/` is a bare world (`volter world init --bare <org>/<world>`). Each world's
|
|
7
|
+
// token is in its own `.volter/token`; each world's serve record names this host's URL. The admin
|
|
8
|
+
// token (`<dir>/.volter-host/admin`) opens the host's own doors — `/-/worlds` — and is printed once.
|
|
9
|
+
import { createWorldHost } from "./twins-host.js";
|
|
10
|
+
const cmd = process.argv[2];
|
|
11
|
+
const rest = process.argv.slice(3);
|
|
12
|
+
const value = (flag) => { const i = rest.indexOf(flag); return i >= 0 ? rest[i + 1] : undefined; };
|
|
13
|
+
if (cmd === 'serve') {
|
|
14
|
+
const dir = value('--dir');
|
|
15
|
+
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');
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
// the console mounts when it is installed beside the host; a host without it serves the doors alone
|
|
20
|
+
const console = await (async () => {
|
|
21
|
+
try {
|
|
22
|
+
const mod = await import('@volter/world-console');
|
|
23
|
+
return mod.createConsole();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
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`) });
|
|
30
|
+
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
|
+
const shutdown = async () => { await host.stop(); process.exit(0); };
|
|
32
|
+
process.on('SIGTERM', shutdown);
|
|
33
|
+
process.on('SIGINT', shutdown);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
process.stderr.write('usage: 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');
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CONSOLE_BASE, type ConsoleMount } from '@volter/world-runtime';
|
|
2
|
+
export type WorldHostOptions = {
|
|
3
|
+
/** the directory of worlds: `<dir>/<org>/<world>/` is a bare world */
|
|
4
|
+
dir: string;
|
|
5
|
+
/** keep the kernel's request journal for every twin served here (layer 8: the world's request report); on unless told otherwise */
|
|
6
|
+
requestJournal?: boolean;
|
|
7
|
+
host?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
announce?: (line: string) => void;
|
|
10
|
+
/** the console, when installed (`@volter/world-console`): served on a listener of its own, its doors
|
|
11
|
+
* forwarded here, so no script a twin serves shares its origin (world-runtime console-apart.ts) */
|
|
12
|
+
console?: ConsoleMount;
|
|
13
|
+
/** the console's port; the host's port + 1 when absent (a stable address), else one the system picks */
|
|
14
|
+
consolePort?: number;
|
|
15
|
+
/** the origin the console is REACHED at, when it differs from its bind address (as `advertise`) */
|
|
16
|
+
consoleAdvertise?: string;
|
|
17
|
+
/** The origin this host is REACHED at, when it differs from the bind address — a container binding
|
|
18
|
+
* 0.0.0.0 behind a port map, a host behind a proxy. Every world's `base`, its serve record and the
|
|
19
|
+
* printed URLs use it; without it the bind address stands (loopback runs). */
|
|
20
|
+
advertise?: string;
|
|
21
|
+
};
|
|
22
|
+
export { CONSOLE_BASE, type ConsoleMount };
|
|
23
|
+
export type WorldHostHandle = {
|
|
24
|
+
url: string;
|
|
25
|
+
port: number;
|
|
26
|
+
adminToken: string;
|
|
27
|
+
console: string | null;
|
|
28
|
+
worlds: Array<{
|
|
29
|
+
name: string;
|
|
30
|
+
base: string;
|
|
31
|
+
}>;
|
|
32
|
+
stop: () => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
/** One row of the inventory: what the platform records and what the console is handed. */
|
|
35
|
+
export type WorldInventoryRow = {
|
|
36
|
+
org: string;
|
|
37
|
+
world: string;
|
|
38
|
+
name: string;
|
|
39
|
+
base: string;
|
|
40
|
+
token: string;
|
|
41
|
+
readToken: string;
|
|
42
|
+
twins: Array<{
|
|
43
|
+
vendor: string;
|
|
44
|
+
protocol: unknown;
|
|
45
|
+
root: unknown;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
/** The bare worlds under a directory: `<dir>/<org>/<world>/.volter/world.json`. */
|
|
49
|
+
export declare function worldsUnder(dir: string): Array<{
|
|
50
|
+
name: string;
|
|
51
|
+
root: string;
|
|
52
|
+
}>;
|
|
53
|
+
/** The host's admin token: minted once, kept at `<dir>/.volter-host/admin` (0600). */
|
|
54
|
+
export declare function adminTokenFor(dir: string): string;
|
|
55
|
+
export declare function createWorldHost(options: WorldHostOptions): Promise<WorldHostHandle>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// THE HOST (company contract "Just like Neon", 4; "The hosted product's doors"): worlds under ONE
|
|
2
|
+
// URL. A directory holds worlds — `<dir>/<org>/<world>/` is a bare world — and the host mounts each
|
|
3
|
+
// on the same port under its served name: `/<org>/<world>/<vendor>/…` is the twin's wire and
|
|
4
|
+
// `/-/<org>/<world>/…` its doors, exactly as `volter world serve` gives one world. Every world keeps
|
|
5
|
+
// its own tokens, its own serve record (the host's URL), its own roots and credentials. A world is
|
|
6
|
+
// served once: a world already served elsewhere refuses to mount here, by name and pid.
|
|
7
|
+
//
|
|
8
|
+
// The host's OWN doors are the hosted product's: an ADMIN token — minted once, kept beside the
|
|
9
|
+
// directory (`<dir>/.volter-host/admin`, 0600), stable across restarts, never an env var — opens
|
|
10
|
+
// `/-/worlds`: the inventory (every world, its address, its tokens, its twins), provision (a new
|
|
11
|
+
// bare world mounted without a restart), rotate (both tokens anew; the old die with the response)
|
|
12
|
+
// and remove. The admin token opens NOTHING under a world, and a world's token opens nothing here:
|
|
13
|
+
// the platform holds the admin token, the console holds world tokens, neither is the other.
|
|
14
|
+
//
|
|
15
|
+
// Nothing of the vendor lives here — no arms, no keys, no folds: the host is the shell; the kernel
|
|
16
|
+
// serves. The v1 host (namespaces, links, the push flip, the R2 shell) left with v1.
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { assertStateRemovable, serveHttp, withAncestryLock, withStateRemoval } from '@volter/world-core';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { assertWorldStateRemovable, CONSOLE_BASE, consoleRedirect, initWorld, loadWorldConfig, mountWorld, serveConsoleApart, TOKEN_HEADER } from '@volter/world-runtime';
|
|
21
|
+
export { CONSOLE_BASE };
|
|
22
|
+
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
23
|
+
/** The bare worlds under a directory: `<dir>/<org>/<world>/.volter/world.json`. */
|
|
24
|
+
export function worldsUnder(dir) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const base = resolve(dir);
|
|
27
|
+
if (!existsSync(base))
|
|
28
|
+
return out;
|
|
29
|
+
for (const org of readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')) {
|
|
30
|
+
for (const world of readdirSync(join(base, org.name), { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
31
|
+
const root = join(base, org.name, world.name);
|
|
32
|
+
if (existsSync(join(root, '.volter', 'world.json')))
|
|
33
|
+
out.push({ name: `${org.name}/${world.name}`, root });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
37
|
+
}
|
|
38
|
+
/** The host's admin token: minted once, kept at `<dir>/.volter-host/admin` (0600). */
|
|
39
|
+
export function adminTokenFor(dir) {
|
|
40
|
+
const path = join(resolve(dir), '.volter-host', 'admin');
|
|
41
|
+
try {
|
|
42
|
+
const t = readFileSync(path, 'utf8').trim();
|
|
43
|
+
if (t.startsWith('tok_a_'))
|
|
44
|
+
return t;
|
|
45
|
+
}
|
|
46
|
+
catch { /* none yet */ }
|
|
47
|
+
const token = `tok_a_${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`;
|
|
48
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
49
|
+
writeFileSync(path, `${token}\n`, { mode: 0o600 });
|
|
50
|
+
chmodSync(path, 0o600);
|
|
51
|
+
return token;
|
|
52
|
+
}
|
|
53
|
+
export async function createWorldHost(options) {
|
|
54
|
+
// the twins this host boots inherit its env: the kernel's journal switch, set by the product, not by an operator
|
|
55
|
+
if (options.requestJournal !== false)
|
|
56
|
+
process.env.VOLTER_TWIN_REQUEST_JOURNAL = '1';
|
|
57
|
+
const dir = resolve(options.dir);
|
|
58
|
+
const found = worldsUnder(dir);
|
|
59
|
+
if (found.length === 0)
|
|
60
|
+
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
|
+
const adminToken = adminTokenFor(dir);
|
|
62
|
+
const mount = async (w) => {
|
|
63
|
+
const config = loadWorldConfig(join(w.root, '.volter', 'world.json'), w.root).config;
|
|
64
|
+
return mountWorld(config.id, { root: w.root });
|
|
65
|
+
};
|
|
66
|
+
const byServed = new Map();
|
|
67
|
+
for (const w of found)
|
|
68
|
+
byServed.set(w.name, await mount(w));
|
|
69
|
+
const host = options.host ?? '127.0.0.1';
|
|
70
|
+
let url = '';
|
|
71
|
+
const json = (body, status = 200) => Response.json(body, { status });
|
|
72
|
+
/** One inventory row, the twins read through the world's own status door with its own token. */
|
|
73
|
+
const row = async (m) => {
|
|
74
|
+
const [org, world] = m.served.split('/');
|
|
75
|
+
const twins = [];
|
|
76
|
+
for (const vendor of m.twins()) {
|
|
77
|
+
const res = await m.handle(new Request(`${url}/-/${m.served}/twins/${vendor}/status`, { headers: { [TOKEN_HEADER]: m.token } }));
|
|
78
|
+
const status = res.ok ? (await res.json()) : {};
|
|
79
|
+
twins.push({ vendor, protocol: status.protocol ?? null, root: status.root ?? null });
|
|
80
|
+
}
|
|
81
|
+
return { org, world, name: m.served, base: `${url}/${m.served}`, token: m.token, readToken: m.readToken, twins };
|
|
82
|
+
};
|
|
83
|
+
const checkAncestry = (path) => {
|
|
84
|
+
for (const world of worldsUnder(dir))
|
|
85
|
+
assertWorldStateRemovable(world.root, path);
|
|
86
|
+
assertStateRemovable(path);
|
|
87
|
+
};
|
|
88
|
+
const admin = async (request, path) => {
|
|
89
|
+
if (request.headers.get(TOKEN_HEADER) !== adminToken)
|
|
90
|
+
return json({ error: 'the host\'s doors open to the admin token' }, 401);
|
|
91
|
+
const m = /^\/-\/worlds(?:\/([^/]+)\/([^/]+)(?:\/(rotate))?)?$/.exec(path);
|
|
92
|
+
if (!m)
|
|
93
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
94
|
+
const [, org, world, verb] = m;
|
|
95
|
+
if (!org) {
|
|
96
|
+
if (request.method === 'GET') {
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (const w of byServed.values())
|
|
99
|
+
rows.push(await row(w));
|
|
100
|
+
return json({ worlds: rows });
|
|
101
|
+
}
|
|
102
|
+
if (request.method === 'POST') {
|
|
103
|
+
let body;
|
|
104
|
+
try {
|
|
105
|
+
body = (await request.json());
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return json({ error: 'provision: { org, world, vendors }' }, 400);
|
|
109
|
+
}
|
|
110
|
+
const vendors = Array.isArray(body.vendors) ? body.vendors.filter((v) => typeof v === 'string' && NAME.test(v)) : [];
|
|
111
|
+
if (!body.org || !body.world || !NAME.test(body.org) || !NAME.test(body.world) || vendors.length === 0)
|
|
112
|
+
return json({ error: 'provision: { org, world, vendors: [vendor, …] } — names are letters, digits, . _ -' }, 400);
|
|
113
|
+
const name = `${body.org}/${body.world}`;
|
|
114
|
+
const root = join(dir, body.org, body.world);
|
|
115
|
+
if (byServed.has(name) || existsSync(root))
|
|
116
|
+
return json({ error: `${name} exists` }, 409);
|
|
117
|
+
let acquired;
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(root, { recursive: true });
|
|
120
|
+
const result = initWorld(body.world, root, { root, vendors, bare: name });
|
|
121
|
+
if (!result.ok)
|
|
122
|
+
throw new Error(`init refused: ${JSON.stringify(result.plan)}`);
|
|
123
|
+
const mounted = await mount({ name, root });
|
|
124
|
+
acquired = mounted;
|
|
125
|
+
await mounted.boot(url);
|
|
126
|
+
byServed.set(name, mounted);
|
|
127
|
+
options.announce?.(`serving ${name} ${url}/${name}`);
|
|
128
|
+
return json(await row(mounted), 201);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
try {
|
|
132
|
+
if (acquired)
|
|
133
|
+
await acquired.stop();
|
|
134
|
+
withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
|
|
135
|
+
byServed.delete(name);
|
|
136
|
+
}
|
|
137
|
+
catch (cleanup) {
|
|
138
|
+
if (acquired)
|
|
139
|
+
byServed.set(name, acquired);
|
|
140
|
+
return json({ error: `provision ${name} failed; cleanup incomplete, World retained: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}` }, 500);
|
|
141
|
+
}
|
|
142
|
+
return json({ error: `provision ${name}: ${error instanceof Error ? error.message : String(error)}` }, 500);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
146
|
+
}
|
|
147
|
+
const name = `${org}/${world}`;
|
|
148
|
+
const mounted = byServed.get(name);
|
|
149
|
+
if (!mounted)
|
|
150
|
+
return json({ error: `no world ${name} here` }, 404);
|
|
151
|
+
if (verb === 'rotate' && request.method === 'POST')
|
|
152
|
+
return json({ name, ...mounted.rotate() });
|
|
153
|
+
if (!verb && request.method === 'DELETE') {
|
|
154
|
+
try {
|
|
155
|
+
withAncestryLock(() => checkAncestry(mounted.root));
|
|
156
|
+
await mounted.stop();
|
|
157
|
+
withStateRemoval(mounted.root, () => rmSync(mounted.root, { recursive: true, force: true }), () => checkAncestry(mounted.root));
|
|
158
|
+
byServed.delete(name);
|
|
159
|
+
return json({ removed: name });
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
166
|
+
};
|
|
167
|
+
let consoleUrl = null;
|
|
168
|
+
const server = await serveHttp({
|
|
169
|
+
hostname: host, port: options.port ?? 0,
|
|
170
|
+
// This front serves many Worlds and lifecycle doors. Capturing one vendor identity
|
|
171
|
+
// here misattributes requests and can recreate a removed World after its response.
|
|
172
|
+
twinRequestJournal: false,
|
|
173
|
+
async fetch(request) {
|
|
174
|
+
const path = new URL(request.url).pathname;
|
|
175
|
+
const moved = consoleRedirect(request, consoleUrl ? consoleUrl.slice(0, -`${CONSOLE_BASE}/`.length) : null);
|
|
176
|
+
if (moved)
|
|
177
|
+
return moved;
|
|
178
|
+
if (path === '/-/ping')
|
|
179
|
+
return json({ ok: true, worlds: [...byServed.keys()], ...(consoleUrl ? { console: consoleUrl } : {}) });
|
|
180
|
+
if (path === '/-/worlds' || path.startsWith('/-/worlds/'))
|
|
181
|
+
return admin(request, path);
|
|
182
|
+
const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
|
|
183
|
+
const world = m ? byServed.get(`${m[1]}/${m[2]}`) : undefined;
|
|
184
|
+
if (!world)
|
|
185
|
+
return json({ error: `no world at ${path}: this host serves ${[...byServed.keys()].join(', ')}` }, 404);
|
|
186
|
+
return world.handle(request);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
url = options.advertise ? options.advertise.replace(/\/+$/, '') : `http://${host}:${server.port}`;
|
|
190
|
+
let apart = null;
|
|
191
|
+
try {
|
|
192
|
+
for (const m of byServed.values()) {
|
|
193
|
+
await m.boot(url);
|
|
194
|
+
options.announce?.(`serving ${m.served} ${url}/${m.served}`);
|
|
195
|
+
}
|
|
196
|
+
if (options.console) {
|
|
197
|
+
// 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 });
|
|
200
|
+
if (options.consolePort !== undefined)
|
|
201
|
+
apart = await serveApart(options.consolePort);
|
|
202
|
+
else {
|
|
203
|
+
try {
|
|
204
|
+
apart = await serveApart((server.port ?? 0) ? (server.port ?? 0) + 1 : 0);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
apart = await serveApart(0);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
consoleUrl = `${(options.consoleAdvertise ?? apart.url).replace(/\/+$/, '')}${CONSOLE_BASE}/`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
await server.stop(true);
|
|
215
|
+
for (const m of byServed.values())
|
|
216
|
+
await m.stop().catch(() => undefined);
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
url, port: server.port ?? 0, adminToken, console: consoleUrl,
|
|
221
|
+
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())
|
|
223
|
+
await m.stop(); },
|
|
224
|
+
};
|
|
225
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/world-host",
|
|
3
|
+
"version": "2.0.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"twin",
|
|
7
|
+
"host",
|
|
8
|
+
"router",
|
|
9
|
+
"api"
|
|
10
|
+
],
|
|
11
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"volter-host": "dist/src/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "bun test src/*.test.ts",
|
|
19
|
+
"build": "node ../../scripts/publish/build.mjs",
|
|
20
|
+
"prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
|
|
21
|
+
"postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@volter/world-core": "2.0.0",
|
|
25
|
+
"@volter/world-runtime": "2.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@volter/world-console": "2.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@volter/world-console": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@volter/world-console": "2.0.0"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/src/twins-host.d.ts",
|
|
41
|
+
"default": "./dist/src/twins-host.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"src",
|
|
46
|
+
"dist"
|
|
47
|
+
]
|
|
48
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// volter-host — worlds under one URL (see twins-host.ts).
|
|
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>]
|
|
5
|
+
//
|
|
6
|
+
// `<dir>/<org>/<world>/` is a bare world (`volter world init --bare <org>/<world>`). Each world's
|
|
7
|
+
// token is in its own `.volter/token`; each world's serve record names this host's URL. The admin
|
|
8
|
+
// token (`<dir>/.volter-host/admin`) opens the host's own doors — `/-/worlds` — and is printed once.
|
|
9
|
+
import { createWorldHost, type ConsoleMount } from './twins-host.ts';
|
|
10
|
+
|
|
11
|
+
const cmd = process.argv[2];
|
|
12
|
+
const rest = process.argv.slice(3);
|
|
13
|
+
const value = (flag: string): string | undefined => { const i = rest.indexOf(flag); return i >= 0 ? rest[i + 1] : undefined; };
|
|
14
|
+
|
|
15
|
+
if (cmd === 'serve') {
|
|
16
|
+
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); }
|
|
18
|
+
// the console mounts when it is installed beside the host; a host without it serves the doors alone
|
|
19
|
+
const console = await (async (): Promise<ConsoleMount | undefined> => {
|
|
20
|
+
try { const mod = await import('@volter/world-console') as { createConsole: () => ConsoleMount }; return mod.createConsole(); } catch { return undefined; }
|
|
21
|
+
})();
|
|
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`) });
|
|
23
|
+
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
|
+
const shutdown = async () => { await host.stop(); process.exit(0); };
|
|
25
|
+
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
|
|
26
|
+
} else {
|
|
27
|
+
process.stderr.write('usage: 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');
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The host serves worlds under one URL: two bare worlds in a directory, both reachable by their
|
|
2
|
+
// served names, each with its own token and serve record; a world served elsewhere refuses to mount.
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
4
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, existsSync, symlinkSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { forkTwin, observeResources, readTwinRequestJournal, scrubWorld } from '@volter/world-core';
|
|
9
|
+
import { statusWorld } from '@volter/world-runtime';
|
|
10
|
+
import { createWorldHost, worldsUnder, type WorldHostHandle } from './twins-host.ts';
|
|
11
|
+
|
|
12
|
+
const CLI = resolve(import.meta.dir, '..', '..', 'cli', 'src', 'cli.ts');
|
|
13
|
+
const PACKS = resolve(import.meta.dir, '..', '..', 'twin');
|
|
14
|
+
let dir: string; let host: WorldHostHandle;
|
|
15
|
+
const init = (name: string) => { const root = join(dir, ...name.split('/')); mkdirSync(root, { recursive: true }); const r = spawnSync(process.execPath, [CLI, 'world', 'init', '--bare', name, '--twins', 'github'], { cwd: root, encoding: 'utf8' }); if (r.status !== 0) throw new Error(r.stderr); return root; };
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
dir = mkdtempSync(join(tmpdir(), 'world-host-'));
|
|
19
|
+
// a provisioned world resolves its twins above it, as the hosted product's directory does
|
|
20
|
+
mkdirSync(join(dir, 'node_modules', '@volter'), { recursive: true }); symlinkSync(join(PACKS, 'github'), join(dir, 'node_modules', '@volter', 'twin-github'));
|
|
21
|
+
init('acme/team'); init('acme/staging');
|
|
22
|
+
host = await createWorldHost({ dir });
|
|
23
|
+
}, 120_000);
|
|
24
|
+
afterAll(async () => { await host.stop(); rmSync(dir, { recursive: true, force: true }); });
|
|
25
|
+
|
|
26
|
+
describe('the host', () => {
|
|
27
|
+
test('finds the worlds under the directory and serves each under its name', async () => {
|
|
28
|
+
expect(worldsUnder(dir).map((w) => w.name)).toEqual(['acme/staging', 'acme/team']);
|
|
29
|
+
expect(host.worlds.map((w) => w.name)).toEqual(['acme/staging', 'acme/team']);
|
|
30
|
+
const ping = await (await fetch(`${host.url}/-/ping`)).json() as { worlds: string[] };
|
|
31
|
+
expect(ping.worlds.sort()).toEqual(['acme/staging', 'acme/team']);
|
|
32
|
+
});
|
|
33
|
+
test('each world keeps its own token and its serve record names this host', async () => {
|
|
34
|
+
for (const name of ['acme/team', 'acme/staging']) {
|
|
35
|
+
const root = join(dir, ...name.split('/'));
|
|
36
|
+
const token = readFileSync(join(root, '.volter', 'token'), 'utf8').trim();
|
|
37
|
+
const record = JSON.parse(readFileSync(join(root, '.volter', 'serve.json'), 'utf8')) as { base: string; pid: number };
|
|
38
|
+
expect(record.base).toBe(`${host.url}/${name}`);
|
|
39
|
+
expect(record.pid).toBe(process.pid);
|
|
40
|
+
const log = await fetch(`${host.url}/-/${name}/log/github`, { headers: { 'x-volter-token': token } });
|
|
41
|
+
expect(log.status).toBe(200);
|
|
42
|
+
const wrong = await fetch(`${host.url}/-/${name}/log/github`, { headers: { 'x-volter-token': 'tok_nope' } });
|
|
43
|
+
expect(wrong.status).toBe(401);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
test('a world not under the host answers 404 by name; a world served here refuses a second mount', async () => {
|
|
47
|
+
expect((await fetch(`${host.url}/acme/nowhere/github/repos`)).status).toBe(404);
|
|
48
|
+
await expect(createWorldHost({ dir })).rejects.toThrow(/already served at/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("the hosted product's doors", () => {
|
|
53
|
+
const A = () => ({ 'x-volter-token': host.adminToken });
|
|
54
|
+
const worldToken = (name: string) => readFileSync(join(dir, ...name.split('/'), '.volter', 'token'), 'utf8').trim();
|
|
55
|
+
test('the admin token is minted once and kept beside the directory', () => {
|
|
56
|
+
expect(host.adminToken).toMatch(/^tok_a_/);
|
|
57
|
+
expect(readFileSync(join(dir, '.volter-host', 'admin'), 'utf8').trim()).toBe(host.adminToken);
|
|
58
|
+
expect(worldsUnder(dir).map((w) => w.name)).toEqual(['acme/staging', 'acme/team']); // the host's own dir is not a world
|
|
59
|
+
});
|
|
60
|
+
test('the inventory names every world, its address, its tokens and its twins; only the admin token opens it', async () => {
|
|
61
|
+
expect((await fetch(`${host.url}/-/worlds`)).status).toBe(401);
|
|
62
|
+
expect((await fetch(`${host.url}/-/worlds`, { headers: { 'x-volter-token': worldToken('acme/team') } })).status).toBe(401);
|
|
63
|
+
const { worlds } = await (await fetch(`${host.url}/-/worlds`, { headers: A() })).json() as { worlds: Array<{ name: string; base: string; token: string; readToken: string; twins: Array<{ vendor: string; protocol: { major: number } | null; root: unknown }> }> };
|
|
64
|
+
expect(worlds.map((w) => w.name)).toEqual(['acme/staging', 'acme/team']);
|
|
65
|
+
const team = worlds.find((w) => w.name === 'acme/team')!;
|
|
66
|
+
expect(team.base).toBe(`${host.url}/acme/team`);
|
|
67
|
+
expect(team.token).toBe(worldToken('acme/team'));
|
|
68
|
+
expect(team.readToken).toMatch(/^tok_r_/);
|
|
69
|
+
expect(team.twins).toMatchObject([{ vendor: 'github', protocol: { major: 2 }, root: null }]);
|
|
70
|
+
});
|
|
71
|
+
test('the admin token opens nothing under a world', async () => {
|
|
72
|
+
expect((await fetch(`${host.url}/-/acme/team/log/github`, { headers: A() })).status).toBe(401);
|
|
73
|
+
expect((await fetch(`${host.url}/acme/team/github/repos`, { headers: A() })).status).toBe(401);
|
|
74
|
+
});
|
|
75
|
+
test('provision → the CLI clones it → rotate: the old token refuses, the new one opens → remove', async () => {
|
|
76
|
+
const bad = await fetch(`${host.url}/-/worlds`, { method: 'POST', headers: { ...A(), 'content-type': 'application/json' }, body: JSON.stringify({ org: 'acme', world: '../x', vendors: ['github'] }) });
|
|
77
|
+
expect(bad.status).toBe(400);
|
|
78
|
+
const res = await fetch(`${host.url}/-/worlds`, { method: 'POST', headers: { ...A(), 'content-type': 'application/json' }, body: JSON.stringify({ org: 'acme', world: 'preview', vendors: ['github'] }) });
|
|
79
|
+
expect(res.status).toBe(201);
|
|
80
|
+
const made = await res.json() as { name: string; base: string; token: string; readToken: string; twins: Array<{ vendor: string }> };
|
|
81
|
+
expect(made.name).toBe('acme/preview'); expect(made.base).toBe(`${host.url}/acme/preview`);
|
|
82
|
+
expect(made.twins.map((t) => t.vendor)).toEqual(['github']);
|
|
83
|
+
expect(worldToken('acme/preview')).toBe(made.token);
|
|
84
|
+
const ping = await (await fetch(`${host.url}/-/ping`)).json() as { worlds: string[] };
|
|
85
|
+
expect(ping.worlds).toContain('acme/preview');
|
|
86
|
+
expect((await fetch(`${host.url}/-/acme/preview/log/github`, { headers: { 'x-volter-token': made.token } })).status).toBe(200);
|
|
87
|
+
expect((await fetch(`${host.url}/-/worlds`, { method: 'POST', headers: { ...A(), 'content-type': 'application/json' }, body: JSON.stringify({ org: 'acme', world: 'preview', vendors: ['github'] }) })).status).toBe(409);
|
|
88
|
+
// a person holding the handed-out token clones the world with the real CLI
|
|
89
|
+
const app = init('clones/preview'); // a world of one's own, then the clone (share-a-world guide)
|
|
90
|
+
// a clone boots the world, and its twins stay up the way `volter world up` leaves them — so the
|
|
91
|
+
// CLI writes to a file, not a pipe the servers would hold open, and `down` follows
|
|
92
|
+
const volter = async (...args: string[]): Promise<void> => {
|
|
93
|
+
const log = join(app, `${args[1]}.log`);
|
|
94
|
+
const proc = Bun.spawn({ cmd: [process.execPath, CLI, ...args], cwd: app, stdout: Bun.file(log), stderr: Bun.file(log) });
|
|
95
|
+
if (await proc.exited !== 0) throw new Error(`volter ${args.join(' ')} failed:\n${readFileSync(log, 'utf8')}`);
|
|
96
|
+
};
|
|
97
|
+
try { await volter('world', 'clone', made.base, '--token', made.token); }
|
|
98
|
+
finally { await volter('world', 'down'); }
|
|
99
|
+
// rotate: both tokens anew; the old die with the response
|
|
100
|
+
const rotated = await (await fetch(`${host.url}/-/worlds/acme/preview/rotate`, { method: 'POST', headers: A() })).json() as { token: string; readToken: string };
|
|
101
|
+
expect(rotated.token).not.toBe(made.token);
|
|
102
|
+
expect((await fetch(`${host.url}/-/acme/preview/log/github`, { headers: { 'x-volter-token': made.token } })).status).toBe(401);
|
|
103
|
+
expect((await fetch(`${host.url}/-/acme/preview/log/github`, { headers: { 'x-volter-token': rotated.token } })).status).toBe(200);
|
|
104
|
+
expect(worldToken('acme/preview')).toBe(rotated.token);
|
|
105
|
+
const inventory = await (await fetch(`${host.url}/-/worlds`, { headers: A() })).json() as { worlds: Array<{ name: string; token: string }> };
|
|
106
|
+
expect(inventory.worlds.find((w) => w.name === 'acme/preview')?.token).toBe(rotated.token);
|
|
107
|
+
// remove: gone from the host and from disk
|
|
108
|
+
expect((await fetch(`${host.url}/-/worlds/acme/preview`, { method: 'DELETE', headers: A() })).status).toBe(200);
|
|
109
|
+
expect((await fetch(`${host.url}/-/acme/preview/log/github`, { headers: { 'x-volter-token': rotated.token } })).status).toBe(404);
|
|
110
|
+
expect(existsSync(join(dir, 'acme', 'preview'))).toBe(false);
|
|
111
|
+
expect((await fetch(`${host.url}/-/worlds/acme/preview`, { method: 'DELETE', headers: A() })).status).toBe(404);
|
|
112
|
+
}, 120_000);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
test('host deletion refuses a local dependent branch while keeping the parent served', async () => {
|
|
117
|
+
const headers = { 'x-volter-token': host.adminToken, 'content-type': 'application/json' };
|
|
118
|
+
const child = mkdtempSync(join(tmpdir(), 'host-lineage-child-'));
|
|
119
|
+
const response = await fetch(`${host.url}/-/worlds`, { method: 'POST', headers, body: JSON.stringify({ org: 'acme', world: 'lineage', vendors: ['github'] }) });
|
|
120
|
+
expect(response.status).toBe(201);
|
|
121
|
+
const made = await response.json() as { token: string };
|
|
122
|
+
const root = join(dir, 'acme', 'lineage');
|
|
123
|
+
try {
|
|
124
|
+
const parent = join(statusWorld('lineage', root).dirs.data, 'github');
|
|
125
|
+
await fetch(`${host.url}/acme/lineage/github/repos/acme/ancestry/issues`, { headers: { 'x-volter-token': made.token } });
|
|
126
|
+
expect(readTwinRequestJournal('github', parent).some(entry => entry.path === '/repos/acme/ancestry/issues')).toBe(true);
|
|
127
|
+
observeResources('github', [{ type: 'issue', id: 'ancestry-one', fields: { title: 'retained' } }], { root: parent, at: '2026-09-11T00:00:00Z' });
|
|
128
|
+
forkTwin({ service: 'github', fromRoot: parent, toRoot: child, occurredAt: '2026-09-11T00:00:00Z' });
|
|
129
|
+
const refused = await fetch(`${host.url}/-/worlds/acme/lineage`, { method: 'DELETE', headers });
|
|
130
|
+
expect(refused.status).toBe(409);
|
|
131
|
+
expect(await refused.text()).toContain('dependent branch');
|
|
132
|
+
expect(existsSync(root)).toBe(true);
|
|
133
|
+
expect((await fetch(`${host.url}/-/acme/lineage/log/github`, { headers: { 'x-volter-token': made.token } })).status).toBe(200);
|
|
134
|
+
scrubWorld({ root: child, force: true });
|
|
135
|
+
expect((await fetch(`${host.url}/-/worlds/acme/lineage`, { method: 'DELETE', headers })).status).toBe(200);
|
|
136
|
+
expect(existsSync(root)).toBe(false);
|
|
137
|
+
} finally {
|
|
138
|
+
scrubWorld({ root: child, force: true });
|
|
139
|
+
await fetch(`${host.url}/-/worlds/acme/lineage`, { method: 'DELETE', headers });
|
|
140
|
+
rmSync(child, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
}, 60000);
|
|
143
|
+
|
|
144
|
+
test('a provision failure after boot stops compute before removing its records', async () => {
|
|
145
|
+
const isolated = mkdtempSync(join(tmpdir(), 'host-provision-failure-'));
|
|
146
|
+
const baseline = join(isolated, 'acme', 'baseline'); mkdirSync(baseline, { recursive: true });
|
|
147
|
+
mkdirSync(join(isolated, 'node_modules', '@volter'), { recursive: true });
|
|
148
|
+
symlinkSync(join(PACKS, 'github'), join(isolated, 'node_modules', '@volter', 'twin-github'));
|
|
149
|
+
const initialized = spawnSync(process.execPath, [CLI, 'world', 'init', '--bare', 'acme/baseline', '--twins', 'github'], { cwd: baseline, encoding: 'utf8' });
|
|
150
|
+
expect(initialized.status).toBe(0);
|
|
151
|
+
let madeRoot = ''; let holderPid = 0;
|
|
152
|
+
const ownedHost = await createWorldHost({ dir: isolated, announce: (line) => {
|
|
153
|
+
if (line.includes('acme/rejected')) {
|
|
154
|
+
madeRoot = join(isolated, 'acme', 'rejected');
|
|
155
|
+
const status = statusWorld('rejected', madeRoot);
|
|
156
|
+
holderPid = status.lifecycle?.holderPid ?? status.resources?.holderPid ?? status.livePids[0] ?? Object.values(status.services)[0]?.pid ?? 0;
|
|
157
|
+
throw new Error('announcement failed after boot');
|
|
158
|
+
}
|
|
159
|
+
} });
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(`${ownedHost.url}/-/worlds`, { method: 'POST', headers: { 'x-volter-token': ownedHost.adminToken, 'content-type': 'application/json' }, body: JSON.stringify({ org: 'acme', world: 'rejected', vendors: ['github'] }) });
|
|
162
|
+
expect(response.status).toBe(500);
|
|
163
|
+
expect(await response.text()).toContain('announcement failed');
|
|
164
|
+
expect(holderPid).toBeGreaterThan(0);
|
|
165
|
+
expect(existsSync(madeRoot) ? readdirSync(madeRoot, { recursive: true }) : []).toEqual([]);
|
|
166
|
+
expect(existsSync(madeRoot)).toBe(false);
|
|
167
|
+
const state = spawnSync('ps', ['-o', 'state=', '-p', String(holderPid)], { encoding: 'utf8' });
|
|
168
|
+
expect(state.status === 1 || (state.status === 0 && state.stdout.trim().startsWith('Z'))).toBe(true);
|
|
169
|
+
} finally { await ownedHost.stop(); rmSync(isolated, { recursive: true, force: true }); }
|
|
170
|
+
}, 60000);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// THE HOST (company contract "Just like Neon", 4; "The hosted product's doors"): worlds under ONE
|
|
2
|
+
// URL. A directory holds worlds — `<dir>/<org>/<world>/` is a bare world — and the host mounts each
|
|
3
|
+
// on the same port under its served name: `/<org>/<world>/<vendor>/…` is the twin's wire and
|
|
4
|
+
// `/-/<org>/<world>/…` its doors, exactly as `volter world serve` gives one world. Every world keeps
|
|
5
|
+
// its own tokens, its own serve record (the host's URL), its own roots and credentials. A world is
|
|
6
|
+
// served once: a world already served elsewhere refuses to mount here, by name and pid.
|
|
7
|
+
//
|
|
8
|
+
// The host's OWN doors are the hosted product's: an ADMIN token — minted once, kept beside the
|
|
9
|
+
// directory (`<dir>/.volter-host/admin`, 0600), stable across restarts, never an env var — opens
|
|
10
|
+
// `/-/worlds`: the inventory (every world, its address, its tokens, its twins), provision (a new
|
|
11
|
+
// bare world mounted without a restart), rotate (both tokens anew; the old die with the response)
|
|
12
|
+
// and remove. The admin token opens NOTHING under a world, and a world's token opens nothing here:
|
|
13
|
+
// the platform holds the admin token, the console holds world tokens, neither is the other.
|
|
14
|
+
//
|
|
15
|
+
// Nothing of the vendor lives here — no arms, no keys, no folds: the host is the shell; the kernel
|
|
16
|
+
// serves. The v1 host (namespaces, links, the push flip, the R2 shell) left with v1.
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { assertStateRemovable, serveHttp, withAncestryLock, withStateRemoval } from '@volter/world-core';
|
|
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';
|
|
21
|
+
|
|
22
|
+
export type WorldHostOptions = {
|
|
23
|
+
/** the directory of worlds: `<dir>/<org>/<world>/` is a bare world */
|
|
24
|
+
dir: string;
|
|
25
|
+
/** keep the kernel's request journal for every twin served here (layer 8: the world's request report); on unless told otherwise */
|
|
26
|
+
requestJournal?: boolean;
|
|
27
|
+
host?: string;
|
|
28
|
+
port?: number;
|
|
29
|
+
announce?: (line: string) => void;
|
|
30
|
+
/** the console, when installed (`@volter/world-console`): served on a listener of its own, its doors
|
|
31
|
+
* forwarded here, so no script a twin serves shares its origin (world-runtime console-apart.ts) */
|
|
32
|
+
console?: ConsoleMount;
|
|
33
|
+
/** the console's port; the host's port + 1 when absent (a stable address), else one the system picks */
|
|
34
|
+
consolePort?: number;
|
|
35
|
+
/** the origin the console is REACHED at, when it differs from its bind address (as `advertise`) */
|
|
36
|
+
consoleAdvertise?: string;
|
|
37
|
+
/** The origin this host is REACHED at, when it differs from the bind address — a container binding
|
|
38
|
+
* 0.0.0.0 behind a port map, a host behind a proxy. Every world's `base`, its serve record and the
|
|
39
|
+
* printed URLs use it; without it the bind address stands (loopback runs). */
|
|
40
|
+
advertise?: string;
|
|
41
|
+
};
|
|
42
|
+
export { CONSOLE_BASE, type ConsoleMount };
|
|
43
|
+
export type WorldHostHandle = { url: string; port: number; adminToken: string; console: string | null; worlds: Array<{ name: string; base: string }>; stop: () => Promise<void> };
|
|
44
|
+
/** 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 }> };
|
|
46
|
+
|
|
47
|
+
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
48
|
+
|
|
49
|
+
/** The bare worlds under a directory: `<dir>/<org>/<world>/.volter/world.json`. */
|
|
50
|
+
export function worldsUnder(dir: string): Array<{ name: string; root: string }> {
|
|
51
|
+
const out: Array<{ name: string; root: string }> = [];
|
|
52
|
+
const base = resolve(dir);
|
|
53
|
+
if (!existsSync(base)) return out;
|
|
54
|
+
for (const org of readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')) {
|
|
55
|
+
for (const world of readdirSync(join(base, org.name), { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
56
|
+
const root = join(base, org.name, world.name);
|
|
57
|
+
if (existsSync(join(root, '.volter', 'world.json'))) out.push({ name: `${org.name}/${world.name}`, root });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The host's admin token: minted once, kept at `<dir>/.volter-host/admin` (0600). */
|
|
64
|
+
export function adminTokenFor(dir: string): string {
|
|
65
|
+
const path = join(resolve(dir), '.volter-host', 'admin');
|
|
66
|
+
try { const t = readFileSync(path, 'utf8').trim(); if (t.startsWith('tok_a_')) return t; } catch { /* none yet */ }
|
|
67
|
+
const token = `tok_a_${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`;
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
writeFileSync(path, `${token}\n`, { mode: 0o600 }); chmodSync(path, 0o600);
|
|
70
|
+
return token;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createWorldHost(options: WorldHostOptions): Promise<WorldHostHandle> {
|
|
74
|
+
// the twins this host boots inherit its env: the kernel's journal switch, set by the product, not by an operator
|
|
75
|
+
if (options.requestJournal !== false) process.env.VOLTER_TWIN_REQUEST_JOURNAL = '1';
|
|
76
|
+
const dir = resolve(options.dir);
|
|
77
|
+
const found = worldsUnder(dir);
|
|
78
|
+
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
|
+
const adminToken = adminTokenFor(dir);
|
|
80
|
+
const mount = async (w: { name: string; root: string }): Promise<MountedWorld> => {
|
|
81
|
+
const config = loadWorldConfig(join(w.root, '.volter', 'world.json'), w.root).config;
|
|
82
|
+
return mountWorld(config.id, { root: w.root });
|
|
83
|
+
};
|
|
84
|
+
const byServed = new Map<string, MountedWorld>();
|
|
85
|
+
for (const w of found) byServed.set(w.name, await mount(w));
|
|
86
|
+
const host = options.host ?? '127.0.0.1';
|
|
87
|
+
let url = '';
|
|
88
|
+
const json = (body: unknown, status = 200): Response => Response.json(body, { status });
|
|
89
|
+
|
|
90
|
+
/** One inventory row, the twins read through the world's own status door with its own token. */
|
|
91
|
+
const row = async (m: MountedWorld): Promise<WorldInventoryRow> => {
|
|
92
|
+
const [org, world] = m.served.split('/') as [string, string];
|
|
93
|
+
const twins: WorldInventoryRow['twins'] = [];
|
|
94
|
+
for (const vendor of m.twins()) {
|
|
95
|
+
const res = await m.handle(new Request(`${url}/-/${m.served}/twins/${vendor}/status`, { headers: { [TOKEN_HEADER]: m.token } }));
|
|
96
|
+
const status = res.ok ? (await res.json()) as { protocol?: unknown; root?: unknown } : {};
|
|
97
|
+
twins.push({ vendor, protocol: status.protocol ?? null, root: status.root ?? null });
|
|
98
|
+
}
|
|
99
|
+
return { org, world, name: m.served, base: `${url}/${m.served}`, token: m.token, readToken: m.readToken, twins };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const checkAncestry = (path: string): void => {
|
|
103
|
+
for (const world of worldsUnder(dir)) assertWorldStateRemovable(world.root, path);
|
|
104
|
+
assertStateRemovable(path);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const admin = async (request: Request, path: string): Promise<Response> => {
|
|
108
|
+
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);
|
|
110
|
+
if (!m) return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
111
|
+
const [, org, world, verb] = m;
|
|
112
|
+
if (!org) {
|
|
113
|
+
if (request.method === 'GET') { const rows: WorldInventoryRow[] = []; for (const w of byServed.values()) rows.push(await row(w)); return json({ worlds: rows }); }
|
|
114
|
+
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); }
|
|
117
|
+
const vendors = Array.isArray(body.vendors) ? body.vendors.filter((v): v is string => typeof v === 'string' && NAME.test(v)) : [];
|
|
118
|
+
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
|
+
const name = `${body.org}/${body.world}`; const root = join(dir, body.org, body.world);
|
|
120
|
+
if (byServed.has(name) || existsSync(root)) return json({ error: `${name} exists` }, 409);
|
|
121
|
+
let acquired: MountedWorld | undefined;
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(root, { recursive: true });
|
|
124
|
+
const result = initWorld(body.world, root, { root, vendors, bare: name });
|
|
125
|
+
if (!result.ok) throw new Error(`init refused: ${JSON.stringify(result.plan)}`);
|
|
126
|
+
const mounted = await mount({ name, root });
|
|
127
|
+
acquired = mounted;
|
|
128
|
+
await mounted.boot(url);
|
|
129
|
+
byServed.set(name, mounted);
|
|
130
|
+
options.announce?.(`serving ${name} ${url}/${name}`);
|
|
131
|
+
return json(await row(mounted), 201);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
try {
|
|
134
|
+
if (acquired) await acquired.stop();
|
|
135
|
+
withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
|
|
136
|
+
byServed.delete(name);
|
|
137
|
+
} catch (cleanup) {
|
|
138
|
+
if (acquired) byServed.set(name, acquired);
|
|
139
|
+
return json({ error: `provision ${name} failed; cleanup incomplete, World retained: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}` }, 500);
|
|
140
|
+
}
|
|
141
|
+
return json({ error: `provision ${name}: ${error instanceof Error ? error.message : String(error)}` }, 500);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
145
|
+
}
|
|
146
|
+
const name = `${org}/${world}`; const mounted = byServed.get(name);
|
|
147
|
+
if (!mounted) return json({ error: `no world ${name} here` }, 404);
|
|
148
|
+
if (verb === 'rotate' && request.method === 'POST') return json({ name, ...mounted.rotate() });
|
|
149
|
+
if (!verb && request.method === 'DELETE') {
|
|
150
|
+
try {
|
|
151
|
+
withAncestryLock(() => checkAncestry(mounted.root));
|
|
152
|
+
await mounted.stop();
|
|
153
|
+
withStateRemoval(mounted.root, () => rmSync(mounted.root, { recursive: true, force: true }), () => checkAncestry(mounted.root));
|
|
154
|
+
byServed.delete(name);
|
|
155
|
+
return json({ removed: name });
|
|
156
|
+
} catch (error) { return json({ error: error instanceof Error ? error.message : String(error) }, 409); }
|
|
157
|
+
}
|
|
158
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
let consoleUrl: string | null = null;
|
|
162
|
+
const server = await serveHttp({
|
|
163
|
+
hostname: host, port: options.port ?? 0,
|
|
164
|
+
// This front serves many Worlds and lifecycle doors. Capturing one vendor identity
|
|
165
|
+
// here misattributes requests and can recreate a removed World after its response.
|
|
166
|
+
twinRequestJournal: false,
|
|
167
|
+
async fetch(request) {
|
|
168
|
+
const path = new URL(request.url).pathname;
|
|
169
|
+
const moved = consoleRedirect(request, consoleUrl ? consoleUrl.slice(0, -`${CONSOLE_BASE}/`.length) : null);
|
|
170
|
+
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);
|
|
173
|
+
const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
|
|
174
|
+
const world = m ? byServed.get(`${m[1]}/${m[2]}`) : undefined;
|
|
175
|
+
if (!world) return json({ error: `no world at ${path}: this host serves ${[...byServed.keys()].join(', ')}` }, 404);
|
|
176
|
+
return world.handle(request);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
url = options.advertise ? options.advertise.replace(/\/+$/, '') : `http://${host}:${server.port}`;
|
|
180
|
+
let apart: Awaited<ReturnType<typeof serveConsoleApart>> | null = null;
|
|
181
|
+
try {
|
|
182
|
+
for (const m of byServed.values()) { await m.boot(url); options.announce?.(`serving ${m.served} ${url}/${m.served}`); }
|
|
183
|
+
if (options.console) {
|
|
184
|
+
// 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 });
|
|
187
|
+
if (options.consolePort !== undefined) apart = await serveApart(options.consolePort);
|
|
188
|
+
else { try { apart = await serveApart((server.port ?? 0) ? (server.port ?? 0) + 1 : 0); } catch { apart = await serveApart(0); } }
|
|
189
|
+
consoleUrl = `${(options.consoleAdvertise ?? apart.url).replace(/\/+$/, '')}${CONSOLE_BASE}/`;
|
|
190
|
+
}
|
|
191
|
+
} catch (error) { await server.stop(true); for (const m of byServed.values()) await m.stop().catch(() => undefined); throw error; }
|
|
192
|
+
return {
|
|
193
|
+
url, port: server.port ?? 0, adminToken, console: consoleUrl,
|
|
194
|
+
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(); },
|
|
196
|
+
};
|
|
197
|
+
}
|