@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.
- package/dist/src/cli-commands.js +5 -1
- package/dist/src/served-world.d.ts +8 -2
- package/dist/src/served-world.js +98 -11
- package/package.json +4 -4
- package/src/cli-commands.ts +5 -1
- package/src/served-world.ts +90 -12
package/dist/src/cli-commands.js
CHANGED
|
@@ -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,
|
|
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
|
|
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. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, 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
|
|
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
|
|
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
|
|
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}` },
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
72
|
-
"@volter/world-console": "3.0.
|
|
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.
|
|
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"
|
package/src/cli-commands.ts
CHANGED
|
@@ -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,
|
|
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';
|
package/src/served-world.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
29
|
-
import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, 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
|
|
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
|
|
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
|
|
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}` },
|
|
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
|
-
|
|
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 {
|
|
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 } {
|