@volter/world-runtime 3.0.9 → 3.0.11

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,19 @@ 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, with
362
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
363
+ * one frame at a time (`board/changes`). */
364
+ private board;
365
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
366
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
367
+ private boardChanges;
368
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
369
+ private boardFrames;
370
+ /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
371
+ private savedBoards;
353
372
  /** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
354
373
  private map;
355
374
  /** THE TIMELINE: the entries of every twin's log merged, newest first, by each entry's instant, then
@@ -87,6 +87,59 @@ 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
+ /** A change's act: the request that made it (its writes share one correlation id), else the write itself. */
99
+ const actOf = (c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : c.action.id);
100
+ function boardsOf(body) {
101
+ const list = body?.boards;
102
+ if (!Array.isArray(list) || list.length < 1 || list.length > 50)
103
+ return 'boards: a list of 1-50 boards';
104
+ const ids = new Set();
105
+ for (const b of list) {
106
+ if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed', 'sections'].includes(k)))
107
+ return 'a board has only id, name, include, hidden, placed and sections';
108
+ if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id))
109
+ return 'a board id is unique, a-z 0-9 -';
110
+ ids.add(b.id);
111
+ if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80)
112
+ return 'a board name is 1-80 characters';
113
+ for (const k of ['include', 'hidden'])
114
+ if (b[k] !== undefined && (!Array.isArray(b[k]) || b[k].length > 2000 || !b[k].every((x) => typeof x === 'string' && BOARD_KEY.test(x))))
115
+ return `${k}: frame and section names`;
116
+ if (b.placed !== undefined) {
117
+ const placed = b.placed;
118
+ if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000)
119
+ return 'placed: a frame name to its place';
120
+ for (const [k, v] of Object.entries(placed))
121
+ 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)))
122
+ return 'placed: { x, y } by frame name';
123
+ }
124
+ if (b.sections !== undefined) {
125
+ const sections = b.sections;
126
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections) || Object.keys(sections).length > 500)
127
+ return 'sections: a section name to its region';
128
+ for (const [k, v] of Object.entries(sections)) {
129
+ const r = v;
130
+ // a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
131
+ if (!BOARD_KEY.test(k) || !(/^user:[a-z0-9]{1,32}$/.test(k) || /^[a-z0-9-]+:section:/.test(k)) || !r || typeof r !== 'object' || Object.keys(r).some((f) => !['x', 'y', 'w', 'h', 'name', 'gone'].includes(f))
132
+ || !['x', 'y'].every((f) => typeof r[f] === 'number' && Math.abs(r[f]) <= 1e7) || !['w', 'h'].every((f) => typeof r[f] === 'number' && r[f] >= 0 && r[f] <= 1e6)
133
+ || (r.gone !== true && (r.w < 100 || r.h < 100))
134
+ || (r.name !== undefined && (typeof r.name !== 'string' || !r.name.trim() || r.name.length > 80)) || (r.gone !== undefined && r.gone !== true))
135
+ return 'sections: { x, y, w, h, name?, gone? } by section name';
136
+ }
137
+ }
138
+ }
139
+ if (list[0].include !== undefined)
140
+ return 'the first board is everything: it includes no list';
141
+ return list;
142
+ }
90
143
  const keyHash = (key) => createHash('sha256').update(key).digest('hex');
91
144
  function mintToken(prefix) { return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`; }
92
145
  function writeSecret(path, value) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${value}\n`, { mode: 0o600 }); chmodSync(path, 0o600); }
@@ -135,6 +188,7 @@ export async function mountWorld(name, opts = {}) {
135
188
  clockPath: () => clockFile(worldRoot, name),
136
189
  ...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
137
190
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
191
+ ...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin(served) } : {}),
138
192
  ...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
139
193
  ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
140
194
  ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
@@ -795,6 +849,11 @@ export class WorldDoors {
795
849
  }
796
850
  return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
797
851
  }
