@volter/world-runtime 3.0.10 → 3.0.12

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.
@@ -920,11 +920,15 @@ export async function main() {
920
920
  caFile = join(mkdtempSync(join(tmpdir(), 'volter-attach-')), 'ca.pem');
921
921
  writeFileSync(caFile, manifest.ca);
922
922
  }
923
+ // the World's twins, each a loopback listener here that adds its key, so a command with its own HTTP stack reaches
924
+ // them as it would a local World's (the manifest pointed at the listeners before its env is made)
925
+ const { bridgeTwins } = createRequire(import.meta.url)('@volter/world-core/http-bridge');
926
+ await bridgeTwins(manifest, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '', manifest.ca ?? undefined);
923
927
  const injectPath = createRequire(import.meta.url).resolve('@volter/world-core/inject');
924
928
  const env = remoteAttachEnv(manifest, { injectPath, ...(caFile === undefined ? {} : { caFile }) });
925
929
  // the world's byte-stream doors, bridged to loopback listeners here, beside a command in any language
926
930
  const { bridgeStreams, drainStreams } = createRequire(import.meta.url)('@volter/world-core/stream-bridge');
927
- const bridged = manifest.streams ? await bridgeStreams(manifest.streams, attachToken ?? manifest.env.VOLTER_TWINS_KEY ?? '') : { env: {} };
931
+ const bridged = manifest.streams ? await bridgeStreams(manifest.streams, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '') : { env: {} };
928
932
  // VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
929
933
  const commandEnv = withoutVaultSignIn({ ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref });
930
934
  commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
