@volter/world-runtime 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/LICENSE +202 -0
- package/dist/known-external-services.json +1108 -0
- package/dist/src/ancestry.d.ts +2 -0
- package/dist/src/ancestry.js +42 -0
- package/dist/src/app-url.d.ts +47 -0
- package/dist/src/app-url.js +239 -0
- package/dist/src/attach.d.ts +48 -0
- package/dist/src/attach.js +87 -0
- package/dist/src/branch.d.ts +20 -0
- package/dist/src/branch.js +65 -0
- package/dist/src/browser-proxy-cli.d.ts +2 -0
- package/dist/src/browser-proxy-cli.js +41 -0
- package/dist/src/ca-trust.d.ts +5 -0
- package/dist/src/ca-trust.js +64 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +148 -0
- package/dist/src/changeset.d.ts +142 -0
- package/dist/src/changeset.js +570 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +1262 -0
- package/dist/src/command-lifetime.d.ts +15 -0
- package/dist/src/command-lifetime.js +98 -0
- package/dist/src/configs.d.ts +18 -0
- package/dist/src/configs.js +119 -0
- package/dist/src/console-apart.d.ts +38 -0
- package/dist/src/console-apart.js +107 -0
- package/dist/src/consumers.d.ts +46 -0
- package/dist/src/consumers.js +200 -0
- package/dist/src/covers.d.ts +183 -0
- package/dist/src/covers.js +800 -0
- package/dist/src/fixture-env.d.ts +42 -0
- package/dist/src/fixture-env.js +221 -0
- package/dist/src/host-cli.d.ts +2 -0
- package/dist/src/host-cli.js +92 -0
- package/dist/src/host-fault-fixture.d.ts +32 -0
- package/dist/src/host-fault-fixture.js +100 -0
- package/dist/src/host-worker.d.ts +1 -0
- package/dist/src/host-worker.js +23 -0
- package/dist/src/host.d.ts +38 -0
- package/dist/src/host.js +135 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +35 -0
- package/dist/src/infra-cli.d.ts +2 -0
- package/dist/src/infra-cli.js +136 -0
- package/dist/src/init.d.ts +227 -0
- package/dist/src/init.js +1117 -0
- package/dist/src/inject-map.d.ts +34 -0
- package/dist/src/inject-map.js +56 -0
- package/dist/src/lifecycle-record.d.ts +47 -0
- package/dist/src/lifecycle-record.js +196 -0
- package/dist/src/origin.d.ts +31 -0
- package/dist/src/origin.js +139 -0
- package/dist/src/pack-facts.d.ts +75 -0
- package/dist/src/pack-facts.js +98 -0
- package/dist/src/pglite-backing.d.ts +21 -0
- package/dist/src/pglite-backing.js +158 -0
- package/dist/src/pglite-host.mjs +147 -0
- package/dist/src/placeholder.d.ts +20 -0
- package/dist/src/placeholder.js +100 -0
- package/dist/src/prerequisites.d.ts +21 -0
- package/dist/src/prerequisites.js +49 -0
- package/dist/src/process-groups.d.ts +4 -0
- package/dist/src/process-groups.js +49 -0
- package/dist/src/project-inspect.d.ts +109 -0
- package/dist/src/project-inspect.js +827 -0
- package/dist/src/proxy-daemon.d.ts +2 -0
- package/dist/src/proxy-daemon.js +18 -0
- package/dist/src/redirect-proxy.d.ts +105 -0
- package/dist/src/redirect-proxy.js +665 -0
- package/dist/src/reflect.d.ts +74 -0
- package/dist/src/reflect.js +392 -0
- package/dist/src/resources.d.ts +26 -0
- package/dist/src/resources.js +22 -0
- package/dist/src/root.d.ts +114 -0
- package/dist/src/root.js +312 -0
- package/dist/src/run-task-worker.d.ts +1 -0
- package/dist/src/run-task-worker.js +38 -0
- package/dist/src/run-task.d.ts +18 -0
- package/dist/src/run-task.js +48 -0
- package/dist/src/runtime-test-support.d.ts +59 -0
- package/dist/src/runtime-test-support.js +205 -0
- package/dist/src/runtime.d.ts +256 -0
- package/dist/src/runtime.js +3502 -0
- package/dist/src/schema.d.ts +449 -0
- package/dist/src/schema.js +605 -0
- package/dist/src/serve.d.ts +30 -0
- package/dist/src/serve.js +82 -0
- package/dist/src/served-world.d.ts +194 -0
- package/dist/src/served-world.js +986 -0
- package/dist/src/service-exit.d.ts +46 -0
- package/dist/src/service-exit.js +195 -0
- package/dist/src/service-recorder.d.ts +1 -0
- package/dist/src/service-recorder.js +121 -0
- package/dist/src/sibling.d.ts +1 -0
- package/dist/src/sibling.js +9 -0
- package/dist/src/signals.d.ts +1 -0
- package/dist/src/signals.js +11 -0
- package/dist/src/storage-capacity.d.ts +8 -0
- package/dist/src/storage-capacity.js +61 -0
- package/dist/src/tail.d.ts +30 -0
- package/dist/src/tail.js +160 -0
- package/dist/src/tcp-port.d.ts +2 -0
- package/dist/src/tcp-port.js +36 -0
- package/dist/src/up-task-worker.d.ts +1 -0
- package/dist/src/up-task-worker.js +61 -0
- package/dist/src/up-task.d.ts +17 -0
- package/dist/src/up-task.js +49 -0
- package/dist/src/websocket-relay.d.ts +3 -0
- package/dist/src/websocket-relay.js +40 -0
- package/known-external-services.json +1108 -0
- package/package.json +83 -0
- package/src/ancestry.ts +36 -0
- package/src/app-url.ts +253 -0
- package/src/attach.ts +117 -0
- package/src/branch.ts +63 -0
- package/src/browser-proxy-cli.ts +44 -0
- package/src/ca-trust.ts +57 -0
- package/src/catalog.ts +156 -0
- package/src/changeset.ts +627 -0
- package/src/cli.ts +1111 -0
- package/src/command-lifetime.ts +79 -0
- package/src/configs.ts +110 -0
- package/src/console-apart.ts +90 -0
- package/src/consumers.ts +185 -0
- package/src/covers.ts +934 -0
- package/src/fixture-env.ts +230 -0
- package/src/host-cli.ts +90 -0
- package/src/host-worker.ts +23 -0
- package/src/host.ts +169 -0
- package/src/index.ts +171 -0
- package/src/infra-cli.ts +133 -0
- package/src/init.ts +1316 -0
- package/src/inject-map.ts +72 -0
- package/src/lifecycle-record.ts +168 -0
- package/src/origin.ts +134 -0
- package/src/pack-facts.ts +128 -0
- package/src/pglite-backing.ts +141 -0
- package/src/pglite-host.mjs +147 -0
- package/src/placeholder.ts +89 -0
- package/src/prerequisites.ts +66 -0
- package/src/process-groups.ts +33 -0
- package/src/project-inspect.ts +770 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +684 -0
- package/src/reflect.ts +440 -0
- package/src/resources.ts +22 -0
- package/src/root.ts +290 -0
- package/src/run-task-worker.ts +27 -0
- package/src/run-task.ts +44 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +3357 -0
- package/src/schema.ts +922 -0
- package/src/serve.ts +102 -0
- package/src/served-world.ts +812 -0
- package/src/service-exit.ts +175 -0
- package/src/service-recorder.ts +89 -0
- package/src/sibling.ts +10 -0
- package/src/signals.ts +10 -0
- package/src/storage-capacity.ts +60 -0
- package/src/tail.ts +205 -0
- package/src/tcp-port.ts +35 -0
- package/src/up-task-worker.ts +40 -0
- package/src/up-task.ts +45 -0
- package/src/websocket-relay.ts +32 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { getActiveWorldStore, withAncestryLock, captureHistory, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } from '@volter/world-core';
|
|
2
|
+
// A SERVED WORLD (docs/reference/http-api.md#a-served-world): `volter world serve` puts a world at
|
|
3
|
+
// `http://<host>:<port>/<org>/<world>/`. Each twin's vendor API is under its vendor id — the
|
|
4
|
+
// addressed grammar RH2 depends on — and the world's own doors are under `/-/<org>/<world>/`:
|
|
5
|
+
// GET log/<twin>?after=<n> the twin's whole log from a position (what fetch reads)
|
|
6
|
+
// GET checkpoint/<twin> the tree, and the position it stands at (what clone starts from)
|
|
7
|
+
// GET changesets[/<name>] what landed, with receipts, verification and approvals
|
|
8
|
+
// POST push a changeset: its entries land on the world's log if its base is
|
|
9
|
+
// current (else 409 with the drift); under deploy: auto the
|
|
10
|
+
// receipts are in the answer, otherwise each entry is `landed`
|
|
11
|
+
// POST changesets/<name>/verify run the world's checks over it and record the result
|
|
12
|
+
// POST changesets/<name>/approve { as, note? } sign its current hash
|
|
13
|
+
// POST deploy[/<name>] perform landed entries against each root, by policy
|
|
14
|
+
// GET/PUT twins/<vendor>/root the vendor's real account behind the twin (PUT null clears)
|
|
15
|
+
// PUT twins/<vendor>/credential seal the credential; GET answers only that one is sealed
|
|
16
|
+
// POST twins/<vendor>/refresh observe the root now
|
|
17
|
+
// Tokens ride `x-volter-token`: the token opens everything, the read token only GET. `serve` mints
|
|
18
|
+
// both, prints them once and writes them to `.volter/token` and `.volter/token.read` (0600).
|
|
19
|
+
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
21
|
+
import { blobDigest, getActiveBlobStore, loadCheck, mirrorShellUnder, openSealedCredential, readTwinRequestJournal, sealingKey, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
|
|
22
|
+
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
23
|
+
import { dirname, join, resolve } from 'node:path';
|
|
24
|
+
import {
|
|
25
|
+
appendActionIfAbsent, branchEntries, confirmAction, readTree, stateDirName, wholeLog, worldNow,
|
|
26
|
+
parentEntries, rebaseBranch, type Changeset, type Entry, type Receipt, type RootConfig,
|
|
27
|
+
} from '@volter/world-core';
|
|
28
|
+
import { approveWorldChangeset, createWorldChangeset, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
|
|
29
|
+
import { fetchFromOrigin } from './origin.ts';
|
|
30
|
+
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
|
|
31
|
+
import { loadWorldConfig } from './configs.ts';
|
|
32
|
+
import { adaptersFor, packMirrorExports, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from './root.ts';
|
|
33
|
+
import { downWorld, statusWorld, upWorld } from './runtime.ts';
|
|
34
|
+
import type { WorldInstance } from './schema.ts';
|
|
35
|
+
|
|
36
|
+
export const TOKEN_HEADER = 'x-volter-token';
|
|
37
|
+
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
38
|
+
export const TWINS_KEY_HEADER = 'x-twins-key';
|
|
39
|
+
class Booting extends Error { constructor() { super('the world is still booting'); } }
|
|
40
|
+
|
|
41
|
+
/** fetch that waits out a world still booting: a refused connection is retried for `forMs`. */
|
|
42
|
+
export async function fetchReady(input: string, init: RequestInit, forMs = 8000): Promise<Response> {
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
for (;;) {
|
|
45
|
+
try { return await fetch(input, init); } catch (error) {
|
|
46
|
+
if (Date.now() - started > forMs) throw error;
|
|
47
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const MAX_BODY = 4 * 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
/** The serve record beside a world, when the process it names is alive; a stale record (its serving
|
|
54
|
+
* process gone) is removed and reads as none. */
|
|
55
|
+
export type ServeRecord = { name: string; url: string; base: string; port: number; pid: number; startedAt: string };
|
|
56
|
+
export function readServeRecord(worldRoot: string): ServeRecord | null {
|
|
57
|
+
const path = join(resolve(worldRoot), stateDirName(), 'serve.json');
|
|
58
|
+
if (!existsSync(path)) return null;
|
|
59
|
+
let rec: ServeRecord;
|
|
60
|
+
try { rec = JSON.parse(readFileSync(path, 'utf8')) as ServeRecord; } catch { rmSync(path, { force: true }); return null; }
|
|
61
|
+
try { process.kill(rec.pid, 0); } catch { rmSync(path, { force: true }); return null; }
|
|
62
|
+
return rec;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ServedWorld = { url: string; base: string; name: string; port: number; token: string; readToken: string; console: string | null; stop: () => Promise<void> };
|
|
66
|
+
|
|
67
|
+
function mintToken(prefix: string): string { return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`; }
|
|
68
|
+
function writeSecret(path: string, value: string): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${value}\n`, { mode: 0o600 }); chmodSync(path, 0o600); }
|
|
69
|
+
|
|
70
|
+
/** The served name: `bare.name` (`acme/team`), else `<id>/<id>`. */
|
|
71
|
+
export function servedName(worldRoot: string, configRef: string): string {
|
|
72
|
+
const { config } = loadWorldConfig(configRef, worldRoot);
|
|
73
|
+
return config.bare?.name ?? `${config.id}/${config.id}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function configRefFor(worldRoot: string, name: string): string {
|
|
77
|
+
const inRepo = join(worldRoot, stateDirName(), 'world.json');
|
|
78
|
+
return existsSync(inRepo) ? inRepo : name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A world MOUNTED for serving: its doors, its tokens, and a boot that writes the serve record once the
|
|
82
|
+
* URL is known. `serveWorld` mounts one world on its own port; the host mounts many under one URL
|
|
83
|
+
* (contract "Just like Neon", 4: one URL per served world, solid and discoverable). */
|
|
84
|
+
/** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
|
|
85
|
+
* `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
|
|
86
|
+
* with the call — and keeps them beside the world like `boot` does. */
|
|
87
|
+
export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
|
|
88
|
+
export async function mountWorld(name: string, opts: { root?: string } = {}): Promise<MountedWorld> {
|
|
89
|
+
const worldRoot = resolve(opts.root ?? process.cwd());
|
|
90
|
+
const configRef = configRefFor(worldRoot, name);
|
|
91
|
+
if (loadWorldConfig(configRef, worldRoot).config.resources) {
|
|
92
|
+
throw new Error('Hosted World has legacy capacity declarations. Review and migrate its hosting limits before adopting runtime v3; no declared bound was silently removed.');
|
|
93
|
+
}
|
|
94
|
+
const served = servedName(worldRoot, configRef);
|
|
95
|
+
// a world is served once: the serve record beside it names the URL, port and pid serving it; a live
|
|
96
|
+
// one refuses this mount before anything is bound
|
|
97
|
+
const record = join(worldRoot, stateDirName(), 'serve.json');
|
|
98
|
+
const live = readServeRecord(worldRoot);
|
|
99
|
+
if (live) throw new Error(`${served} is already served at ${live.base} (pid ${live.pid}) — use it, or \`volter world down\` there first`);
|
|
100
|
+
// the world's tokens are STABLE across restarts: a world that comes back is the same world to
|
|
101
|
+
// everyone holding its token (a clone's origin, a root's sealed credential); minted once, kept
|
|
102
|
+
const held = (file: string): string | null => { try { const t = readFileSync(join(worldRoot, stateDirName(), file), 'utf8').trim(); return t.startsWith('tok_') ? t : null; } catch { return null; } };
|
|
103
|
+
let instance: WorldInstance | null = null;
|
|
104
|
+
const host: DoorHost = {
|
|
105
|
+
ready: () => instance !== null,
|
|
106
|
+
layout: () => { if (!instance) throw new Booting(); return instance; },
|
|
107
|
+
reload: () => { instance = statusWorld(name, worldRoot); return instance; },
|
|
108
|
+
// a local twin reads the scenario its service declares (colocate.scenarioPath), if any
|
|
109
|
+
scenarioPath: (vendor) => {
|
|
110
|
+
const declared = loadWorldConfig(configRef, worldRoot).config.services.find((s) => s.id === vendor)?.colocate?.scenarioPath;
|
|
111
|
+
return declared ? resolve(worldRoot, declared) : null;
|
|
112
|
+
},
|
|
113
|
+
// a local twin is its own process: the wire forwards to the port it listens on
|
|
114
|
+
twinFetch: (vendor) => {
|
|
115
|
+
const target = instance?.services[vendor]?.url;
|
|
116
|
+
if (!target) return null;
|
|
117
|
+
return (request) => { const at = new URL(request.url); return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: request.headers, ...(request.body ? { body: request.body, duplex: 'half' } : {}), redirect: 'manual' } as RequestInit); };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
|
|
121
|
+
const keep = (): void => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
|
|
122
|
+
let timers: ReturnType<typeof setInterval>[] = [];
|
|
123
|
+
return {
|
|
124
|
+
name, served, root: worldRoot,
|
|
125
|
+
get token() { return world.token; }, get readToken() { return world.readToken; },
|
|
126
|
+
twins: () => world.twins(),
|
|
127
|
+
rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
|
|
128
|
+
handle: (request) => world.handle(request),
|
|
129
|
+
async boot(url) {
|
|
130
|
+
keep();
|
|
131
|
+
const port = Number(new URL(url).port) || 0;
|
|
132
|
+
writeFileSync(record, `${JSON.stringify({ name: served, url, base: `${url}/${served}`, port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
133
|
+
const booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
|
|
134
|
+
instance = booted;
|
|
135
|
+
// a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
|
|
136
|
+
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
137
|
+
timers = await scheduleRefreshes(name, worldRoot, booted);
|
|
138
|
+
},
|
|
139
|
+
async stop() {
|
|
140
|
+
for (const t of timers) clearInterval(t);
|
|
141
|
+
try { rmSync(record, { force: true }); } catch { /* already gone */ }
|
|
142
|
+
const result = await downWorld(name, worldRoot);
|
|
143
|
+
if (result.externalErrors.length) throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
|
|
149
|
+
export async function serveWorld(name: string, opts: { root?: string; port?: number; host?: string; consolePort?: number; announce?: (info: { name: string; base: string; token: string; readToken: string; console: string | null }) => void } = {}): Promise<ServedWorld> {
|
|
150
|
+
const mounted = await mountWorld(name, opts);
|
|
151
|
+
const host = opts.host ?? '127.0.0.1';
|
|
152
|
+
// THE PORT FIRST: a port already taken fails here, loudly, before any token is written — a
|
|
153
|
+
// stale server on the port must never be mistaken for this one. Until the world is up the
|
|
154
|
+
// doors answer 503.
|
|
155
|
+
let consoleOrigin: string | null = null;
|
|
156
|
+
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
|
|
157
|
+
const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
|
|
158
|
+
const url = `http://${host}:${server.port}`;
|
|
159
|
+
try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
|
|
160
|
+
// the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
|
|
161
|
+
const mount = await (async (): Promise<ConsoleMount | undefined> => {
|
|
162
|
+
try { const mod = (await import('@volter/world-console')) as { createConsole: () => ConsoleMount }; return mod.createConsole(); } catch { return undefined; }
|
|
163
|
+
})();
|
|
164
|
+
let consoleServer: { url: string; server: { stop: (force?: boolean) => Promise<void> | void } } | null = null;
|
|
165
|
+
if (mount) {
|
|
166
|
+
// a stable address for a bookmark: the port asked for, else the World's port + 1 when that is free
|
|
167
|
+
const wanted = opts.consolePort ?? (server.port ? server.port + 1 : 0);
|
|
168
|
+
try { consoleServer = await serveConsoleApart(mount, { upstream: url, hostname: host, port: wanted }); }
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (opts.consolePort !== undefined) { await server.stop(true); await mounted.stop(); throw error; }
|
|
171
|
+
try { consoleServer = await serveConsoleApart(mount, { upstream: url, hostname: host, port: 0 }); }
|
|
172
|
+
catch (again) { await server.stop(true); await mounted.stop(); throw again; }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const consoleUrl = consoleServer ? `${consoleServer.url}${CONSOLE_BASE}/` : null;
|
|
176
|
+
consoleOrigin = consoleServer?.url ?? null;
|
|
177
|
+
const stopAll = async (): Promise<void> => { await consoleServer?.server.stop(true); await server.stop(true); };
|
|
178
|
+
try {
|
|
179
|
+
opts.announce?.({ name: mounted.served, base: `${url}/${mounted.served}`, token: mounted.token, readToken: mounted.readToken, console: consoleUrl });
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Boot succeeded: this call owns the World, but cannot return its stop handle.
|
|
182
|
+
try { await stopAll(); } finally { await mounted.stop(); }
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
return { url, base: `${url}/${mounted.served}`, name: mounted.served, port: server.port ?? 0, token: mounted.token, readToken: mounted.readToken, console: consoleUrl, stop: async () => { await stopAll(); await mounted.stop(); } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The env an attacher's client reads for a stream: the protocol's own conventional names (smtp's
|
|
189
|
+
* SMTP_HOST/SMTP_PORT…), else the service's `<ID>_TWIN_URL` as a connection URL. */
|
|
190
|
+
function streamEnv(id: string, protocol: string): Record<string, string> {
|
|
191
|
+
const own = APP_READ_ENDPOINT_ENV[id]?.injectEnvTemplates;
|
|
192
|
+
if (own) return own;
|
|
193
|
+
return { [`${id.toUpperCase().replace(/-/g, '_')}_TWIN_URL`]: `${protocol}://twin:twin@\${host}:\${port}/twin` };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What the doors need of a world: where each twin's state lives and which twins it has. A booted
|
|
197
|
+
* local instance is one; a hosted World describes itself. */
|
|
198
|
+
export type WorldLayout = { dirs: { data: string }; services: Record<string, { url?: string; protocol?: unknown }> };
|
|
199
|
+
/** The host a world's doors run in: its layout, and how a request reaches a twin's wire — a local
|
|
200
|
+
* host forwards to the twin's own port, a hosted World calls the pack in-process. */
|
|
201
|
+
export type DoorHost = {
|
|
202
|
+
ready: () => boolean;
|
|
203
|
+
layout: () => WorldLayout;
|
|
204
|
+
/** The layout re-read before a write lands (a local world may have been re-booted). */
|
|
205
|
+
reload: () => WorldLayout;
|
|
206
|
+
/** The twin's wire for a request whose path is the twin's own; null when the world has no such twin. */
|
|
207
|
+
twinFetch: (vendor: string) => ((request: Request) => Promise<Response>) | null;
|
|
208
|
+
/** The world's byte-stream doors (a TCP protocol a twin serves: smtp's own, planetscale's mysql),
|
|
209
|
+
* by the id a local World gives that listener's service, when this host carries bytes over a
|
|
210
|
+
* WebSocket (a hosted World takes no inbound TCP). */
|
|
211
|
+
streams?: () => Record<string, { protocol: string }>;
|
|
212
|
+
openStream?: (id: string, sink: TwinStreamSink, peer: string) => TwinStreamConnection | null;
|
|
213
|
+
/** Where this host reads a twin's scenario (the handlers document a generative twin serves from,
|
|
214
|
+
* re-read on every request), or null when the twin takes none here. */
|
|
215
|
+
scenarioPath?: (vendor: string) => string | null;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export class WorldDoors {
|
|
219
|
+
private readonly queues = new Map<string, Promise<unknown>>();
|
|
220
|
+
token: string; readToken: string;
|
|
221
|
+
constructor(readonly name: string, readonly worldRoot: string, readonly configRef: string, readonly served: string, private readonly host: DoorHost, token: string, readToken: string) { this.token = token; this.readToken = readToken; }
|
|
222
|
+
/** New tokens; a request presenting the old ones is refused from the next call on. */
|
|
223
|
+
retoken(token: string, readToken: string): void { this.token = token; this.readToken = readToken; }
|
|
224
|
+
/** The world's twins, by vendor, once booted. */
|
|
225
|
+
twins(): string[] { return Object.keys(this.up().services); }
|
|
226
|
+
private up(): WorldLayout { return this.host.layout(); }
|
|
227
|
+
|
|
228
|
+
/** The world's token, presented the way the app's SDK presents a credential: the world's own header,
|
|
229
|
+
* the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
|
|
230
|
+
* GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
|
|
231
|
+
private scope(request: Request): 'write' | 'read' | null { return this.match(request)?.scope ?? null; }
|
|
232
|
+
/** The World token the request presented, in whichever form, and the scope it opens. */
|
|
233
|
+
private match(request: Request): { scope: 'write' | 'read'; token: string; fromSession: boolean } | null {
|
|
234
|
+
const authorization = request.headers.get('authorization') ?? '';
|
|
235
|
+
const bearer = /^(?:Bearer|token)\s+(\S+)$/i.exec(authorization)?.[1];
|
|
236
|
+
const basic = /^Basic\s+(\S+)$/i.exec(authorization)?.[1];
|
|
237
|
+
const basicSecret = basic ? (() => { try { const text = Buffer.from(basic, 'base64').toString('utf8'); const i = text.indexOf(':'); return i >= 0 ? text.slice(i + 1) : text; } catch { return undefined; } })() : undefined;
|
|
238
|
+
// a browser holding a session for this World (POST …/session): what a mirror page reads the wire with
|
|
239
|
+
const cookie = /(?:^|;\s*)volter_world=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
240
|
+
// every form presented is a candidate: a mirror page sends the VENDOR's bearer (the X token its
|
|
241
|
+
// sign-in holds) beside the World's session cookie, and the cookie is what opens the World
|
|
242
|
+
const headed = [request.headers.get(TOKEN_HEADER), request.headers.get(TWINS_KEY_HEADER), bearer, basicSecret];
|
|
243
|
+
const session = cookie ? decodeURIComponent(cookie) : null;
|
|
244
|
+
for (const [scope, token] of [['write', this.token], ['read', this.readToken]] as const) {
|
|
245
|
+
if (headed.includes(token)) return { scope, token, fromSession: false };
|
|
246
|
+
if (session === token) return { scope, token, fromSession: true };
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
/** Open one connection on a byte-stream door for the holder of the World's (write) token; null when
|
|
251
|
+
* the token is not the World's or the World has no such stream. */
|
|
252
|
+
openStream(token: string, id: string, sink: TwinStreamSink, peer: string): TwinStreamConnection | null {
|
|
253
|
+
if (token !== this.token || !this.host.openStream || !(id in (this.host.streams?.() ?? {}))) return null;
|
|
254
|
+
return this.host.openStream(id, sink, peer);
|
|
255
|
+
}
|
|
256
|
+
private serialized<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
257
|
+
const prior = this.queues.get(key) ?? Promise.resolve();
|
|
258
|
+
const next = prior.then(fn, fn);
|
|
259
|
+
this.queues.set(key, next.then(() => undefined, () => undefined));
|
|
260
|
+
return next;
|
|
261
|
+
}
|
|
262
|
+
private refresh(): WorldLayout { return this.host.reload(); }
|
|
263
|
+
private controlRoot(vendor: string): string { return join(this.up().dirs.data, vendor); }
|
|
264
|
+
private stateOf(vendor: string): string {
|
|
265
|
+
return stateServiceOf(this.controlRoot(vendor), vendor);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async handle(request: Request): Promise<Response> {
|
|
269
|
+
try {
|
|
270
|
+
if (!this.host.ready() && !new URL(request.url).pathname.startsWith('/-/ping')) return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
|
|
271
|
+
const url = new URL(request.url);
|
|
272
|
+
const prefix = `/${this.served}/`;
|
|
273
|
+
if (url.pathname === '/-/ping') return Response.json({ ok: true, world: this.served, worlds: [this.served] });
|
|
274
|
+
if (url.pathname.startsWith(`/-/${this.served}/`)) return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
|
|
275
|
+
// the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
|
|
276
|
+
// place under this World, and the token the injector carries to them — the presented one
|
|
277
|
+
if (url.pathname === `${prefix}.well-known/volter-world` && request.method === 'GET') {
|
|
278
|
+
// the manifest hands its caller the token it presented; a session's token is HttpOnly, never echoed to script
|
|
279
|
+
const matched = this.match(request);
|
|
280
|
+
if (matched === null || matched.fromSession) return Response.json({ error: 'token required' }, { status: 401 });
|
|
281
|
+
const presented = matched.token;
|
|
282
|
+
const vendors = Object.fromEntries(Object.keys(this.up().services).map((v) => [v, `${url.origin}/${this.served}/${v}`]));
|
|
283
|
+
// a stream is reached through the attacher's loopback bridge (@volter/world-core/stream-bridge):
|
|
284
|
+
// its env is what the client reads, with ${host}/${port} the bridge's listener
|
|
285
|
+
const ws = url.origin.replace(/^http/, 'ws');
|
|
286
|
+
const streams = Object.fromEntries(Object.entries(this.host.streams?.() ?? {}).map(([id, { protocol }]) => [id, { protocol, url: `${ws}/-/${this.served}/streams/${id}`, env: streamEnv(id, protocol) }]));
|
|
287
|
+
// a twin whose SDK is pointed by an app-read endpoint (TUNNEL_SERVER_URL, QSTASH_URL): that name, at its place here
|
|
288
|
+
const endpoints = Object.fromEntries(Object.keys(vendors).flatMap((v) => { const name = APP_READ_ENDPOINT_ENV[v]?.injectEnv; return name ? [[name, vendors[v]!]] : []; }));
|
|
289
|
+
return Response.json({ name: this.served, vendors, ca: null, proxy: null, env: { ...endpoints, VOLTER_TWINS_KEY: presented }, ...(Object.keys(streams).length ? { streams } : {}) });
|
|
290
|
+
}
|
|
291
|
+
if (url.pathname.startsWith(prefix)) return await this.wire(request, url, url.pathname.slice(prefix.length));
|
|
292
|
+
return Response.json({ error: `not this world: serving /${this.served}/` }, { status: 404 });
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
|
|
299
|
+
private async wire(request: Request, url: URL, rest: string): Promise<Response> {
|
|
300
|
+
const [vendor, ...tail] = rest.split('/');
|
|
301
|
+
if (tail[0] === 'mirror') return await this.mirror(request, vendor!, tail.slice(1).join('/'));
|
|
302
|
+
const scope = this.scope(request);
|
|
303
|
+
const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
|
|
304
|
+
if (scope === null && !manifestAsk) return Response.json({ error: 'token required' }, { status: 401 });
|
|
305
|
+
if (scope === 'read' && request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
306
|
+
// A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
|
|
307
|
+
// the World (the World holds the key; its callers hold the World's token). Stateless, no log.
|
|
308
|
+
// a person's browser landing on a twin's root (a mirror that moved to a `#/…` route by assigning
|
|
309
|
+
// location, which resolves against the twin's <base>) is sent to the mirror; the browser keeps the hash
|
|
310
|
+
if (request.method === 'GET' && tail.join('/') === '' && request.headers.get('sec-fetch-dest') === 'document' && this.up().services[vendor!] && (await this.hasMirror(vendor!))) {
|
|
311
|
+
return new Response(null, { status: 302, headers: { location: `/${this.served}/${vendor}/mirror/` } });
|
|
312
|
+
}
|
|
313
|
+
const link = this.link(vendor!);
|
|
314
|
+
if (link) return await this.forward(request, url, tail.join('/'), vendor!, link.origin);
|
|
315
|
+
const twin = this.host.twinFetch(vendor!);
|
|
316
|
+
if (!twin) return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
|
|
317
|
+
const headers = new Headers(request.headers); headers.delete(TOKEN_HEADER); headers.delete(TWINS_KEY_HEADER); headers.delete('host');
|
|
318
|
+
// the browser's World session is the World's credential, never the twin's (a tunnel would hand it on)
|
|
319
|
+
const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !c.startsWith('volter_world='));
|
|
320
|
+
if (cookies.length) headers.set('cookie', cookies.join('; ')); else headers.delete('cookie');
|
|
321
|
+
// the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
|
|
322
|
+
headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
|
|
323
|
+
headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
324
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
325
|
+
const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
326
|
+
// an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
|
|
327
|
+
if (answer.status === 101) return answer;
|
|
328
|
+
// staleness is a fact on the wire: a twin with a root says when the vendor was last observed
|
|
329
|
+
const out = new Headers(answer.headers);
|
|
330
|
+
const observedAt = this.observedAt(vendor!);
|
|
331
|
+
if (observedAt) out.set('x-volter-observed-at', observedAt);
|
|
332
|
+
return new Response(answer.body, { status: answer.status, headers: out });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Whether the twin's pack ships a mirror (its vendor's UI over this World's state). */
|
|
336
|
+
private async hasMirror(vendor: string): Promise<boolean> {
|
|
337
|
+
const mod = await packMirrorExports(vendor, this.worldRoot).catch(() => null);
|
|
338
|
+
return !!mod && Object.keys(mod).some((n) => /^\w+MirrorHtml$/.test(n)) && Object.keys(mod).some((n) => /^build\w+MirrorClient$/.test(n));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** THE MIRROR MOUNT, as the hosted World serves it (apps/cloud supervisor.ts): `/<org>/<world>/<vendor>/mirror/`
|
|
342
|
+
* and `…/mirror/assets/*`, keyless, GET only — the pack's own shell and bundle. The shell's <base> is the
|
|
343
|
+
* twin's place under this World, so its reads go to the keyed wire with the browser's World session. */
|
|
344
|
+
private async mirror(request: Request, vendor: string, rest: string): Promise<Response> {
|
|
345
|
+
if (request.method !== 'GET') return Response.json({ error: 'the mirror mount serves the shell and its assets only' }, { status: 404 });
|
|
346
|
+
const isAsset = /^assets\/[A-Za-z0-9._-]+$/.test(rest);
|
|
347
|
+
if (rest.startsWith('assets/') && !isAsset) return Response.json({ error: 'no such asset' }, { status: 404 });
|
|
348
|
+
if (!this.up().services[vendor]) return Response.json({ error: 'no mirror for this twin' }, { status: 404 });
|
|
349
|
+
const mod = await packMirrorExports(vendor, this.worldRoot);
|
|
350
|
+
const pick = (re: RegExp) => { const k = mod && Object.keys(mod).find((n) => re.test(n) && typeof mod[n] === 'function'); return k ? (mod![k] as () => string | Promise<string>) : undefined; };
|
|
351
|
+
const html = pick(/^\w+MirrorHtml$/); const client = pick(/^build\w+MirrorClient$/); const styles = pick(/^\w+MirrorStyles$/);
|
|
352
|
+
if (!html || !client) return Response.json({ error: 'no mirror for this twin' }, { status: 404 });
|
|
353
|
+
if (rest === 'assets/app.js') return new Response(await client(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
354
|
+
if (rest === 'assets/styles.css' && styles) return new Response(await styles(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
355
|
+
if (isAsset) return Response.json({ error: 'no such asset' }, { status: 404 });
|
|
356
|
+
const shell = mirrorShellUnder(await html(), `/${this.served}/${vendor}/`);
|
|
357
|
+
return new Response(shell, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private linksPath(): string { return join(this.worldRoot, stateDirName(), 'links.json'); }
|
|
361
|
+
/** The World's links: name → the vendor origin it forwards to. */
|
|
362
|
+
links(): Record<string, { origin: string }> {
|
|
363
|
+
const held = getActiveWorldStore().read(this.linksPath());
|
|
364
|
+
return held === null ? {} : (JSON.parse(held) as Record<string, { origin: string }>);
|
|
365
|
+
}
|
|
366
|
+
private link(name: string): { origin: string } | null { return this.links()[name] ?? null; }
|
|
367
|
+
|
|
368
|
+
/** Forward to the link's origin: the path under the link, the caller's headers with the World's
|
|
369
|
+
* token removed and the sealed credential's headers replacing any of the same name. The vendor's
|
|
370
|
+
* answer (status, headers, body) is streamed back as it arrives. */
|
|
371
|
+
private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
|
|
372
|
+
const sealed = getActiveWorldStore().read(credentialPath(this.worldRoot, name));
|
|
373
|
+
if (sealed === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
374
|
+
const credential = await openSealedCredential(sealingKey(), JSON.parse(sealed) as SealedCredential, name);
|
|
375
|
+
const base = validateRemoteOrigin(origin);
|
|
376
|
+
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
377
|
+
if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
378
|
+
const headers = new Headers(request.headers);
|
|
379
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) headers.delete(h);
|
|
380
|
+
for (const [k, v] of Object.entries(credential.headers)) headers.set(k, v);
|
|
381
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
382
|
+
const controller = new AbortController();
|
|
383
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
384
|
+
try {
|
|
385
|
+
return await fetch(target, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual', signal: controller.signal });
|
|
386
|
+
} finally { clearTimeout(timer); }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The instant of the twin's last completed refresh (its marker's `observedAt`), when it has a root. */
|
|
390
|
+
private observedAt(vendor: string): string | undefined {
|
|
391
|
+
try {
|
|
392
|
+
if (!rootForControlRoot(this.controlRoot(vendor), vendor)) return undefined;
|
|
393
|
+
const held = getActiveWorldStore().read(join(this.controlRoot(vendor), stateDirName(), 'world', vendor, 'refresh.json'));
|
|
394
|
+
if (held === null) return undefined;
|
|
395
|
+
const parsed = JSON.parse(held) as { observedAt?: string };
|
|
396
|
+
return typeof parsed.observedAt === 'string' ? parsed.observedAt : undefined;
|
|
397
|
+
} catch { return undefined; }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The world's own doors under `/-/<org>/<world>/`. */
|
|
401
|
+
private async door(request: Request, url: URL, path: string): Promise<Response> {
|
|
402
|
+
// INGEST is keyless by necessity (a vendor sends no world token): the vendor's own signature
|
|
403
|
+
// is the credential, verified by the pack's adapter with the secret sealed beside the world;
|
|
404
|
+
// without one the door does not exist for that vendor — 403, never an unverified fold.
|
|
405
|
+
const ingestMatch = /^twins\/([a-z0-9_-]+)\/ingest$/.exec(path);
|
|
406
|
+
if (ingestMatch && request.method === 'POST') {
|
|
407
|
+
const vendor = ingestMatch[1]!;
|
|
408
|
+
if (!this.up().services[vendor]) return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
|
|
409
|
+
const secret = await twinSigningSecret(this.worldRoot, vendor);
|
|
410
|
+
if (secret === null) return Response.json({ error: `ingest for ${vendor} needs a signingSecret sealed with its credential` }, { status: 403 });
|
|
411
|
+
const adapters = await adaptersFor(vendor, this.worldRoot);
|
|
412
|
+
if (!adapters.ingest) return Response.json({ error: `the ${vendor} twin has no ingest adapter` }, { status: 404 });
|
|
413
|
+
return await this.serialized(`ingest:${vendor}`, () => Promise.resolve(adapters.ingest!(request, { root: this.controlRoot(vendor), secret })));
|
|
414
|
+
}
|
|
415
|
+
// ending a browser's session needs no token: it only clears the caller's own cookie, which never
|
|
416
|
+
// reaches these /-/ paths, so a page holding just the session could not otherwise sign out
|
|
417
|
+
const sessionPath = path.replace(/\/+$/, '');
|
|
418
|
+
// a person signing in from a page (the console's form, posted to this origin): the token in the
|
|
419
|
+
// form body, the session set here, then on to a page of this World
|
|
420
|
+
// a form or beacon from another site never opens or ends a session here (the console, on this host's
|
|
421
|
+
// next port, is same-site)
|
|
422
|
+
const crossSite = request.headers.get('sec-fetch-site') === 'cross-site';
|
|
423
|
+
if (sessionPath === 'session' && request.method === 'POST' && (request.headers.get('content-type') ?? '').startsWith('application/x-www-form-urlencoded')) {
|
|
424
|
+
if (crossSite) return new Response('A session is opened from this host\'s own pages.', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
425
|
+
const form = new URLSearchParams(await request.text());
|
|
426
|
+
const token = form.get('token') ?? '';
|
|
427
|
+
if (token !== this.token && token !== this.readToken) return new Response('That token does not open this World.', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
428
|
+
const next = form.get('next') ?? '';
|
|
429
|
+
// the path the browser would land on, normalized (%2e%2e resolves), and only inside this World
|
|
430
|
+
const landing = (() => { try { const at = new URL(next, url.origin); return at.origin === url.origin ? at.pathname : ''; } catch { return ''; } })();
|
|
431
|
+
const to = next.startsWith('/') && !next.startsWith('//') && !/[\\\s]/.test(next) && landing.startsWith(`/${this.served}/`) ? landing : `/${this.served}/`;
|
|
432
|
+
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
433
|
+
return new Response(null, { status: 303, headers: { location: to, 'set-cookie': `volter_world=${encodeURIComponent(token)}; Path=/${this.served}/; HttpOnly; SameSite=Strict${secure}` } });
|
|
434
|
+
}
|
|
435
|
+
if (((sessionPath === 'session' && request.method === 'DELETE') || (sessionPath === 'session/end' && request.method === 'POST')) && !crossSite) {
|
|
436
|
+
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
437
|
+
return new Response(JSON.stringify({ world: this.served, ended: true }), { status: 200, headers: { 'content-type': 'application/json', 'set-cookie': `volter_world=; Path=/${this.served}/; HttpOnly; SameSite=Strict; Max-Age=0${secure}` } });
|
|
438
|
+
}
|
|
439
|
+
const scope = this.scope(request);
|
|
440
|
+
if (scope === null) return Response.json({ error: 'token required' }, { status: 401 });
|
|
441
|
+
if (scope === 'read' && request.method !== 'GET') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
442
|
+
const parts = path.split('/').filter(Boolean);
|
|
443
|
+
const [kind, name, verb] = parts;
|
|
444
|
+
|
|
445
|
+
if (kind === 'log' && name && request.method === 'GET') {
|
|
446
|
+
const state = this.stateOf(name);
|
|
447
|
+
const after = Number(url.searchParams.get('after') ?? '0');
|
|
448
|
+
const limit = Math.min(Number(url.searchParams.get('limit') ?? '500'), 2000);
|
|
449
|
+
if (!Number.isInteger(after) || after < 0 || !Number.isInteger(limit) || limit < 1) return Response.json({ error: 'Invalid history page bounds' }, { status: 400 });
|
|
450
|
+
const controlRoot = this.controlRoot(name);
|
|
451
|
+
const view = url.searchParams.get('view') ?? captureHistory(state, controlRoot).view;
|
|
452
|
+
const descriptor = readHistoryView(worldPaths(state, controlRoot).dir, view);
|
|
453
|
+
const entries = historyEntries(descriptor);
|
|
454
|
+
if (after > entries.length) return Response.json({ error: 'History cursor exceeds the selected view' }, { status: 400 });
|
|
455
|
+
let page = entries.slice(after, after + limit);
|
|
456
|
+
// ?receipts=1: each entry with the receipt its landing carries (a landed copy in the parent log,
|
|
457
|
+
// as the status door reads lastReceipt), for a reader showing an entry's standing; after the digest
|
|
458
|
+
if (url.searchParams.get('receipts') === '1') {
|
|
459
|
+
const landed = new Map<string, unknown>();
|
|
460
|
+
for (const e of parentEntries(state, controlRoot)) if (e.landsId && e.receipt) landed.set(e.landsId, e.receipt);
|
|
461
|
+
page = page.map((e) => (e.receipt || !landed.has(e.id) ? e : { ...e, receipt: landed.get(e.id) as typeof e.receipt }));
|
|
462
|
+
}
|
|
463
|
+
return Response.json({ twin: name, state, view, layout: descriptor.layout, digest: historyDigest({ layout: descriptor.layout, entries }), position: entries.length, after, entries: page });
|
|
464
|
+
}
|
|
465
|
+
if (kind === 'checkpoint' && name && request.method === 'GET') {
|
|
466
|
+
const state = this.stateOf(name);
|
|
467
|
+
const controlRoot = this.controlRoot(name); const cut = captureHistory(state, controlRoot);
|
|
468
|
+
return Response.json({ twin: name, state, view: cut.view, position: cut.position, tree: readTree(state, controlRoot, { view: cut.view }) });
|
|
469
|
+
}
|
|
470
|
+
if (kind === 'changesets' && request.method === 'GET') {
|
|
471
|
+
if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json(l.changeset); }
|
|
472
|
+
return Response.json({ changesets: listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset) });
|
|
473
|
+
}
|
|
474
|
+
if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
|
|
475
|
+
const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
|
|
476
|
+
return Response.json(outcome);
|
|
477
|
+
}
|
|
478
|
+
if (kind === 'changesets' && name && verb === 'approve' && request.method === 'POST') {
|
|
479
|
+
const body = (await request.json()) as { as?: string; note?: string };
|
|
480
|
+
if (!body.as) return Response.json({ error: 'approve: { as, note? }' }, { status: 400 });
|
|
481
|
+
return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal: body.as, ...(body.note ? { note: body.note } : {}) }));
|
|
482
|
+
}
|
|
483
|
+
// a browser session for this World: the presented token as an HttpOnly cookie scoped to the World's
|
|
484
|
+
// paths, so a page served under it (a twin's mirror) reads the wire as the token's holder
|
|
485
|
+
if (kind === 'session' && !name && request.method === 'POST') {
|
|
486
|
+
const presented = this.match(request)!.token;
|
|
487
|
+
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
488
|
+
return new Response(JSON.stringify({ world: this.served, scope }), { status: 200, headers: { 'content-type': 'application/json', 'set-cookie': `volter_world=${encodeURIComponent(presented)}; Path=/${this.served}/; HttpOnly; SameSite=Strict${secure}` } });
|
|
489
|
+
}
|
|
490
|
+
if (kind === 'links' && !name && request.method === 'GET') return Response.json({ links: this.links() });
|
|
491
|
+
if (kind === 'links' && name && request.method === 'PUT') {
|
|
492
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name) || this.up().services[name]) return Response.json({ error: 'a link is named [a-z0-9_-] and is not a twin of this World' }, { status: 400 });
|
|
493
|
+
const body = (await request.json()) as { origin?: string };
|
|
494
|
+
try { validateRemoteOrigin(body.origin ?? ''); } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }); }
|
|
495
|
+
getActiveWorldStore().write(this.linksPath(), `${JSON.stringify({ ...this.links(), [name]: { origin: body.origin! } }, null, 2)}\n`);
|
|
496
|
+
return Response.json({ link: name, origin: body.origin });
|
|
497
|
+
}
|
|
498
|
+
if (kind === 'links' && name && request.method === 'DELETE') {
|
|
499
|
+
const rest = this.links(); delete rest[name];
|
|
500
|
+
getActiveWorldStore().write(this.linksPath(), `${JSON.stringify(rest, null, 2)}\n`);
|
|
501
|
+
return Response.json({ removed: name });
|
|
502
|
+
}
|
|
503
|
+
if (kind === 'checks') {
|
|
504
|
+
// the world's checks (CI on deployment): what a World without a repo is given its checks through;
|
|
505
|
+
// a check is JavaScript exporting `check` (or default) as { name, run(entry, tree) }
|
|
506
|
+
const dir = join(this.worldRoot, stateDirName(), 'checks');
|
|
507
|
+
const store = getActiveWorldStore();
|
|
508
|
+
if (!name && request.method === 'GET') return Response.json({ checks: store.list(dir).filter((f) => /\.(ts|js|mjs)$/.test(f)).map((f) => f.replace(/\.(ts|js|mjs)$/, '')).sort() });
|
|
509
|
+
if (name && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) return Response.json({ error: 'a check is named [a-z0-9-]' }, { status: 400 });
|
|
510
|
+
if (name && request.method === 'PUT') {
|
|
511
|
+
const source = await request.text();
|
|
512
|
+
if (source.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
|
|
513
|
+
const path = join(dir, `${name}.js`);
|
|
514
|
+
store.write(path, source);
|
|
515
|
+
try { await loadCheck(path, name); } catch (error) { store.remove(path); return Response.json({ error: `not a check: ${error instanceof Error ? error.message : String(error)}` }, { status: 400 }); }
|
|
516
|
+
return Response.json({ check: name });
|
|
517
|
+
}
|
|
518
|
+
if (name && request.method === 'DELETE') { for (const ext of ['js', 'mjs', 'ts']) store.remove(join(dir, `${name}.${ext}`)); return Response.json({ removed: name }); }
|
|
519
|
+
}
|
|
520
|
+
// THE ORIGIN, through doors (the CLI's clone, pull, changeset and push, for a World with no shell of
|
|
521
|
+
// its own): the World this one branches from, at a URL, with the token that opens it
|
|
522
|
+
if (kind === 'origin') {
|
|
523
|
+
const tokenPath = join(this.worldRoot, stateDirName(), 'origin.token');
|
|
524
|
+
const store = getActiveWorldStore();
|
|
525
|
+
const key = (): string => { const t = store.read(tokenPath)?.trim(); if (!t) throw new Error(`${this.served} has no origin — PUT /-/${this.served}/origin { url, token }`); return t; };
|
|
526
|
+
if (!name && request.method === 'GET') return Response.json({ origin: statusWorld(this.name, this.worldRoot).origin ?? null });
|
|
527
|
+
if (!name && request.method === 'PUT') {
|
|
528
|
+
const body = (await request.json()) as { url?: string; token?: string };
|
|
529
|
+
const at = /^(https?:\/\/[^/]+)\/([a-z0-9][a-z0-9_-]*\/[a-z0-9][a-z0-9_-]*)\/?$/i.exec(body.url ?? '');
|
|
530
|
+
if (!at || !body.token) return Response.json({ error: 'PUT origin: { url: "https://<host>/<org>/<world>", token }' }, { status: 400 });
|
|
531
|
+
store.write(tokenPath, `${body.token}\n`, { secret: true });
|
|
532
|
+
const fetched = await this.serialized('push', () => fetchFromOrigin(this.name, { root: this.worldRoot, url: at[1]!, namespace: at[2]!, key: body.token!, full: true }));
|
|
533
|
+
return Response.json({ origin: fetched.origin, appended: fetched.appended });
|
|
534
|
+
}
|
|
535
|
+
if (name === 'pull' && request.method === 'POST') {
|
|
536
|
+
return Response.json(await this.serialized('push', async () => {
|
|
537
|
+
const fetched = await fetchFromOrigin(this.name, { root: this.worldRoot, key: key() });
|
|
538
|
+
const rebased = Object.keys(this.up().services).map((twin) => ({ twin, ...rebaseBranch(this.stateOf(twin), this.controlRoot(twin), { origin: true }) }));
|
|
539
|
+
return { appended: fetched.appended, rebased };
|
|
540
|
+
}));
|
|
541
|
+
}
|
|
542
|
+
if (name === 'push' && request.method === 'POST') {
|
|
543
|
+
return Response.json(await this.serialized('push', async () => {
|
|
544
|
+
const queue = listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.applied === null || l.changeset.applied.outcome === 'refused').sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
545
|
+
const pushed = [];
|
|
546
|
+
for (const l of queue) {
|
|
547
|
+
const outcome = await pushWorldChangeset(l.changeset.name, { root: this.worldRoot, world: this.name, key: key() });
|
|
548
|
+
pushed.push({ changeset: l.changeset.name, outcome: outcome.application.outcome, receipts: outcome.application.receipts, ...(outcome.application.refusal ? { refusal: outcome.application.refusal } : {}), ...(outcome.unsent ? { unsent: outcome.unsent } : {}) });
|
|
549
|
+
if (outcome.application.outcome === 'refused') break;
|
|
550
|
+
}
|
|
551
|
+
return { pushed };
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (kind === 'changesets' && !name && request.method === 'POST') {
|
|
556
|
+
const body = (await request.json().catch(() => ({}))) as { name?: string; message?: string };
|
|
557
|
+
const slug = body.message ? body.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
|
|
558
|
+
const cut = body.name ?? (slug || `changeset-${Date.now()}`);
|
|
559
|
+
return Response.json(await this.serialized('push', async () => createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message ? { message: body.message } : {}) })));
|
|
560
|
+
}
|
|
561
|
+
// THE BLOBS DOOR: a twin's resource payloads (uploaded media, file bytes) by their key under its
|
|
562
|
+
// resources directory. A push sends the ones its branch holds and this World lacks before the
|
|
563
|
+
// entries that name them, so what landed can be served and performed here. A key naming a sha256
|
|
564
|
+
// must hold those bytes (a segment that is exactly a 64-hex digest).
|
|
565
|
+
if (kind === 'blobs' && name && (request.method === 'HEAD' || request.method === 'PUT')) {
|
|
566
|
+
const key = parts.slice(2).join('/');
|
|
567
|
+
if (!key || key.split('/').some((seg) => seg === '' || seg === '.' || seg === '..') || /[\\\0]/.test(key)) return Response.json({ error: 'a blob key is a relative path' }, { status: 400 });
|
|
568
|
+
// a digest-shaped segment is lowercase, so a case-insensitive filesystem can't take unchecked bytes for it
|
|
569
|
+
if (key.split('/').some((seg) => /^[0-9a-fA-F]{64}$/.test(seg) && seg !== seg.toLowerCase())) return Response.json({ error: 'a digest in a blob key is lowercase hex' }, { status: 400 });
|
|
570
|
+
if (!this.up().services[name]) return Response.json({ error: `no twin "${name}" in ${this.served}` }, { status: 404 });
|
|
571
|
+
const path = join(worldPaths(this.stateOf(name), this.controlRoot(name)).resources, key);
|
|
572
|
+
const store = getActiveBlobStore();
|
|
573
|
+
// HEAD names the blob's own digest, so a push can tell the same key holding other bytes from the same blob
|
|
574
|
+
if (request.method === 'HEAD') {
|
|
575
|
+
const held = await store.get(path);
|
|
576
|
+
return new Response(null, { status: held === null ? 404 : 200, headers: held === null ? { 'x-volter-blob': 'absent' } : { 'x-volter-blob-sha256': blobDigest(held) } });
|
|
577
|
+
}
|
|
578
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
579
|
+
// a segment that IS a digest names these bytes; a name built on one (`<sha>.json`, a blob's metadata) does not
|
|
580
|
+
const named = key.split('/').find((seg) => /^[0-9a-f]{64}$/.test(seg));
|
|
581
|
+
if (named && blobDigest(bytes) !== named) return Response.json({ error: 'the bytes do not match the sha256 the key names' }, { status: 400 });
|
|
582
|
+
await store.put(path, bytes);
|
|
583
|
+
return new Response(null, { status: 204 });
|
|
584
|
+
}
|
|
585
|
+
if (kind === 'push' && request.method === 'POST') return await this.serialized('push', () => this.push(request));
|
|
586
|
+
if (kind === 'deploy' && request.method === 'POST') {
|
|
587
|
+
const outcomes = await this.serialized('push', () => deployWorld(this.name, { root: this.worldRoot, instance: this.up(), ...(name ? { changeset: name } : {}) }));
|
|
588
|
+
return Response.json({ deployed: outcomes.map((o) => ({ twin: o.service, pushed: o.report.pushed, ...(o.report.refused ? { refused: o.report.refused } : {}), ...(o.report.failed ? { failed: o.report.failed } : {}) })) });
|
|
589
|
+
}
|
|
590
|
+
if (kind === 'requests' && !name && request.method === 'GET') {
|
|
591
|
+
// the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
|
|
592
|
+
// requests by twin and status class, the response time's p50/p95, the top routes
|
|
593
|
+
const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000; const since = Date.now() - sinceMs;
|
|
594
|
+
const twins: Array<{ twin: string; requests: number; byStatus: Record<string, number>; p50Ms: number | null; p95Ms: number | null; topRoutes: Array<{ route: string; requests: number }> }> = [];
|
|
595
|
+
for (const vendor of Object.keys(this.up().services)) {
|
|
596
|
+
const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
|
|
597
|
+
const byStatus: Record<string, number> = {}; const routes = new Map<string, number>(); const times: number[] = [];
|
|
598
|
+
for (const e of entries) { const cls = `${String(e.status)[0]}xx`; byStatus[cls] = (byStatus[cls] ?? 0) + 1; const route = `${e.method} ${e.path.replace(/\/[0-9]+(?=\/|$)/g, '/{id}')}`; routes.set(route, (routes.get(route) ?? 0) + 1); if (typeof e.ms === 'number') times.push(e.ms); }
|
|
599
|
+
times.sort((a, b) => a - b); const pct = (p: number): number | null => (times.length ? times[Math.min(times.length - 1, Math.floor((p / 100) * times.length))]! : null);
|
|
600
|
+
twins.push({ twin: vendor, requests: entries.length, byStatus, p50Ms: pct(50), p95Ms: pct(95), topRoutes: [...routes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([route, requests]) => ({ route, requests })) });
|
|
601
|
+
}
|
|
602
|
+
return Response.json({ world: this.served, hours: sinceMs / 3_600_000, twins, requests: twins.reduce((n, t) => n + t.requests, 0) });
|
|
603
|
+
}
|
|
604
|
+
if (kind === 'twins' && !name && request.method === 'GET') {
|
|
605
|
+
// every twin of the world as a consumer sees it — the status door mapped over the world; what a
|
|
606
|
+
// console renders first, and what a holder of a world token (never the admin's) can ask
|
|
607
|
+
const twins: unknown[] = [];
|
|
608
|
+
for (const vendor of Object.keys(this.up().services)) {
|
|
609
|
+
const res = await this.handle(new Request(`${url.origin}/-/${this.served}/twins/${vendor}/status`, { headers: request.headers }));
|
|
610
|
+
if (res.ok) twins.push(await res.json());
|
|
611
|
+
}
|
|
612
|
+
return Response.json({ world: this.served, twins });
|
|
613
|
+
}
|
|
614
|
+
if (kind === 'twins' && name) {
|
|
615
|
+
if (verb === 'root' && request.method === 'GET') return Response.json({ twin: name, root: rootForControlRoot(this.controlRoot(name), name) ?? null, credential: sealedCredentialInfo(this.worldRoot, name) });
|
|
616
|
+
if (verb === 'root' && request.method === 'PUT') {
|
|
617
|
+
const body = (await request.json()) as RootConfig | null;
|
|
618
|
+
setTwinRoot(this.worldRoot, name, body, { config: this.configRef });
|
|
619
|
+
materializeRoots(loadWorldConfig(this.configRef, this.worldRoot).config, this.up().dirs.data, this.worldRoot);
|
|
620
|
+
return Response.json({ twin: name, root: body });
|
|
621
|
+
}
|
|
622
|
+
if (verb === 'credential' && request.method === 'PUT') {
|
|
623
|
+
const text = await request.text();
|
|
624
|
+
if (text.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
|
|
625
|
+
const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name));
|
|
626
|
+
return Response.json({ twin: name, sealed: true, ...info });
|
|
627
|
+
}
|
|
628
|
+
if (verb === 'credential' && request.method === 'GET') return Response.json({ twin: name, credential: sealedCredentialInfo(this.worldRoot, name) });
|
|
629
|
+
// the twin's scenario: the handlers document it serves its generative surface from, where this
|
|
630
|
+
// host reads it; the twin re-reads it on every request, so a PUT is live on the next call
|
|
631
|
+
if (verb === 'scenario') {
|
|
632
|
+
const at = this.host.scenarioPath?.(name) ?? null;
|
|
633
|
+
if (at === null) return Response.json({ error: `the ${name} twin takes no scenario on this world (a generative twin does; locally, its service declares colocate.scenarioPath)` }, { status: 404 });
|
|
634
|
+
const store = getActiveWorldStore();
|
|
635
|
+
if (request.method === 'GET') {
|
|
636
|
+
const held = store.read(at);
|
|
637
|
+
return held === null ? Response.json({ error: `the ${name} twin has no scenario` }, { status: 404 }) : new Response(held, { headers: { 'content-type': 'application/json' } });
|
|
638
|
+
}
|
|
639
|
+
if (request.method === 'PUT') {
|
|
640
|
+
const text = await request.text();
|
|
641
|
+
if (text.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
|
|
642
|
+
let doc: unknown;
|
|
643
|
+
try { doc = JSON.parse(text); } catch { return Response.json({ error: 'a scenario is a JSON document' }, { status: 400 }); }
|
|
644
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return Response.json({ error: 'a scenario is a JSON object' }, { status: 400 });
|
|
645
|
+
store.writeAtomic(at, text);
|
|
646
|
+
return Response.json({ twin: name, scenario: true, bytes: text.length });
|
|
647
|
+
}
|
|
648
|
+
if (request.method === 'DELETE') { store.remove(at); return Response.json({ twin: name, scenario: false }); }
|
|
649
|
+
}
|
|
650
|
+
if (verb === 'status' && request.method === 'GET') {
|
|
651
|
+
const state = this.stateOf(name);
|
|
652
|
+
// what a consumer asks a served world about a twin: its root, whether a credential is sealed,
|
|
653
|
+
// when the vendor was last asked, and how the last performed entry went
|
|
654
|
+
const root = rootForControlRoot(this.controlRoot(name), name);
|
|
655
|
+
const marker = getActiveWorldStore().read(join(this.controlRoot(name), stateDirName(), 'world', name, 'refresh.json'));
|
|
656
|
+
let refresh: unknown = null; try { if (marker !== null) refresh = JSON.parse(marker); } catch { refresh = null; }
|
|
657
|
+
const last = [...parentEntries(state, this.controlRoot(name))].reverse().find((e) => e.landsId && e.receipt);
|
|
658
|
+
return Response.json({ twin: name, state, protocol: this.up().services[name]?.protocol ?? null, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null, mirror: await this.hasMirror(name) });
|
|
659
|
+
}
|
|
660
|
+
if (verb === 'refresh' && request.method === 'POST') {
|
|
661
|
+
const root = rootForControlRoot(this.controlRoot(name), name);
|
|
662
|
+
if (!root) return Response.json({ error: `the ${name} twin has no root` }, { status: 409 });
|
|
663
|
+
return Response.json(await refreshTwin({ worldRoot: this.worldRoot, vendor: name, controlRoot: this.controlRoot(name), root, ...(url.searchParams.get('force') === '1' ? { force: true } : {}) }));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return Response.json({ error: `no such door: ${request.method} /-/${this.served}/${path}` }, { status: 404 });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** PUSH: see `landChangeset` — the door hands the body to it. */
|
|
670
|
+
private async push(request: Request): Promise<Response> {
|
|
671
|
+
const body = (await request.json()) as Changeset | { changeset: Changeset; expect?: Record<string, HistoryReference> };
|
|
672
|
+
const changeset = 'changeset' in body && !('actions' in body) ? body.changeset : (body as Changeset);
|
|
673
|
+
if (!changeset || !Array.isArray(changeset.actions)) return Response.json({ error: 'push: a changeset object' }, { status: 400 });
|
|
674
|
+
this.refresh();
|
|
675
|
+
// fast-forward only: the client says where it last saw each twin's log (its fetch cursor); a
|
|
676
|
+
// twin that moved past it refuses with the drift — fetch, rebase, push again
|
|
677
|
+
const expect = 'expect' in body && body.expect ? body.expect : {};
|
|
678
|
+
const positionOf = (twin: string): number => wholeLog(this.stateOf(twin), this.controlRoot(twin)).length;
|
|
679
|
+
try {
|
|
680
|
+
const receipts = await landChangeset(this.name, this.worldRoot, this.up(), changeset, expect);
|
|
681
|
+
const position = Object.fromEntries([...new Set(changeset.actions.map((a) => a.service))].map((twin) => [twin, positionOf(twin)]));
|
|
682
|
+
const snapshots = Object.fromEntries(Object.keys(position).map(twin => { const head = captureHistory(this.stateOf(twin), this.controlRoot(twin)); return [twin, { view: head.view, position: head.position }]; }));
|
|
683
|
+
return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots });
|
|
684
|
+
} catch (error) {
|
|
685
|
+
return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* LAND a pushed changeset in a world: its entries go onto the world's branch log for each twin, in
|
|
692
|
+
* order, each by its own id (landing twice is a no-op). Under `deploy: auto` each landed entry is
|
|
693
|
+
* performed at once and its receipt answered; under `gated`/`hold` the entry waits (`landed`). The
|
|
694
|
+
* changeset object is kept beside the world's own, so verify/approve/deploy can name it. The push
|
|
695
|
+
* door and the path transport both come here.
|
|
696
|
+
*/
|
|
697
|
+
export async function landChangeset(name: string, worldRoot: string, instance: WorldLayout, changeset: Changeset, expected: Record<string, HistoryReference> = {}): Promise<Array<{ actionId: string; twin: string } & Receipt>> {
|
|
698
|
+
const receipts: Array<{ actionId: string; twin: string } & Receipt> = [];
|
|
699
|
+
const checks = await loadWorldChecks(worldRoot);
|
|
700
|
+
const stateOf = (twin: string): string => stateServiceOf(join(instance.dirs.data, twin), twin);
|
|
701
|
+
const byTwin = new Map<string, Entry[]>();
|
|
702
|
+
for (const item of changeset.actions) {
|
|
703
|
+
if (!instance.services[item.service]) throw new Error(`no twin "${item.service}" in this world`);
|
|
704
|
+
byTwin.set(item.service, [...(byTwin.get(item.service) ?? []), item.action as Entry]);
|
|
705
|
+
}
|
|
706
|
+
const landedByTwin = new Map<string, string[]>();
|
|
707
|
+
withAncestryLock(() => {
|
|
708
|
+
for (const [twin, at] of Object.entries(expected)) {
|
|
709
|
+
if (!instance.services[twin]) throw new Error(`Unknown expected twin ${twin}`);
|
|
710
|
+
const head = captureHistory(stateOf(twin), join(instance.dirs.data, twin));
|
|
711
|
+
if (!at || head.view !== at.view || head.position !== at.position) throw new Error(`origin moved on ${twin}; fetch and rebase before pushing`);
|
|
712
|
+
}
|
|
713
|
+
for (const [twin, entries] of byTwin) {
|
|
714
|
+
const controlRoot = join(instance.dirs.data, twin); const state = stateOf(twin);
|
|
715
|
+
const ids: string[] = [];
|
|
716
|
+
for (const entry of entries) {
|
|
717
|
+
const { shadowBasis: _basis, ...rest } = entry as Entry & { shadowBasis?: unknown };
|
|
718
|
+
const { action } = appendActionIfAbsent({ ...rest, service: state } as Parameters<typeof appendActionIfAbsent>[0], controlRoot);
|
|
719
|
+
ids.push(action.id);
|
|
720
|
+
}
|
|
721
|
+
landedByTwin.set(twin, ids);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
for (const [twin, landedIds] of landedByTwin) {
|
|
725
|
+
const controlRoot = join(instance.dirs.data, twin); const state = stateOf(twin);
|
|
726
|
+
const root = rootForControlRoot(controlRoot, twin);
|
|
727
|
+
if (root?.deploy === 'auto') {
|
|
728
|
+
const outcome = await deployTwin({ worldRoot, vendor: twin, controlRoot, state, root, actionIds: landedIds, checks });
|
|
729
|
+
const done = new Map(outcome.report.deployed.map((d) => [d.actionId, d]));
|
|
730
|
+
const replayed = new Set(outcome.report.replayed);
|
|
731
|
+
for (const id of landedIds) {
|
|
732
|
+
const r = done.get(id);
|
|
733
|
+
if (r) receipts.push({ actionId: id, twin, status: 'deployed', at: r.at, ...(r.externalId ? { externalId: r.externalId } : {}) });
|
|
734
|
+
else if (replayed.has(id)) receipts.push({ actionId: id, twin, status: 'deployed', at: worldNow() });
|
|
735
|
+
else if (outcome.report.refused?.actionId === id) receipts.push({ actionId: id, twin, status: 'refused', at: worldNow(), reason: `${outcome.report.refused.check}: ${outcome.report.refused.reason}` });
|
|
736
|
+
else if (outcome.report.failed?.actionId === id) receipts.push({ actionId: id, twin, status: 'failed', at: worldNow(), reason: outcome.report.failed.error });
|
|
737
|
+
else receipts.push({ actionId: id, twin, status: 'skipped', at: worldNow() });
|
|
738
|
+
}
|
|
739
|
+
} else {
|
|
740
|
+
for (const id of landedIds) receipts.push({ actionId: id, twin, status: 'landed', at: worldNow() });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const path = join(worldChangesetsDir(name, worldRoot), `${changeset.name}.json`);
|
|
744
|
+
const store = getActiveWorldStore();
|
|
745
|
+
if (!store.exists(path)) store.write(path, `${JSON.stringify(changeset, null, 2)}\n`);
|
|
746
|
+
return receipts;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The state service a twin records under: its own id, else its single state dir, else its id. */
|
|
750
|
+
function stateServiceOf(controlRoot: string, vendor: string): string {
|
|
751
|
+
const store = getActiveWorldStore();
|
|
752
|
+
const stateRoot = join(controlRoot, stateDirName(), 'world');
|
|
753
|
+
if (store.exists(join(stateRoot, vendor))) return vendor;
|
|
754
|
+
const dirs = store.list(stateRoot).filter((entry) => store.stat(join(stateRoot, entry))?.isDirectory);
|
|
755
|
+
return dirs.length === 1 ? dirs[0]! : vendor;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Confirm locally what a served world answered: each receipt lands the entry's copy on the parent
|
|
759
|
+
* log with that receipt, so `log --receipts` shows it and the entry stops being unpushed. */
|
|
760
|
+
export function landReceipts(controlRoot: string, state: string, receipts: Array<{ actionId: string } & Receipt>, at: string = worldNow()): void {
|
|
761
|
+
const entries = new Map(branchEntries(state, controlRoot).map((e) => [e.id, e]));
|
|
762
|
+
for (const r of receipts) {
|
|
763
|
+
const entry = entries.get(r.actionId);
|
|
764
|
+
if (!entry) continue; // not this branch's entry (a changeset pushed from elsewhere)
|
|
765
|
+
confirmAction({ service: state, actionId: r.actionId, subject: entry.subject, fields: entry.fields ?? {}, occurredAt: at, root: controlRoot, receipt: { status: r.status, at: r.at, ...(r.externalId ? { externalId: r.externalId } : {}), ...(r.reason ? { reason: r.reason } : {}) }, ...(r.externalId ? { vendorSubjectId: r.externalId } : {}) });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** The twins of a world that refresh on a cadence: each rooted twin with `root.refresh.every` ("15m",
|
|
770
|
+
* "1h", "30s"), else its pack's declared posture ("Refresh is the kernel's fold", 2). What a local
|
|
771
|
+
* host puts on timers and a hosted World on its object's alarm. */
|
|
772
|
+
export async function refreshSchedule(worldRoot: string, layout: WorldLayout, log: (line: string) => void = () => {}): Promise<Array<{ vendor: string; controlRoot: string; root: NonNullable<ReturnType<typeof rootForControlRoot>>; everyMs: number }>> {
|
|
773
|
+
const out: Array<{ vendor: string; controlRoot: string; root: NonNullable<ReturnType<typeof rootForControlRoot>>; everyMs: number }> = [];
|
|
774
|
+
for (const vendor of Object.keys(layout.services)) {
|
|
775
|
+
const controlRoot = join(layout.dirs.data, vendor);
|
|
776
|
+
const root = rootForControlRoot(controlRoot, vendor);
|
|
777
|
+
if (!root) continue;
|
|
778
|
+
const every = root.refresh?.every ?? (await refreshPosture(vendor, worldRoot))?.every;
|
|
779
|
+
if (!every) continue;
|
|
780
|
+
const ms = spanMs(every);
|
|
781
|
+
if (ms === null) { log(`${vendor}: refresh.every ${JSON.stringify(every)} is not <N>(s|m|h|d); not scheduled`); continue; }
|
|
782
|
+
out.push({ vendor, controlRoot, root, everyMs: Math.max(ms, 1_000) });
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Observe each scheduled twin's root on its cadence while the world serves locally. A failed refresh
|
|
788
|
+
* is logged, never fatal; the next tick tries again. */
|
|
789
|
+
export async function scheduleRefreshes(name: string, worldRoot: string, instance: WorldInstance, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<ReturnType<typeof setInterval>[]> {
|
|
790
|
+
const timers: ReturnType<typeof setInterval>[] = [];
|
|
791
|
+
for (const { vendor, controlRoot, root, everyMs } of await refreshSchedule(worldRoot, instance, log)) {
|
|
792
|
+
const timer = setInterval(() => { refreshTwin({ worldRoot, vendor, controlRoot, root, force: true }).then((r) => { if (!r.refreshed) log(`${vendor}: not refreshed: ${r.reason}`); }, (e: unknown) => log(`${vendor}: refresh failed: ${e instanceof Error ? e.message : String(e)}`)); }, everyMs);
|
|
793
|
+
timer.unref?.();
|
|
794
|
+
timers.push(timer);
|
|
795
|
+
}
|
|
796
|
+
return timers;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** A remote given as a PATH: the world in that directory, on its checked-out branch, as `fetch` and
|
|
800
|
+
* `push` reach it with no server between — git's file transport. The world must have booted at
|
|
801
|
+
* least once (its logs live in its instance); `serve` or `up` makes that so. */
|
|
802
|
+
export function isPathRemote(target: string): boolean { return !/^[a-z][a-z0-9+.-]*:\/\//i.test(target); }
|
|
803
|
+
export function localWorld(target: string): { root: string; name: string; served: string; instance: WorldInstance } {
|
|
804
|
+
const root = resolve(target);
|
|
805
|
+
const configRef = existsSync(join(root, stateDirName(), 'world.json')) ? join(root, stateDirName(), 'world.json') : null;
|
|
806
|
+
if (!configRef) throw new Error(`${root} is not a world (no .volter/world.json)`);
|
|
807
|
+
const { config } = loadWorldConfig(configRef, root);
|
|
808
|
+
const current = join(root, stateDirName(), 'current');
|
|
809
|
+
const name = existsSync(current) && readFileSync(current, 'utf8').trim() !== '' ? readFileSync(current, 'utf8').trim() : config.id;
|
|
810
|
+
const instance = statusWorld(name, root);
|
|
811
|
+
return { root, name, served: config.bare?.name ?? `${config.id}/${config.id}`, instance };
|
|
812
|
+
}
|