852
+ // a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
853
+ if ((request.headers.get('accept') ?? '').includes('text/html')) {
854
+ const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
855
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
856
+ }
798
857
  return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
799
858
  }
800
859
  async handle(request) {
@@ -943,9 +1002,9 @@ export class WorldDoors {
943
1002
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
944
1003
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
945
1004
  // 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'))
1005
+ const at = this.host.sitesOrigin?.() ?? null;
1006
+ const sites = at ? new URL(at) : null;
1007
+ if (sites)
949
1008
  headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`);
950
1009
  else
951
1010
  headers.delete(TWIN_SITES_HEADER);
@@ -1181,6 +1240,52 @@ export class WorldDoors {
1181
1240
  }
1182
1241
  if (kind === 'map' && !name && request.method === 'GET')
1183
1242
  return Response.json(await this.map());
1243
+ if (kind === 'board' && !name && request.method === 'GET')
1244
+ return Response.json(await this.board(url));
1245
+ if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET')
1246
+ return await this.boardChanges(url);
1247
+ if (kind === 'board' && !name && request.method === 'PUT') {
1248
+ if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
1249
+ return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
1250
+ if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES)
1251
+ return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
1252
+ // read as it arrives, never past the cap (a chunked body names no length)
1253
+ const chunks = [];
1254
+ let size = 0;
1255
+ const reader = request.body?.getReader();
1256
+ for (let part = await reader?.read(); part && !part.done; part = await reader.read()) {
1257
+ size += part.value.byteLength;
1258
+ if (size > BOARD_BYTES) {
1259
+ await reader.cancel();
1260
+ return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
1261
+ }
1262
+ chunks.push(part.value);
1263
+ }
1264
+ const text = Buffer.concat(chunks).toString('utf8');
1265
+ let body;
1266
+ try {
1267
+ body = JSON.parse(text);
1268
+ }
1269
+ catch {
1270
+ return Response.json({ error: 'the board is JSON' }, { status: 400 });
1271
+ }
1272
+ // a save names the version it was made from: one made from a version another save has since replaced is refused,
1273
+ // with the boards as they now stand, never written over them
1274
+ return this.serialized('board', async () => {
1275
+ const held = this.savedBoards();
1276
+ const from = body?.version;
1277
+ if (typeof from !== 'string')
1278
+ return Response.json({ error: 'a board save names the version it was made from (the version GET answered)', ...held }, { status: 409 });
1279
+ if (from !== held.version)
1280
+ return Response.json({ error: 'the board changed since this was made from it', ...held }, { status: 409 });
1281
+ const boards = boardsOf({ boards: body.boards });
1282
+ if (typeof boards === 'string')
1283
+ return Response.json({ error: boards }, { status: 400 });
1284
+ const kept = `${JSON.stringify({ boards })}\n`;
1285
+ getActiveWorldStore().write(boardFile(this.worldRoot), kept);
1286
+ return Response.json({ world: this.served, boards, version: boardVersion(kept) });
1287
+ });
1288
+ }
1184
1289
  if (kind === 'timeline' && !name && request.method === 'GET')
1185
1290
  return this.timeline(url);
1186
1291
  if (kind === 'diff' && !name && request.method === 'GET') {
@@ -1794,6 +1899,101 @@ export class WorldDoors {
1794
1899
  }
1795
1900
  return newest;
1796
1901
  }
1902
+ /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
1903
+ * hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
1904
+ * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
1905
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
1906
+ * one frame at a time (`board/changes`). */
1907
+ async board(url) {
1908
+ const { frames, delta } = await this.boardFrames(url);
1909
+ // the changes are counted from the World's base: what was last cut or received, and when
1910
+ return { world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, frames: frames.map(({ frame, mine }) => ({ ...frame, changes: new Set(mine.map(actOf)).size, since: mine.map((c) => c.action.occurredAt).sort()[0] ?? null })), ...this.savedBoards() };
1911
+ }
1912
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
1913
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
1914
+ async boardChanges(url) {
1915
+ const key = url.searchParams.get('frame') ?? '';
1916
+ if (!key)
1917
+ return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
1918
+ const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1919
+ if (!found)
1920
+ return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
1921
+ const acts = new Map();
1922
+ for (const c of found.mine) {
1923
+ const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
1924
+ if (c.action.occurredAt > act.at)
1925
+ act.at = c.action.occurredAt;
1926
+ act.writes += 1;
1927
+ const named = Object.fromEntries(['title', 'name', 'hostname', 'email', 'text', 'subject', 'summary', 'description'].flatMap((k) => { const v = c.action.fields?.[k]; return typeof v === 'string' ? [[k, v.slice(0, 120)]] : []; }));
1928
+ if (act.entries.length < 50)
1929
+ act.entries.push({ op: c.action.op, ...(c.action.operation ? { operation: c.action.operation } : {}), subject: c.action.subject, fields: named });
1930
+ acts.set(actOf(c), act);
1931
+ }
1932
+ const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
1933
+ return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
1934
+ }
1935
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1936
+ async boardFrames(url) {
1937
+ const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1938
+ const routed = this.host.sitesOrigin?.() ?? null;
1939
+ const sites = routed ? new URL(routed) : null;
1940
+ const delta = diffWorld(this.name, { root: this.worldRoot });
1941
+ 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)));
1942
+ const changes = delta.actions.filter((a) => !received.has(a.action.id));
1943
+ const frames = [];
1944
+ for (const vendor of Object.keys(this.up().services)) {
1945
+ const twin = this.host.twinFetch(vendor);
1946
+ let listed = [];
1947
+ try {
1948
+ const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
1949
+ listed = answer?.ok ? ((await answer.json()).frames ?? []) : [];
1950
+ }
1951
+ catch {
1952
+ listed = [];
1953
+ }
1954
+ // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
1955
+ // one), whose changes are all of the vendor's
1956
+ if (!listed.length) {
1957
+ const screens = await this.screensOf(vendor);
1958
+ const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
1959
+ if (home)
1960
+ frames.push({ frame: { 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}` } }, mine: changes.filter((c) => c.service === vendor) });
1961
+ }
1962
+ for (const f of listed) {
1963
+ let at = null;
1964
+ // why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
1965
+ let unseen;
1966
+ try {
1967
+ const u = new URL(f.url);
1968
+ if (claimed.get(vendor)?.has(u.hostname.toLowerCase()))
1969
+ at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
1970
+ else if (f.drawn)
1971
+ at = { screen: `${u.pathname}${u.search}` };
1972
+ if (!at)
1973
+ unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
1974
+ }
1975
+ catch {
1976
+ at = null;
1977
+ unseen = 'not-drawn';
1978
+ }
1979
+ const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
1980
+ frames.push({ frame: { vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}) }, mine: changes.filter((x) => x.service === vendor && covered.has(`${x.action.subject.type}:${x.action.subject.id}`)) });
1981
+ }
1982
+ }
1983
+ return { delta, frames };
1984
+ }
1985
+ /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
1986
+ savedBoards() {
1987
+ const text = getActiveWorldStore().read(boardFile(this.worldRoot));
1988
+ let boards = [{ id: 'everything', name: 'Everything' }];
1989
+ try {
1990
+ const kept = boardsOf(JSON.parse(text ?? 'null'));
1991
+ if (typeof kept !== 'string')
1992
+ boards = kept;
1993
+ }
1994
+ catch { /* never arranged */ }
1995
+ return { boards, version: boardVersion(text) };
1996
+ }
1797
1997
  /** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
1798
1998
  async map() {
1799
1999
  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.9",
3
+ "version": "3.0.11",
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.9",
72
- "@volter/world-console": "3.0.4"
71
+ "@volter/world-core": "3.0.11",
72
+ "@volter/world-console": "3.0.6"
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.9",
78
+ "@volter/world-core": "3.0.11",
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,50 @@ 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 }>; /** each section's region and name, by a pack's group or `user:<id>`; a pack's group removed is `gone` */ sections?: Record<string, { x: number; y: number; w: number; h: number; name?: string; gone?: true }> };
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
+ /** A change's act: the request that made it (its writes share one correlation id), else the write itself. */
95
+ const actOf = (c: { action: { id: string; correlationId?: unknown } }): string => (typeof c.action.correlationId === 'string' ? c.action.correlationId : c.action.id);
96
+ function boardsOf(body: unknown): SavedBoard[] | string {
97
+ const list = (body as { boards?: unknown } | null)?.boards;
98
+ if (!Array.isArray(list) || list.length < 1 || list.length > 50) return 'boards: a list of 1-50 boards';
99
+ const ids = new Set<string>();
100
+ for (const b of list as Array<Record<string, unknown>>) {
101
+ if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed', 'sections'].includes(k))) return 'a board has only id, name, include, hidden, placed and sections';
102
+ 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 -';
103
+ ids.add(b.id);
104
+ if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) return 'a board name is 1-80 characters';
105
+ 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`;
106
+ if (b.placed !== undefined) {
107
+ const placed = b.placed as Record<string, unknown>;
108
+ if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000) return 'placed: a frame name to its place';
109
+ 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';
110
+ }
111
+ if (b.sections !== undefined) {
112
+ const sections = b.sections as Record<string, unknown>;
113
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections) || Object.keys(sections).length > 500) return 'sections: a section name to its region';
114
+ for (const [k, v] of Object.entries(sections)) {
115
+ const r = v as Record<string, unknown> | null;
116
+ // a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
117
+ if (!BOARD_KEY.test(k) || !(/^user:[a-z0-9]{1,32}$/.test(k) || /^[a-z0-9-]+:section:/.test(k)) || !r || typeof r !== 'object' || Object.keys(r).some((f) => !['x', 'y', 'w', 'h', 'name', 'gone'].includes(f))
118
+ || !['x', 'y'].every((f) => typeof r[f] === 'number' && Math.abs(r[f] as number) <= 1e7) || !['w', 'h'].every((f) => typeof r[f] === 'number' && (r[f] as number) >= 0 && (r[f] as number) <= 1e6)
119
+ || (r.gone !== true && ((r.w as number) < 100 || (r.h as number) < 100))
120
+ || (r.name !== undefined && (typeof r.name !== 'string' || !r.name.trim() || r.name.length > 80)) || (r.gone !== undefined && r.gone !== true)) return 'sections: { x, y, w, h, name?, gone? } by section name';
121
+ }
122
+ }
123
+ }
124
+ if ((list[0] as SavedBoard).include !== undefined) return 'the first board is everything: it includes no list';
125
+ return list as SavedBoard[];
126
+ }
83
127
  /** 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
128
  type HeldKey = { id: string; name: string; scope: 'read' | 'write'; hash: string; createdAt: string; lastUsedAt?: string;
85
129
  /** who made it: the World's token, or a person's session (the subject a pass named, and its platform) */
@@ -119,7 +163,7 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
119
163
  /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
120
164
  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
165
 
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> {
166
+ 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
167
  const worldRoot = resolve(opts.root ?? process.cwd());
124
168
  const configRef = configRefFor(worldRoot, name);
125
169
  if (loadWorldConfig(configRef, worldRoot).config.resources) {
@@ -147,6 +191,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
147
191
  clockPath: () => clockFile(worldRoot, name),
148
192
  ...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
149
193
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
194
+ ...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin!(served) } : {}),
150
195
  ...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
151
196
  ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
152
197
  ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
@@ -298,6 +343,9 @@ export type DoorHost = {
298
343
  * host gives each World one: its session lives only there (docs/contributing/architecture.md,
299
344
  * "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
300
345
  browserOrigin?: () => string | null;
346
+ /** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
347
+ * (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
348
+ sitesOrigin?: () => string | null;
301
349
  /** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
302
350
  originOf?: (served: string) => string | null;
303
351
  /** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
@@ -706,6 +754,11 @@ export class WorldDoors {
706
754
  if (location) { try { const to = new URL(location, `https://${host}`); if (to.hostname === host) out.set('location', `${to.pathname}${to.search}${to.hash}`); } catch { /* left as answered */ } }
707
755
  return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
708
756
  }