@@ -358,9 +358,15 @@ export declare class WorldDoors {
358
358
  private newestEntry;
359
359
  /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
360
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. */
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`). */
363
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;
364
370
  /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
365
371
  private savedBoards;
366
372
  /** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
@@ -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, readFileSync, writeFileSync, rmSync } from 'node:fs';
29
- import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
29
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
30
30
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { packFacts } from "./pack-facts.js";
32
32
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
@@ -95,14 +95,16 @@ const BOARD_BYTES = 256 * 1024;
95
95
  /** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
96
96
  /** A kept board document's version: its bytes' digest, or `none` before one is kept. */
97
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);
98
100
  function boardsOf(body) {
99
101
  const list = body?.boards;
100
102
  if (!Array.isArray(list) || list.length < 1 || list.length > 50)
101
103
  return 'boards: a list of 1-50 boards';
102
104
  const ids = new Set();
103
105
  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 (!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';
106
108
  if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id))
107
109
  return 'a board id is unique, a-z 0-9 -';
108
110
  ids.add(b.id);
@@ -119,6 +121,20 @@ function boardsOf(body) {
119
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)))
120
122
  return 'placed: { x, y } by frame name';
121
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
+ }
122
138
  }
123
139
  if (list[0].include !== undefined)
124
140
  return 'the first board is everything: it includes no list';
@@ -833,6 +849,11 @@ export class WorldDoors {
833
849
  }
834
850
  return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
835
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
+ }
836
857
  return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
837
858
  }
838
859
  async handle(request) {
@@ -961,6 +982,12 @@ export class WorldDoors {
961
982
  // the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
962
983
  // request's log entries carry, so each entry is joined to its caller by an id no caller chose
963
984
  headers.delete(CALLER_HEADER);
985
+ // the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
986
+ // credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
987
+ if (via === 'session') {
988
+ headers.delete(ORIGINAL_PATH_HEADER);
989
+ headers.delete('x-volter-twin-original-host');
990
+ }
964
991
  const who = this.callerOf(request);
965
992
  if (who)
966
993
  headers.set(CALLER_HEADER, callerForHeader(who));
@@ -1123,6 +1150,35 @@ export class WorldDoors {
1123
1150
  // ending a browser's session needs no token: it only clears the caller's own cookie, which never
1124
1151
  // reaches these /-/ paths, so a page holding just the session could not otherwise sign out
1125
1152
  const sessionPath = path.replace(/\/+$/, '');
1153
+ // THE HAND-OFF: a page of no UI that opens this World's session from the token in its fragment, then goes to the page
1154
+ // of this World it names (`land`): how a page elsewhere links to this World's own pages (the console's comparison
1155
+ // opening a vendor's page in a copy of the World, read only). The token never reaches the server in a URL; its script
1156
+ // is a file of this origin, so the page keeps a strict policy. It is never framed: a session opened in another
1157
+ // site's frame is not this World's own page's
1158
+ if (sessionPath === 'session/hand-off' && request.method === 'GET') {
1159
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="/-/${this.served}/session/hand-off.js"></script></body>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
1160
+ }
1161
+ if (sessionPath === 'session/hand-off.js' && request.method === 'GET') {
1162
+ const script = `(() => {
1163
+ const world = document.body.dataset.world;
1164
+ const carried = new URLSearchParams(location.hash.slice(1));
1165
+ history.replaceState(null, '', location.pathname);
1166
+ const token = carried.get('token') || '';
1167
+ const land = carried.get('land') || '';
1168
+ // resolved as the browser will (encoded dots too), on this origin and under this World
1169
+ let to = null;
1170
+ try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
1171
+ const open = () => fetch('/-/' + world + '/session', { method: 'POST', headers: { 'x-volter-token': token } });
1172
+ if (!token || !to) { document.body.textContent = 'Nothing to open.'; return; }
1173
+ // a session this browser already holds here is kept: a page elsewhere cannot replace, downgrade or fix it
1174
+ fetch('/-/' + world + '/session')
1175
+ .then((held) => (held.ok ? held : open()))
1176
+ .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
1177
+ .catch(() => { document.body.textContent = 'This World did not answer.'; });
1178
+ })();
1179
+ `;
1180
+ return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
1181
+ }
1126
1182
  // a person signing in from a page (the console's form, posted to this origin): the token in the
1127
1183
  // form body, the session set here, then on to a page of this World
1128
1184
  // a form or beacon from another site never opens or ends a session here (the console, on this host's
@@ -1221,6 +1277,8 @@ export class WorldDoors {
1221
1277
  return Response.json(await this.map());
1222
1278
  if (kind === 'board' && !name && request.method === 'GET')
1223
1279
  return Response.json(await this.board(url));
1280
+ if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET')
1281
+ return await this.boardChanges(url);
1224
1282
  if (kind === 'board' && !name && request.method === 'PUT') {
1225
1283
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
1226
1284
  return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
@@ -1878,9 +1936,39 @@ export class WorldDoors {
1878
1936
  }
1879
1937
  /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
1880
1938
  * 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. */
1939
+ * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
1940
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
1941
+ * one frame at a time (`board/changes`). */
1883
1942
  async board(url) {
1943
+ const { frames, delta } = await this.boardFrames(url);
1944
+ // the changes are counted from the World's base: what was last cut or received, and when
1945
+ 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() };
1946
+ }
1947
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
1948
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
1949
+ async boardChanges(url) {
1950
+ const key = url.searchParams.get('frame') ?? '';
1951
+ if (!key)
1952
+ return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
1953
+ const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1954
+ if (!found)
1955
+ return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
1956
+ const acts = new Map();
1957
+ for (const c of found.mine) {
1958
+ const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
1959
+ if (c.action.occurredAt > act.at)
1960
+ act.at = c.action.occurredAt;
1961
+ act.writes += 1;
1962
+ 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)]] : []; }));
1963
+ if (act.entries.length < 50)
1964
+ act.entries.push({ op: c.action.op, ...(c.action.operation ? { operation: c.action.operation } : {}), subject: c.action.subject, fields: named });
1965
+ acts.set(actOf(c), act);
1966
+ }
1967
+ const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
1968
+ return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
1969
+ }
1970
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1971
+ async boardFrames(url) {
1884
1972
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1885
1973
  const routed = this.host.sitesOrigin?.() ?? null;
1886
1974
  const sites = routed ? new URL(routed) : null;
@@ -1898,12 +1986,13 @@ export class WorldDoors {
1898
1986
  catch {
1899
1987
  listed = [];
1900
1988
  }
1901
- // a pack that lays out no board yet keeps its vendor's home on it: its root workspace, else its first unparameterized one
1989
+ // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
1990
+ // one), whose changes are all of the vendor's
1902
1991
  if (!listed.length) {
1903
1992
  const screens = await this.screensOf(vendor);
1904
1993
  const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
1905
1994
  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 });
1995
+ 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) });
1907
1996
  }
1908
1997
  for (const f of listed) {
1909
1998
  let at = null;
@@ -1923,12 +2012,10 @@ export class WorldDoors {
1923
2012
  unseen = 'not-drawn';
1924
2013
  }
1925
2014
  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 });
2015
+ 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}`)) });
1929
2016
  }
