@volter/world-runtime 3.0.8 → 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.
@@ -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; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
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
@@ -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 own = this.host.browserOrigin?.() ?? null;
947
- const sites = own ? new URL(own) : null;
948
- if (sites && sites.hostname.endsWith('.localhost'))
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 = [];
@@ -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
- const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
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.8",
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.8",
72
- "@volter/world-console": "3.0.3"
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.8",
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"
@@ -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 own = this.host.browserOrigin?.() ?? null;
833
- const sites = own ? new URL(own) : null;
834
- if (sites && sites.hostname.endsWith('.localhost')) headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`); else headers.delete(TWIN_SITES_HEADER);
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
- 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 } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
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);