757
+ // a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
758
+ if ((request.headers.get('accept') ?? '').includes('text/html')) {
759
+ const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
760
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
761
+ }
709
762
  return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
710
763
  }
711
764
 
@@ -829,9 +882,9 @@ export class WorldDoors {
829
882
  headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
830
883
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
831
884
  // 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);
885
+ const at = this.host.sitesOrigin?.() ?? null;
886
+ const sites = at ? new URL(at) : null;
887
+ if (sites) headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`); else headers.delete(TWIN_SITES_HEADER);
835
888
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
836
889
  const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
837
890
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
@@ -1017,6 +1070,36 @@ export class WorldDoors {
1017
1070
  return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
1018
1071
  }
1019
1072
  if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
1073
+ if (kind === 'board' && !name && request.method === 'GET') return Response.json(await this.board(url));
1074
+ if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET') return await this.boardChanges(url);
1075
+ if (kind === 'board' && !name && request.method === 'PUT') {
1076
+ if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
1077
+ if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES) return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
1078
+ // read as it arrives, never past the cap (a chunked body names no length)
1079
+ const chunks: Uint8Array[] = []; let size = 0;
1080
+ const reader = request.body?.getReader();
1081
+ for (let part = await reader?.read(); part && !part.done; part = await reader!.read()) {
1082
+ size += part.value.byteLength;
1083
+ if (size > BOARD_BYTES) { await reader!.cancel(); return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 }); }
1084
+ chunks.push(part.value);
1085
+ }
1086
+ const text = Buffer.concat(chunks).toString('utf8');
1087
+ let body: unknown;
1088
+ try { body = JSON.parse(text); } catch { return Response.json({ error: 'the board is JSON' }, { status: 400 }); }
1089
+ // a save names the version it was made from: one made from a version another save has since replaced is refused,
1090
+ // with the boards as they now stand, never written over them
1091
+ return this.serialized('board', async () => {
1092
+ const held = this.savedBoards();
1093
+ const from = (body as { version?: unknown } | null)?.version;
1094
+ 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 });
1095
+ if (from !== held.version) return Response.json({ error: 'the board changed since this was made from it', ...held }, { status: 409 });
1096
+ const boards = boardsOf({ boards: (body as { boards?: unknown }).boards });
1097
+ if (typeof boards === 'string') return Response.json({ error: boards }, { status: 400 });
1098
+ const kept = `${JSON.stringify({ boards })}\n`;
1099
+ getActiveWorldStore().write(boardFile(this.worldRoot), kept);
1100
+ return Response.json({ world: this.served, boards, version: boardVersion(kept) });
1101
+ });
1102
+ }
1020
1103
  if (kind === 'timeline' && !name && request.method === 'GET') return this.timeline(url);
1021
1104
  if (kind === 'diff' && !name && request.method === 'GET') {
1022
1105
  const delta = diffWorld(this.name, { root: this.worldRoot });
@@ -1500,6 +1583,82 @@ export class WorldDoors {
1500
1583
  return newest;
1501
1584
  }
1502
1585
 
1586
+ /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
1587
+ * hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
1588
+ * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
1589
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
1590
+ * one frame at a time (`board/changes`). */
1591
+ private async board(url: URL): Promise<unknown> {
1592
+ const { frames, delta } = await this.boardFrames(url);
1593
+ // the changes are counted from the World's base: what was last cut or received, and when
1594
+ return { world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, frames: frames.map(({ frame, mine }) => ({ ...frame, changes: new Set(mine.map(actOf)).size, since: mine.map((c) => c.action.occurredAt).sort()[0] ?? null })), ...this.savedBoards() };
1595
+ }
1596
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
1597
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
1598
+ private async boardChanges(url: URL): Promise<Response> {
1599
+ const key = url.searchParams.get('frame') ?? '';
1600
+ if (!key) return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
1601
+ const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1602
+ if (!found) return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
1603
+ const acts = new Map<string, { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> }>();
1604
+ for (const c of found.mine) {
1605
+ const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
1606
+ if (c.action.occurredAt > act.at) act.at = c.action.occurredAt;
1607
+ act.writes += 1;
1608
+ const named = Object.fromEntries(['title', 'name', 'hostname', 'email', 'text', 'subject', 'summary', 'description'].flatMap((k) => { const v = (c.action.fields as Record<string, unknown> | undefined)?.[k]; return typeof v === 'string' ? [[k, v.slice(0, 120)]] : []; }));
1609
+ if (act.entries.length < 50) act.entries.push({ op: c.action.op, ...(c.action.operation ? { operation: c.action.operation } : {}), subject: c.action.subject, fields: named });
1610
+ acts.set(actOf(c), act);
1611
+ }
1612
+ const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
1613
+ return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
1614
+ }
1615
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1616
+ private async boardFrames(url: URL): Promise<{ delta: ReturnType<typeof diffWorld>; frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: Array<ReturnType<typeof diffWorld>['actions'][number]> }> }> {
1617
+ const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1618
+ const routed = this.host.sitesOrigin?.() ?? null;
1619
+ const sites = routed ? new URL(routed) : null;
1620
+ const delta = diffWorld(this.name, { root: this.worldRoot });
1621
+ 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)));
1622
+ const changes = delta.actions.filter((a) => !received.has(a.action.id));
1623
+ const frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: typeof changes }> = [];
1624
+ for (const vendor of Object.keys(this.up().services)) {
1625
+ const twin = this.host.twinFetch(vendor);
1626
+ let listed: BoardFrame[] = [];
1627
+ try {
1628
+ const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
1629
+ listed = answer?.ok ? (((await answer.json()) as { frames?: BoardFrame[] }).frames ?? []) : [];
1630
+ } catch { listed = []; }
1631
+ // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
1632
+ // one), whose changes are all of the vendor's
1633
+ if (!listed.length) {
1634
+ const screens = await this.screensOf(vendor);
1635
+ const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
1636
+ if (home) frames.push({ frame: { 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}` } }, mine: changes.filter((c) => c.service === vendor) });
1637
+ }
1638
+ for (const f of listed) {
1639
+ let at: { site: string } | { screen: string } | null = null;
1640
+ // why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
1641
+ let unseen: 'not-drawn' | 'sites-not-routed' | undefined;
1642
+ try {
1643
+ const u = new URL(f.url);
1644
+ if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
1645
+ else if (f.drawn) at = { screen: `${u.pathname}${u.search}` };
1646
+ if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
1647
+ } catch { at = null; unseen = 'not-drawn'; }
1648
+ const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
1649
+ frames.push({ frame: { vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}) }, mine: changes.filter((x) => x.service === vendor && covered.has(`${x.action.subject.type}:${x.action.subject.id}`)) });
1650
+ }
1651
+ }
1652
+ return { delta, frames };
1653
+ }
1654
+ /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
1655
+ private savedBoards(): { boards: SavedBoard[]; version: string } {
1656
+ const text = getActiveWorldStore().read(boardFile(this.worldRoot));
1657
+ let boards: SavedBoard[] = [{ id: 'everything', name: 'Everything' }];
1658
+ try { const kept = boardsOf(JSON.parse(text ?? 'null')); if (typeof kept !== 'string') boards = kept; } catch { /* never arranged */ }
1659
+ return { boards, version: boardVersion(text) };
1660
+ }
1661
+
1503
1662
  /** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
1504
1663
  private async map(): Promise<unknown> {
1505
1664
  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);