1930
2017
  }
1931
- return { world: this.served, frames, ...this.savedBoards() };
2018
+ return { delta, frames };
1932
2019
  }
1933
2020
  /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
1934
2021
  savedBoards() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.10",
3
+ "version": "3.0.12",
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.10",
72
- "@volter/world-console": "3.0.5"
71
+ "@volter/world-core": "3.0.12",
72
+ "@volter/world-console": "3.0.7"
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.10",
78
+ "@volter/world-core": "3.0.12",
79
79
  "pg-gateway": "0.3.0-beta.4",
80
80
  "smol-toml": "^1.8.0",
81
81
  "yaml": "2.9.0"
@@ -802,11 +802,15 @@ export async function main(): Promise<void> {
802
802
  caFile = join(mkdtempSync(join(tmpdir(), 'volter-attach-')), 'ca.pem');
803
803
  writeFileSync(caFile, manifest.ca);
804
804
  }
805
+ // the World's twins, each a loopback listener here that adds its key, so a command with its own HTTP stack reaches
806
+ // them as it would a local World's (the manifest pointed at the listeners before its env is made)
807
+ const { bridgeTwins } = createRequire(import.meta.url)('@volter/world-core/http-bridge') as { bridgeTwins: (manifest: WorldManifest, key: string, ca?: string) => Promise<unknown[]> };
808
+ await bridgeTwins(manifest, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '', manifest.ca ?? undefined);
805
809
  const injectPath = createRequire(import.meta.url).resolve('@volter/world-core/inject');
806
810
  const env = remoteAttachEnv(manifest, { injectPath, ...(caFile === undefined ? {} : { caFile }) });
807
811
  // the world's byte-stream doors, bridged to loopback listeners here, beside a command in any language
808
812
  const { bridgeStreams, drainStreams } = createRequire(import.meta.url)('@volter/world-core/stream-bridge') as { bridgeStreams: (streams: WorldManifest['streams'], token: string) => Promise<{ env: Record<string, string> }>; drainStreams: (ms?: number) => Promise<void> };
809
- const bridged = manifest.streams ? await bridgeStreams(manifest.streams, attachToken ?? manifest.env.VOLTER_TWINS_KEY ?? '') : { env: {} };
813
+ const bridged = manifest.streams ? await bridgeStreams(manifest.streams, manifest.env.VOLTER_TWINS_KEY ?? attachToken ?? '') : { env: {} };
810
814
  // VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
811
815
  const commandEnv: Record<string, string | undefined> = withoutVaultSignIn({ ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref });
812
816
  commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
@@ -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, type BoardFrame } 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, ORIGINAL_PATH_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';
@@ -84,19 +84,21 @@ export function keysFile(worldRoot: string): string { return join(worldRoot, sta
84
84
  export function boardFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'board.json'); }
