@volter/world-runtime 3.0.9 → 3.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/served-world.d.ts +14 -1
- package/dist/src/served-world.js +151 -3
- package/dist/src/world-view.js +4 -2
- package/package.json +4 -4
- package/src/served-world.ts +119 -5
- package/src/world-view.ts +4 -2
|
@@ -35,6 +35,8 @@ export type ServedWorld = {
|
|
|
35
35
|
export declare function sessionsFile(worldRoot: string): string;
|
|
36
36
|
/** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
|
|
37
37
|
export declare function keysFile(worldRoot: string): string;
|
|
38
|
+
/** Where a World keeps its boards as its people arranged them (what each hides or includes, and its frames' places): `.volter/board.json`. */
|
|
39
|
+
export declare function boardFile(worldRoot: string): string;
|
|
38
40
|
/** The served name: `bare.name` (`acme/team`), else `<id>/<id>`. */
|
|
39
41
|
export declare function servedName(worldRoot: string, configRef: string): string;
|
|
40
42
|
/** A world MOUNTED for serving: its doors, its tokens, and a boot that writes the serve record once the
|
|
@@ -63,7 +65,8 @@ export declare function mountWorld(name: string, opts?: {
|
|
|
63
65
|
passIssuers?: TrustedIssuer[];
|
|
64
66
|
root?: string; /** the host's branches of this World, by its served name */
|
|
65
67
|
branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
|
|
66
|
-
browserOrigin?: (served: string) => string | null; /**
|
|
68
|
+
browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */
|
|
69
|
+
sitesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
|
|
67
70
|
localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
|
|
68
71
|
localPerson?: string; /** DoorHost.serveBranch */
|
|
69
72
|
serveBranch?: (branch: string) => Promise<void>;
|
|
@@ -118,6 +121,9 @@ export type DoorHost = {
|
|
|
118
121
|
* host gives each World one: its session lives only there (docs/contributing/architecture.md,
|
|
119
122
|
* "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
|
|
120
123
|
browserOrigin?: () => string | null;
|
|
124
|
+
/** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
|
|
125
|
+
* (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
|
|
126
|
+
sitesOrigin?: () => string | null;
|
|
121
127
|
/** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
|
|
122
128
|
originOf?: (served: string) => string | null;
|
|
123
129
|
/** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
|
|
@@ -350,6 +356,13 @@ export declare class WorldDoors {
|
|
|
350
356
|
private branchesDoor;
|
|
351
357
|
/** The newest entry's instant across the World's twins (ms), or null when it has none. */
|
|
352
358
|
private newestEntry;
|
|
359
|
+
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
360
|
+
* hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
|
|
361
|
+
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them; and
|
|
362
|
+
* the boards as this World's people arranged them. */
|
|
363
|
+
private board;
|
|
364
|
+
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
365
|
+
private savedBoards;
|
|
353
366
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
354
367
|
private map;
|
|
355
368
|
/** THE TIMELINE: the entries of every twin's log merged, newest first, by each entry's instant, then
|
package/dist/src/served-world.js
CHANGED
|
@@ -87,6 +87,43 @@ export function readServeRecord(worldRoot) {
|
|
|
87
87
|
export function sessionsFile(worldRoot) { return join(worldRoot, stateDirName(), 'sessions.json'); }
|
|
88
88
|
/** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
|
|
89
89
|
export function keysFile(worldRoot) { return join(worldRoot, stateDirName(), 'keys.json'); }
|
|
90
|
+
/** Where a World keeps its boards as its people arranged them (what each hides or includes, and its frames' places): `.volter/board.json`. */
|
|
91
|
+
export function boardFile(worldRoot) { return join(worldRoot, stateDirName(), 'board.json'); }
|
|
92
|
+
const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
|
|
93
|
+
/** The largest board document a World keeps. */
|
|
94
|
+
const BOARD_BYTES = 256 * 1024;
|
|
95
|
+
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
96
|
+
/** A kept board document's version: its bytes' digest, or `none` before one is kept. */
|
|
97
|
+
const boardVersion = (text) => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
|
|
98
|
+
function boardsOf(body) {
|
|
99
|
+
const list = body?.boards;
|
|
100
|
+
if (!Array.isArray(list) || list.length < 1 || list.length > 50)
|
|
101
|
+
return 'boards: a list of 1-50 boards';
|
|
102
|
+
const ids = new Set();
|
|
103
|
+
for (const b of list) {
|
|
104
|
+
if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed'].includes(k)))
|
|
105
|
+
return 'a board has only id, name, include, hidden and placed';
|
|
106
|
+
if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id))
|
|
107
|
+
return 'a board id is unique, a-z 0-9 -';
|
|
108
|
+
ids.add(b.id);
|
|
109
|
+
if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80)
|
|
110
|
+
return 'a board name is 1-80 characters';
|
|
111
|
+
for (const k of ['include', 'hidden'])
|
|
112
|
+
if (b[k] !== undefined && (!Array.isArray(b[k]) || b[k].length > 2000 || !b[k].every((x) => typeof x === 'string' && BOARD_KEY.test(x))))
|
|
113
|
+
return `${k}: frame and section names`;
|
|
114
|
+
if (b.placed !== undefined) {
|
|
115
|
+
const placed = b.placed;
|
|
116
|
+
if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000)
|
|
117
|
+
return 'placed: a frame name to its place';
|
|
118
|
+
for (const [k, v] of Object.entries(placed))
|
|
119
|
+
if (!BOARD_KEY.test(k) || !v || typeof v !== 'object' || Object.keys(v).join() !== 'x,y' || !Object.values(v).every((n) => typeof n === 'number' && Number.isFinite(n)))
|
|
120
|
+
return 'placed: { x, y } by frame name';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (list[0].include !== undefined)
|
|
124
|
+
return 'the first board is everything: it includes no list';
|
|
125
|
+
return list;
|
|
126
|
+
}
|
|
90
127
|
const keyHash = (key) => createHash('sha256').update(key).digest('hex');
|
|
91
128
|
function mintToken(prefix) { return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`; }
|
|
92
129
|
function writeSecret(path, value) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${value}\n`, { mode: 0o600 }); chmodSync(path, 0o600); }
|
|
@@ -135,6 +172,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
135
172
|
clockPath: () => clockFile(worldRoot, name),
|
|
136
173
|
...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
|
|
137
174
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
|
|
175
|
+
...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin(served) } : {}),
|
|
138
176
|
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
139
177
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
140
178
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
|
|
@@ -943,9 +981,9 @@ export class WorldDoors {
|
|
|
943
981
|
headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
944
982
|
// where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
|
|
945
983
|
// origin on this machine (siteOfHost); a World without one shows none
|
|
946
|
-
const
|
|
947
|
-
const sites =
|
|
948
|
-
if (sites
|
|
984
|
+
const at = this.host.sitesOrigin?.() ?? null;
|
|
985
|
+
const sites = at ? new URL(at) : null;
|
|
986
|
+
if (sites)
|
|
949
987
|
headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`);
|
|
950
988
|
else
|
|
951
989
|
headers.delete(TWIN_SITES_HEADER);
|
|
@@ -1181,6 +1219,50 @@ export class WorldDoors {
|
|
|
1181
1219
|
}
|
|
1182
1220
|
if (kind === 'map' && !name && request.method === 'GET')
|
|
1183
1221
|
return Response.json(await this.map());
|
|
1222
|
+
if (kind === 'board' && !name && request.method === 'GET')
|
|
1223
|
+
return Response.json(await this.board(url));
|
|
1224
|
+
if (kind === 'board' && !name && request.method === 'PUT') {
|
|
1225
|
+
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
|
|
1226
|
+
return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
|
|
1227
|
+
if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES)
|
|
1228
|
+
return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
|
|
1229
|
+
// read as it arrives, never past the cap (a chunked body names no length)
|
|
1230
|
+
const chunks = [];
|
|
1231
|
+
let size = 0;
|
|
1232
|
+
const reader = request.body?.getReader();
|
|
1233
|
+
for (let part = await reader?.read(); part && !part.done; part = await reader.read()) {
|
|
1234
|
+
size += part.value.byteLength;
|
|
1235
|
+
if (size > BOARD_BYTES) {
|
|
1236
|
+
await reader.cancel();
|
|
1237
|
+
return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
|
|
1238
|
+
}
|
|
1239
|
+
chunks.push(part.value);
|
|
1240
|
+
}
|
|
1241
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
1242
|
+
let body;
|
|
1243
|
+
try {
|
|
1244
|
+
body = JSON.parse(text);
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
return Response.json({ error: 'the board is JSON' }, { status: 400 });
|
|
1248
|
+
}
|
|
1249
|
+
// a save names the version it was made from: one made from a version another save has since replaced is refused,
|
|
1250
|
+
// with the boards as they now stand, never written over them
|
|
1251
|
+
return this.serialized('board', async () => {
|
|
1252
|
+
const held = this.savedBoards();
|
|
1253
|
+
const from = body?.version;
|
|
1254
|
+
if (typeof from !== 'string')
|
|
1255
|
+
return Response.json({ error: 'a board save names the version it was made from (the version GET answered)', ...held }, { status: 409 });
|
|
1256
|
+
if (from !== held.version)
|
|
1257
|
+
return Response.json({ error: 'the board changed since this was made from it', ...held }, { status: 409 });
|
|
1258
|
+
const boards = boardsOf({ boards: body.boards });
|
|
1259
|
+
if (typeof boards === 'string')
|
|
1260
|
+
return Response.json({ error: boards }, { status: 400 });
|
|
1261
|
+
const kept = `${JSON.stringify({ boards })}\n`;
|
|
1262
|
+
getActiveWorldStore().write(boardFile(this.worldRoot), kept);
|
|
1263
|
+
return Response.json({ world: this.served, boards, version: boardVersion(kept) });
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1184
1266
|
if (kind === 'timeline' && !name && request.method === 'GET')
|
|
1185
1267
|
return this.timeline(url);
|
|
1186
1268
|
if (kind === 'diff' && !name && request.method === 'GET') {
|
|
@@ -1794,6 +1876,72 @@ export class WorldDoors {
|
|
|
1794
1876
|
}
|
|
1795
1877
|
return newest;
|
|
1796
1878
|
}
|
|
1879
|
+
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
1880
|
+
* hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
|
|
1881
|
+
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them; and
|
|
1882
|
+
* the boards as this World's people arranged them. */
|
|
1883
|
+
async board(url) {
|
|
1884
|
+
const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
1885
|
+
const routed = this.host.sitesOrigin?.() ?? null;
|
|
1886
|
+
const sites = routed ? new URL(routed) : null;
|
|
1887
|
+
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
1888
|
+
const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
|
|
1889
|
+
const changes = delta.actions.filter((a) => !received.has(a.action.id));
|
|
1890
|
+
const frames = [];
|
|
1891
|
+
for (const vendor of Object.keys(this.up().services)) {
|
|
1892
|
+
const twin = this.host.twinFetch(vendor);
|
|
1893
|
+
let listed = [];
|
|
1894
|
+
try {
|
|
1895
|
+
const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1896
|
+
listed = answer?.ok ? ((await answer.json()).frames ?? []) : [];
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
listed = [];
|
|
1900
|
+
}
|
|
1901
|
+
// a pack that lays out no board yet keeps its vendor's home on it: its root workspace, else its first unparameterized one
|
|
1902
|
+
if (!listed.length) {
|
|
1903
|
+
const screens = await this.screensOf(vendor);
|
|
1904
|
+
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
1905
|
+
if (home)
|
|
1906
|
+
frames.push({ vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` }, changes: changes.filter((c) => c.service === vendor).length ? new Set(changes.filter((c) => c.service === vendor).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id)).size : 0 });
|
|
1907
|
+
}
|
|
1908
|
+
for (const f of listed) {
|
|
1909
|
+
let at = null;
|
|
1910
|
+
// why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
|
|
1911
|
+
let unseen;
|
|
1912
|
+
try {
|
|
1913
|
+
const u = new URL(f.url);
|
|
1914
|
+
if (claimed.get(vendor)?.has(u.hostname.toLowerCase()))
|
|
1915
|
+
at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
|
|
1916
|
+
else if (f.drawn)
|
|
1917
|
+
at = { screen: `${u.pathname}${u.search}` };
|
|
1918
|
+
if (!at)
|
|
1919
|
+
unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
1920
|
+
}
|
|
1921
|
+
catch {
|
|
1922
|
+
at = null;
|
|
1923
|
+
unseen = 'not-drawn';
|
|
1924
|
+
}
|
|
1925
|
+
const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
|
|
1926
|
+
// one change per request that made it: a deploy's script, versions and assets are one act, its domain another
|
|
1927
|
+
const acts = new Set(changes.filter((c) => c.service === vendor && covered.has(`${c.action.subject.type}:${c.action.subject.id}`)).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id));
|
|
1928
|
+
frames.push({ vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}), changes: acts.size });
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
return { world: this.served, frames, ...this.savedBoards() };
|
|
1932
|
+
}
|
|
1933
|
+
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
1934
|
+
savedBoards() {
|
|
1935
|
+
const text = getActiveWorldStore().read(boardFile(this.worldRoot));
|
|
1936
|
+
let boards = [{ id: 'everything', name: 'Everything' }];
|
|
1937
|
+
try {
|
|
1938
|
+
const kept = boardsOf(JSON.parse(text ?? 'null'));
|
|
1939
|
+
if (typeof kept !== 'string')
|
|
1940
|
+
boards = kept;
|
|
1941
|
+
}
|
|
1942
|
+
catch { /* never arranged */ }
|
|
1943
|
+
return { boards, version: boardVersion(text) };
|
|
1944
|
+
}
|
|
1797
1945
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
1798
1946
|
async map() {
|
|
1799
1947
|
const twins = [];
|
package/dist/src/world-view.js
CHANGED
|
@@ -48,7 +48,9 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
48
48
|
// (the first World mounted is the World; every later one is a branch of it)
|
|
49
49
|
let base = null;
|
|
50
50
|
const browserOrigin = origins ? (served) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
|
|
51
|
-
|
|
51
|
+
// sites are routed at each World's own *.localhost origin, never behind the proxy
|
|
52
|
+
const sitesOrigin = origins && !proxied ? (served) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
53
|
+
const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
|
|
52
54
|
const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
53
55
|
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
54
56
|
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
@@ -90,7 +92,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
90
92
|
switching = false;
|
|
91
93
|
}
|
|
92
94
|
};
|
|
93
|
-
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
95
|
+
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
94
96
|
let world = await mountWorld(name, mountOpts);
|
|
95
97
|
base = world.served;
|
|
96
98
|
worlds.set(world.served, world);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.10",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -68,14 +68,14 @@
|
|
|
68
68
|
"node": ">=22.3"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@volter/world-core": "3.0.
|
|
72
|
-
"@volter/world-console": "3.0.
|
|
71
|
+
"@volter/world-core": "3.0.10",
|
|
72
|
+
"@volter/world-console": "3.0.5"
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
75
|
"@electric-sql/pglite": "0.5.8",
|
|
76
76
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
77
77
|
"@volter/world-access": "3.0.1",
|
|
78
|
-
"@volter/world-core": "3.0.
|
|
78
|
+
"@volter/world-core": "3.0.10",
|
|
79
79
|
"pg-gateway": "0.3.0-beta.4",
|
|
80
80
|
"smol-toml": "^1.8.0",
|
|
81
81
|
"yaml": "2.9.0"
|
package/src/served-world.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
29
|
-
import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core/runtime';
|
|
29
|
+
import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
31
31
|
import { packFacts } from './pack-facts.ts';
|
|
32
32
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
|
|
@@ -80,6 +80,36 @@ export type ServedWorld = { url: string; base: string; name: string; port: numbe
|
|
|
80
80
|
export function sessionsFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'sessions.json'); }
|
|
81
81
|
/** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
|
|
82
82
|
export function keysFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'keys.json'); }
|
|
83
|
+
/** Where a World keeps its boards as its people arranged them (what each hides or includes, and its frames' places): `.volter/board.json`. */
|
|
84
|
+
export function boardFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'board.json'); }
|
|
85
|
+
/** A board as the World keeps it: a frame or a section is named `<vendor>:<frame id>` or `<vendor>:section:<section id>`.
|
|
86
|
+
* The first board is everything the packs lay out; another holds what it includes, a section with its frames to come. */
|
|
87
|
+
type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }> };
|
|
88
|
+
const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
|
|
89
|
+
/** The largest board document a World keeps. */
|
|
90
|
+
const BOARD_BYTES = 256 * 1024;
|
|
91
|
+
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
92
|
+
/** A kept board document's version: its bytes' digest, or `none` before one is kept. */
|
|
93
|
+
const boardVersion = (text: string | null): string => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
|
|
94
|
+
function boardsOf(body: unknown): SavedBoard[] | string {
|
|
95
|
+
const list = (body as { boards?: unknown } | null)?.boards;
|
|
96
|
+
if (!Array.isArray(list) || list.length < 1 || list.length > 50) return 'boards: a list of 1-50 boards';
|
|
97
|
+
const ids = new Set<string>();
|
|
98
|
+
for (const b of list as Array<Record<string, unknown>>) {
|
|
99
|
+
if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed'].includes(k))) return 'a board has only id, name, include, hidden and placed';
|
|
100
|
+
if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id)) return 'a board id is unique, a-z 0-9 -';
|
|
101
|
+
ids.add(b.id);
|
|
102
|
+
if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) return 'a board name is 1-80 characters';
|
|
103
|
+
for (const k of ['include', 'hidden'] as const) if (b[k] !== undefined && (!Array.isArray(b[k]) || (b[k] as unknown[]).length > 2000 || !(b[k] as unknown[]).every((x) => typeof x === 'string' && BOARD_KEY.test(x)))) return `${k}: frame and section names`;
|
|
104
|
+
if (b.placed !== undefined) {
|
|
105
|
+
const placed = b.placed as Record<string, unknown>;
|
|
106
|
+
if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000) return 'placed: a frame name to its place';
|
|
107
|
+
for (const [k, v] of Object.entries(placed)) if (!BOARD_KEY.test(k) || !v || typeof v !== 'object' || Object.keys(v).join() !== 'x,y' || !Object.values(v).every((n) => typeof n === 'number' && Number.isFinite(n))) return 'placed: { x, y } by frame name';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if ((list[0] as SavedBoard).include !== undefined) return 'the first board is everything: it includes no list';
|
|
111
|
+
return list as SavedBoard[];
|
|
112
|
+
}
|
|
83
113
|
/** A named key as the World keeps it: the SHA-256 of the key, what it may do, and when it was made and last used. */
|
|
84
114
|
type HeldKey = { id: string; name: string; scope: 'read' | 'write'; hash: string; createdAt: string; lastUsedAt?: string;
|
|
85
115
|
/** who made it: the World's token, or a person's session (the subject a pass named, and its platform) */
|
|
@@ -119,7 +149,7 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
|
|
|
119
149
|
/** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
|
|
120
150
|
type HeldSession = { scope: 'read' | 'write'; until: number; /** opened by this machine's own page (localSession) */ local?: true; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
|
|
121
151
|
|
|
122
|
-
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
152
|
+
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */ sitesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
123
153
|
const worldRoot = resolve(opts.root ?? process.cwd());
|
|
124
154
|
const configRef = configRefFor(worldRoot, name);
|
|
125
155
|
if (loadWorldConfig(configRef, worldRoot).config.resources) {
|
|
@@ -147,6 +177,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
147
177
|
clockPath: () => clockFile(worldRoot, name),
|
|
148
178
|
...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
|
|
149
179
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
|
|
180
|
+
...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin!(served) } : {}),
|
|
150
181
|
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
151
182
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
152
183
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
|
|
@@ -298,6 +329,9 @@ export type DoorHost = {
|
|
|
298
329
|
* host gives each World one: its session lives only there (docs/contributing/architecture.md,
|
|
299
330
|
* "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
|
|
300
331
|
browserOrigin?: () => string | null;
|
|
332
|
+
/** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
|
|
333
|
+
* (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
|
|
334
|
+
sitesOrigin?: () => string | null;
|
|
301
335
|
/** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
|
|
302
336
|
originOf?: (served: string) => string | null;
|
|
303
337
|
/** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
|
|
@@ -829,9 +863,9 @@ export class WorldDoors {
|
|
|
829
863
|
headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
830
864
|
// where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
|
|
831
865
|
// origin on this machine (siteOfHost); a World without one shows none
|
|
832
|
-
const
|
|
833
|
-
const sites =
|
|
834
|
-
if (sites
|
|
866
|
+
const at = this.host.sitesOrigin?.() ?? null;
|
|
867
|
+
const sites = at ? new URL(at) : null;
|
|
868
|
+
if (sites) headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`); else headers.delete(TWIN_SITES_HEADER);
|
|
835
869
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
836
870
|
const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
837
871
|
// an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
|
|
@@ -1017,6 +1051,35 @@ export class WorldDoors {
|
|
|
1017
1051
|
return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
|
|
1018
1052
|
}
|
|
1019
1053
|
if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
|
|
1054
|
+
if (kind === 'board' && !name && request.method === 'GET') return Response.json(await this.board(url));
|
|
1055
|
+
if (kind === 'board' && !name && request.method === 'PUT') {
|
|
1056
|
+
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
|
|
1057
|
+
if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES) return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
|
|
1058
|
+
// read as it arrives, never past the cap (a chunked body names no length)
|
|
1059
|
+
const chunks: Uint8Array[] = []; let size = 0;
|
|
1060
|
+
const reader = request.body?.getReader();
|
|
1061
|
+
for (let part = await reader?.read(); part && !part.done; part = await reader!.read()) {
|
|
1062
|
+
size += part.value.byteLength;
|
|
1063
|
+
if (size > BOARD_BYTES) { await reader!.cancel(); return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 }); }
|
|
1064
|
+
chunks.push(part.value);
|
|
1065
|
+
}
|
|
1066
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
1067
|
+
let body: unknown;
|
|
1068
|
+
try { body = JSON.parse(text); } catch { return Response.json({ error: 'the board is JSON' }, { status: 400 }); }
|
|
1069
|
+
// a save names the version it was made from: one made from a version another save has since replaced is refused,
|
|
1070
|
+
// with the boards as they now stand, never written over them
|
|
1071
|
+
return this.serialized('board', async () => {
|
|
1072
|
+
const held = this.savedBoards();
|
|
1073
|
+
const from = (body as { version?: unknown } | null)?.version;
|
|
1074
|
+
if (typeof from !== 'string') return Response.json({ error: 'a board save names the version it was made from (the version GET answered)', ...held }, { status: 409 });
|
|
1075
|
+
if (from !== held.version) return Response.json({ error: 'the board changed since this was made from it', ...held }, { status: 409 });
|
|
1076
|
+
const boards = boardsOf({ boards: (body as { boards?: unknown }).boards });
|
|
1077
|
+
if (typeof boards === 'string') return Response.json({ error: boards }, { status: 400 });
|
|
1078
|
+
const kept = `${JSON.stringify({ boards })}\n`;
|
|
1079
|
+
getActiveWorldStore().write(boardFile(this.worldRoot), kept);
|
|
1080
|
+
return Response.json({ world: this.served, boards, version: boardVersion(kept) });
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1020
1083
|
if (kind === 'timeline' && !name && request.method === 'GET') return this.timeline(url);
|
|
1021
1084
|
if (kind === 'diff' && !name && request.method === 'GET') {
|
|
1022
1085
|
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
@@ -1500,6 +1563,57 @@ export class WorldDoors {
|
|
|
1500
1563
|
return newest;
|
|
1501
1564
|
}
|
|
1502
1565
|
|
|
1566
|
+
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
1567
|
+
* hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
|
|
1568
|
+
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them; and
|
|
1569
|
+
* the boards as this World's people arranged them. */
|
|
1570
|
+
private async board(url: URL): Promise<unknown> {
|
|
1571
|
+
const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
1572
|
+
const routed = this.host.sitesOrigin?.() ?? null;
|
|
1573
|
+
const sites = routed ? new URL(routed) : null;
|
|
1574
|
+
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
1575
|
+
const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
|
|
1576
|
+
const changes = delta.actions.filter((a) => !received.has(a.action.id));
|
|
1577
|
+
const frames: unknown[] = [];
|
|
1578
|
+
for (const vendor of Object.keys(this.up().services)) {
|
|
1579
|
+
const twin = this.host.twinFetch(vendor);
|
|
1580
|
+
let listed: BoardFrame[] = [];
|
|
1581
|
+
try {
|
|
1582
|
+
const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1583
|
+
listed = answer?.ok ? (((await answer.json()) as { frames?: BoardFrame[] }).frames ?? []) : [];
|
|
1584
|
+
} catch { listed = []; }
|
|
1585
|
+
// a pack that lays out no board yet keeps its vendor's home on it: its root workspace, else its first unparameterized one
|
|
1586
|
+
if (!listed.length) {
|
|
1587
|
+
const screens = await this.screensOf(vendor);
|
|
1588
|
+
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
1589
|
+
if (home) frames.push({ vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` }, changes: changes.filter((c) => c.service === vendor).length ? new Set(changes.filter((c) => c.service === vendor).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id)).size : 0 });
|
|
1590
|
+
}
|
|
1591
|
+
for (const f of listed) {
|
|
1592
|
+
let at: { site: string } | { screen: string } | null = null;
|
|
1593
|
+
// why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
|
|
1594
|
+
let unseen: 'not-drawn' | 'sites-not-routed' | undefined;
|
|
1595
|
+
try {
|
|
1596
|
+
const u = new URL(f.url);
|
|
1597
|
+
if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
|
|
1598
|
+
else if (f.drawn) at = { screen: `${u.pathname}${u.search}` };
|
|
1599
|
+
if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
1600
|
+
} catch { at = null; unseen = 'not-drawn'; }
|
|
1601
|
+
const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
|
|
1602
|
+
// one change per request that made it: a deploy's script, versions and assets are one act, its domain another
|
|
1603
|
+
const acts = new Set(changes.filter((c) => c.service === vendor && covered.has(`${c.action.subject.type}:${c.action.subject.id}`)).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id));
|
|
1604
|
+
frames.push({ vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}), changes: acts.size });
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return { world: this.served, frames, ...this.savedBoards() };
|
|
1608
|
+
}
|
|
1609
|
+
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
1610
|
+
private savedBoards(): { boards: SavedBoard[]; version: string } {
|
|
1611
|
+
const text = getActiveWorldStore().read(boardFile(this.worldRoot));
|
|
1612
|
+
let boards: SavedBoard[] = [{ id: 'everything', name: 'Everything' }];
|
|
1613
|
+
try { const kept = boardsOf(JSON.parse(text ?? 'null')); if (typeof kept !== 'string') boards = kept; } catch { /* never arranged */ }
|
|
1614
|
+
return { boards, version: boardVersion(text) };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1503
1617
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
1504
1618
|
private async map(): Promise<unknown> {
|
|
1505
1619
|
const twins: unknown[] = [];
|
package/src/world-view.ts
CHANGED
|
@@ -49,7 +49,9 @@ export async function serveWorldView(name: string, opts: { /** the person signed
|
|
|
49
49
|
// (the first World mounted is the World; every later one is a branch of it)
|
|
50
50
|
let base: string | null = null;
|
|
51
51
|
const browserOrigin = origins ? (served: string) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
|
|
52
|
-
|
|
52
|
+
// sites are routed at each World's own *.localhost origin, never behind the proxy
|
|
53
|
+
const sitesOrigin = origins && !proxied ? (served: string) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
54
|
+
const mountAt = (at: string): Promise<MountedWorld> => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
|
|
53
55
|
const branches: LocalBranches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
54
56
|
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
55
57
|
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
@@ -80,7 +82,7 @@ export async function serveWorldView(name: string, opts: { /** the person signed
|
|
|
80
82
|
if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id) rmSync(current, { force: true }); else writeFileSync(current, `${branch}\n`);
|
|
81
83
|
} finally { switching = false; }
|
|
82
84
|
};
|
|
83
|
-
const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
85
|
+
const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
84
86
|
let world = await mountWorld(name, mountOpts);
|
|
85
87
|
base = world.served;
|
|
86
88
|
worlds.set(world.served, world);
|