85
85
  /** A board as the World keeps it: a frame or a section is named `<vendor>:<frame id>` or `<vendor>:section:<section id>`.
86
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 }> };
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
88
  const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
89
89
  /** The largest board document a World keeps. */
90
90
  const BOARD_BYTES = 256 * 1024;
91
91
  /** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
92
92
  /** A kept board document's version: its bytes' digest, or `none` before one is kept. */
93
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);
94
96
  function boardsOf(body: unknown): SavedBoard[] | string {
95
97
  const list = (body as { boards?: unknown } | null)?.boards;
96
98
  if (!Array.isArray(list) || list.length < 1 || list.length > 50) return 'boards: a list of 1-50 boards';
97
99
  const ids = new Set<string>();
98
100
  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';
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';
100
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 -';
101
103
  ids.add(b.id);
102
104
  if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) return 'a board name is 1-80 characters';
@@ -106,6 +108,18 @@ function boardsOf(body: unknown): SavedBoard[] | string {
106
108
  if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000) return 'placed: a frame name to its place';
107
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';
108
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
+ }
109
123
  }
110
124
  if ((list[0] as SavedBoard).include !== undefined) return 'the first board is everything: it includes no list';
111
125
  return list as SavedBoard[];
@@ -740,6 +754,11 @@ export class WorldDoors {
740
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 */ } }
741
755
  return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
742
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
+ }
743
762
  return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
744
763
  }
745
764
 
@@ -850,6 +869,9 @@ export class WorldDoors {
850
869
  // the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
851
870
  // request's log entries carry, so each entry is joined to its caller by an id no caller chose
852
871
  headers.delete(CALLER_HEADER);
872
+ // the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
873
+ // credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
874
+ if (via === 'session') { headers.delete(ORIGINAL_PATH_HEADER); headers.delete('x-volter-twin-original-host'); }
853
875
  const who = this.callerOf(request); if (who) headers.set(CALLER_HEADER, callerForHeader(who));
854
876
  headers.set('traceparent', traceparentForDelivery(headers.get('traceparent')) ?? newTraceparent());
855
877
  // at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
@@ -978,6 +1000,36 @@ export class WorldDoors {
978
1000
  // ending a browser's session needs no token: it only clears the caller's own cookie, which never
979
1001
  // reaches these /-/ paths, so a page holding just the session could not otherwise sign out
980
1002
  const sessionPath = path.replace(/\/+$/, '');
1003
+ // THE HAND-OFF: a page of no UI that opens this World's session from the token in its fragment, then goes to the page
1004
+ // of this World it names (`land`): how a page elsewhere links to this World's own pages (the console's comparison
1005
+ // opening a vendor's page in a copy of the World, read only). The token never reaches the server in a URL; its script
1006
+ // is a file of this origin, so the page keeps a strict policy. It is never framed: a session opened in another
1007
+ // site's frame is not this World's own page's
1008
+ if (sessionPath === 'session/hand-off' && request.method === 'GET') {
1009
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="/-/${this.served}/session/hand-off.js"></script></body>`,
1010
+ { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
1011
+ }
1012
+ if (sessionPath === 'session/hand-off.js' && request.method === 'GET') {
1013
+ const script = `(() => {
1014
+ const world = document.body.dataset.world;
1015
+ const carried = new URLSearchParams(location.hash.slice(1));
1016
+ history.replaceState(null, '', location.pathname);
1017
+ const token = carried.get('token') || '';
1018
+ const land = carried.get('land') || '';
1019
+ // resolved as the browser will (encoded dots too), on this origin and under this World
1020
+ let to = null;
1021
+ try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
1022
+ const open = () => fetch('/-/' + world + '/session', { method: 'POST', headers: { 'x-volter-token': token } });
1023
+ if (!token || !to) { document.body.textContent = 'Nothing to open.'; return; }
1024
+ // a session this browser already holds here is kept: a page elsewhere cannot replace, downgrade or fix it
1025
+ fetch('/-/' + world + '/session')
1026
+ .then((held) => (held.ok ? held : open()))
1027
+ .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
1028
+ .catch(() => { document.body.textContent = 'This World did not answer.'; });
1029
+ })();
1030
+ `;
1031
+ return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
1032
+ }
981
1033
  // a person signing in from a page (the console's form, posted to this origin): the token in the
982
1034
  // form body, the session set here, then on to a page of this World
983
1035
  // a form or beacon from another site never opens or ends a session here (the console, on this host's
@@ -1052,6 +1104,7 @@ export class WorldDoors {
1052
1104
  }
1053
1105
  if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
1054
1106
  if (kind === 'board' && !name && request.method === 'GET') return Response.json(await this.board(url));
1107
+ if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET') return await this.boardChanges(url);
1055
1108
  if (kind === 'board' && !name && request.method === 'PUT') {
1056
1109
  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
1110
  if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES) return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
@@ -1565,16 +1618,42 @@ export class WorldDoors {
1565
1618
 
1566
1619
  /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
1567
1620
  * 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. */
1621
+ * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
1622
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
1623
+ * one frame at a time (`board/changes`). */
1570
1624
  private async board(url: URL): Promise<unknown> {
1625
+ const { frames, delta } = await this.boardFrames(url);
1626
+ // the changes are counted from the World's base: what was last cut or received, and when
1627
+ 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() };
1628
+ }
1629
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
1630
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
1631
+ private async boardChanges(url: URL): Promise<Response> {
1632
+ const key = url.searchParams.get('frame') ?? '';
1633
+ if (!key) return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
1634
+ const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1635
+ if (!found) return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
1636
+ const acts = new Map<string, { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> }>();
1637
+ for (const c of found.mine) {
1638
+ const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
1639
+ if (c.action.occurredAt > act.at) act.at = c.action.occurredAt;
1640
+ act.writes += 1;
1641
+ 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)]] : []; }));
1642
+ 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 });
1643
+ acts.set(actOf(c), act);
1644
+ }
1645
+ const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
1646
+ return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
1647
+ }
1648
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1649
+ 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]> }> }> {
1571
1650
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1572
1651
  const routed = this.host.sitesOrigin?.() ?? null;
1573
1652
  const sites = routed ? new URL(routed) : null;
1574
1653
  const delta = diffWorld(this.name, { root: this.worldRoot });
1575
1654
  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
1655
  const changes = delta.actions.filter((a) => !received.has(a.action.id));
1577
- const frames: unknown[] = [];
1656
+ const frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: typeof changes }> = [];
1578
1657
  for (const vendor of Object.keys(this.up().services)) {
1579
1658
  const twin = this.host.twinFetch(vendor);
1580
1659
  let listed: BoardFrame[] = [];
@@ -1582,11 +1661,12 @@ export class WorldDoors {
1582
1661
  const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
1583
1662
  listed = answer?.ok ? (((await answer.json()) as { frames?: BoardFrame[] }).frames ?? []) : [];
1584
1663
  } 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
1664
+ // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
1665
+ // one), whose changes are all of the vendor's
1586
1666
  if (!listed.length) {
1587
1667
  const screens = await this.screensOf(vendor);
1588
1668
  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 });
1669
+ 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) });
1590
1670
  }
1591
1671
  for (const f of listed) {
1592
1672
  let at: { site: string } | { screen: string } | null = null;
@@ -1599,12 +1679,10 @@ export class WorldDoors {
1599
1679
  if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
1600
1680
  } catch { at = null; unseen = 'not-drawn'; }
1601
1681
  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 });
1682
+ 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}`)) });
1605
1683
  }
1606
1684
  }
1607
- return { world: this.served, frames, ...this.savedBoards() };
1685
+ return { delta, frames };
1608
1686
  }
1609
1687
  /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
1610
1688
  private savedBoards(): { boards: SavedBoard[]; version: string } {