@volter/world-runtime 3.0.30 → 3.0.32
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/branch.js +3 -0
- package/dist/src/browsers.d.ts +29 -0
- package/dist/src/browsers.js +180 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +36 -4
- package/dist/src/local-branches.js +5 -1
- package/dist/src/pack-facts.d.ts +4 -0
- package/dist/src/pack-facts.js +14 -0
- package/dist/src/placeholder.js +16 -2
- package/dist/src/redirect-proxy.js +3 -3
- package/dist/src/reflect.js +1 -2
- package/dist/src/runtime.js +9 -2
- package/dist/src/served-world.d.ts +12 -9
- package/dist/src/served-world.js +406 -189
- package/dist/src/world-origins.js +1 -1
- package/dist/src/world-view.js +5 -1
- package/package.json +4 -4
- package/src/branch.ts +3 -0
- package/src/browsers.ts +146 -0
- package/src/index.ts +2 -0
- package/src/init.ts +36 -4
- package/src/local-branches.ts +5 -1
- package/src/pack-facts.ts +16 -0
- package/src/placeholder.ts +16 -2
- package/src/redirect-proxy.ts +3 -3
- package/src/reflect.ts +1 -2
- package/src/runtime.ts +7 -2
- package/src/served-world.ts +323 -166
- package/src/world-origins.ts +1 -1
- package/src/world-view.ts +5 -1
package/dist/src/served-world.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, readHistoryView, worldPaths } from '@volter/world-core/runtime';
|
|
1
|
+
import { boardFrameKey, getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, readHistoryView, worldPaths } from '@volter/world-core/runtime';
|
|
2
2
|
// A SERVED WORLD (docs/reference/http-api.md#a-served-world): `volter world serve` puts a world at
|
|
3
3
|
// `http://<host>:<port>/<org>/<world>/`. Each twin's vendor API is under its vendor id — the
|
|
4
4
|
// addressed grammar RH2 depends on — and the world's own doors are under `/-/<org>/<world>/`:
|
|
@@ -13,7 +13,6 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
|
|
|
13
13
|
// POST deploy[/<name>] perform landed entries against each root, by policy
|
|
14
14
|
// GET/PUT twins/<vendor>/root the vendor's real account behind the twin (PUT null clears)
|
|
15
15
|
// PUT twins/<vendor>/credential seal the credential; GET answers only that one is sealed
|
|
16
|
-
// GET twins/<vendor>/people, POST twins/<vendor>/session a browser views the vendor as a World's person
|
|
17
16
|
// POST twins/<vendor>/refresh observe the root now
|
|
18
17
|
// and what a person looks into it through (docs/contributing/architecture.md, "Viewing a World"):
|
|
19
18
|
// GET map | timeline | diff every twin and what it holds; the logs merged newest first; the changes since the base
|
|
@@ -31,10 +30,12 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
|
|
|
31
30
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
32
31
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
33
32
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
34
|
-
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,
|
|
35
|
-
import {
|
|
33
|
+
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, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
|
|
34
|
+
import { resolveTwinHost } from '@volter/world-core/host-routing';
|
|
35
|
+
import { assertBrowserName, validateBrowserState, cookieMatchesHost } from '@volter/world-core';
|
|
36
|
+
import { worldBrowser, worldBrowsers, putWorldBrowser, deleteWorldBrowser, materializeBrowserFiles, browserLoad, putBrowserLoad, browserLoadId } from "./browsers.js";
|
|
36
37
|
import { APP_READ_ENDPOINT_ENV } from "./init.js";
|
|
37
|
-
import { packFacts } from "./pack-facts.js";
|
|
38
|
+
import { packFacts, packHostRules } from "./pack-facts.js";
|
|
38
39
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
|
|
39
40
|
import { createHash } from 'node:crypto';
|
|
40
41
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -49,6 +50,17 @@ import { assertChangesetRules } from "./schema.js";
|
|
|
49
50
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
50
51
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
51
52
|
export const TWINS_KEY_HEADER = 'x-twins-key';
|
|
53
|
+
// Browser input policy; these caps bound accepted input, independently of infrastructure limits.
|
|
54
|
+
const BROWSER_COUNT_LIMIT = 32;
|
|
55
|
+
const BROWSER_STATE_BYTES_LIMIT = 1024 * 1024;
|
|
56
|
+
/** This fact belongs to the World router, never to a caller. */
|
|
57
|
+
function withoutBrowserHeader(request) {
|
|
58
|
+
if (!request.headers.has('x-volter-world-browser'))
|
|
59
|
+
return request;
|
|
60
|
+
const headers = new Headers(request.headers);
|
|
61
|
+
headers.delete('x-volter-world-browser');
|
|
62
|
+
return new Request(request, { headers });
|
|
63
|
+
}
|
|
52
64
|
class Booting extends Error {
|
|
53
65
|
constructor() { super('the world is still booting'); }
|
|
54
66
|
}
|
|
@@ -95,7 +107,7 @@ export function sessionsFile(worldRoot) { return join(worldRoot, stateDirName(),
|
|
|
95
107
|
export function keysFile(worldRoot) { return join(worldRoot, stateDirName(), 'keys.json'); }
|
|
96
108
|
/** Where a World keeps its boards as its people arranged them (what each hides or includes, and its frames' places): `.volter/board.json`. */
|
|
97
109
|
export function boardFile(worldRoot) { return join(worldRoot, stateDirName(), 'board.json'); }
|
|
98
|
-
const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
|
|
110
|
+
const BOARD_KEY = /^(?:[a-z0-9-]+|~browser:[a-z0-9-]+:[a-z0-9-]+):[^\s]{1,300}$/;
|
|
99
111
|
/** The largest board document a World keeps. */
|
|
100
112
|
const BOARD_BYTES = 256 * 1024;
|
|
101
113
|
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
@@ -134,7 +146,7 @@ function boardsOf(body) {
|
|
|
134
146
|
for (const [k, v] of Object.entries(sections)) {
|
|
135
147
|
const r = v;
|
|
136
148
|
// a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
|
|
137
|
-
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))
|
|
149
|
+
if (!BOARD_KEY.test(k) || !(/^user:[a-z0-9]{1,32}$/.test(k) || /^(?:[a-z0-9-]+|~browser:[a-z0-9-]+:[a-z0-9-]+):section:/.test(k)) || !r || typeof r !== 'object' || Object.keys(r).some((f) => !['x', 'y', 'w', 'h', 'name', 'gone'].includes(f))
|
|
138
150
|
|| !['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)
|
|
139
151
|
|| (r.gone !== true && (r.w < 100 || r.h < 100))
|
|
140
152
|
|| (r.name !== undefined && (typeof r.name !== 'string' || !r.name.trim() || r.name.length > 80)) || (r.gone !== undefined && r.gone !== true))
|
|
@@ -486,7 +498,7 @@ export class WorldDoors {
|
|
|
486
498
|
return pass ? `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}` : null;
|
|
487
499
|
}
|
|
488
500
|
/** A one-time pass (a minute long) for the content plane or a hosted site; null when its asker holds fifty unused. */
|
|
489
|
-
mintPagesPass(scope, grant
|
|
501
|
+
mintPagesPass(scope, grant) {
|
|
490
502
|
const now = Date.now();
|
|
491
503
|
for (const [k, v] of this.pagePasses)
|
|
492
504
|
if (v.until <= now)
|
|
@@ -496,7 +508,7 @@ export class WorldDoors {
|
|
|
496
508
|
if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000)
|
|
497
509
|
return null;
|
|
498
510
|
const pass = mintToken('pass_');
|
|
499
|
-
this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000
|
|
511
|
+
this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
|
|
500
512
|
return pass;
|
|
501
513
|
}
|
|
502
514
|
/** The hostname a hosted site address names (`{site}` template: `<label>--<world>--<org>.<suffix>`) among those this
|
|
@@ -504,35 +516,50 @@ export class WorldDoors {
|
|
|
504
516
|
/** Whether an address has a hosted site's shape here (`<label>--<world>--<org>.<suffix>`), claimed or not. */
|
|
505
517
|
siteShaped(url) {
|
|
506
518
|
const template = this.host.sitesTemplate?.() ?? null;
|
|
507
|
-
if (!template
|
|
519
|
+
if (!template)
|
|
520
|
+
return false;
|
|
521
|
+
const flat = template.includes('{site}');
|
|
522
|
+
if (!flat && !template.includes('{host}'))
|
|
523
|
+
return false;
|
|
524
|
+
const after = new URL(template.replace(flat ? '{site}' : '{host}', 'x')).host.slice(1);
|
|
525
|
+
if (!url.host.endsWith(after) || url.host.length <= after.length)
|
|
508
526
|
return false;
|
|
509
|
-
const
|
|
510
|
-
return
|
|
527
|
+
const label = url.host.slice(0, -after.length);
|
|
528
|
+
return flat || (!label.includes('.') && label.includes('--'));
|
|
511
529
|
}
|
|
512
530
|
async hostedSiteOf(url) {
|
|
513
531
|
if (!this.siteShaped(url))
|
|
514
532
|
return null;
|
|
515
|
-
const template = this.host.sitesTemplate
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
533
|
+
const template = this.host.sitesTemplate();
|
|
534
|
+
const claimed = await this.claimedHosts(url.origin);
|
|
535
|
+
for (const { hosts } of claimed)
|
|
536
|
+
for (const site of hosts) {
|
|
537
|
+
const at = siteUrlOf(template, site);
|
|
538
|
+
if (at && new URL(at).host === url.host)
|
|
539
|
+
return { site };
|
|
540
|
+
}
|
|
541
|
+
const browsers = worldBrowsers(this.up().dirs.data);
|
|
542
|
+
if (!browsers.length)
|
|
520
543
|
return null;
|
|
521
|
-
const
|
|
522
|
-
for (const
|
|
523
|
-
for (const
|
|
524
|
-
|
|
525
|
-
|
|
544
|
+
const sites = await this.browserSites(url.origin, claimed);
|
|
545
|
+
for (const browser of browsers) {
|
|
546
|
+
for (const { hosts } of sites)
|
|
547
|
+
for (const site of hosts) {
|
|
548
|
+
const at = siteUrlOf(template, site, browser.name);
|
|
549
|
+
if (at && new URL(at).host === url.host)
|
|
550
|
+
return { site, browser: browser.name };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
526
553
|
return null;
|
|
527
554
|
}
|
|
528
555
|
pagesCookie(url) { return url.protocol === 'https:' ? '__Host-volter_pages' : 'volter_pages'; }
|
|
529
|
-
pageSession(request, url, site) {
|
|
556
|
+
pageSession(request, url, site, browser) {
|
|
530
557
|
const id = new RegExp(`(?:^|;\\s*)${this.pagesCookie(url)}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
531
558
|
const held = id && Object.hasOwn(this.pagesSessions(), id) ? this.pagesSessions()[id] : undefined;
|
|
532
559
|
if (!id || !held || held.until <= Date.now() || !this.pagesParentLive(held))
|
|
533
560
|
return null;
|
|
534
561
|
// a session opens only where its pass was for: the content plane, or that one hosted site
|
|
535
|
-
if ((held.site ?? null) !== (site ?? null))
|
|
562
|
+
if ((held.site ?? null) !== (site ?? null) || held.browser !== browser)
|
|
536
563
|
return null;
|
|
537
564
|
const { until: _until, ...grant } = held;
|
|
538
565
|
return { id, ...grant };
|
|
@@ -545,18 +572,16 @@ export class WorldDoors {
|
|
|
545
572
|
return;
|
|
546
573
|
out.delete('set-cookie');
|
|
547
574
|
for (const c of cookies) {
|
|
548
|
-
if (/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))
|
|
575
|
+
if (/^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c))
|
|
549
576
|
continue;
|
|
550
577
|
out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned|domain=[^;]*)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
|
|
551
578
|
}
|
|
552
579
|
}
|
|
553
580
|
/** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
|
|
554
|
-
async pagesPlane(request, url, /** a hosted site served here, not the content plane */ site) {
|
|
581
|
+
async pagesPlane(request, url, /** a hosted site served here, not the content plane */ site, browser) {
|
|
555
582
|
const doors = `/-/${this.served}/pages/`;
|
|
556
|
-
const
|
|
557
|
-
const
|
|
558
|
-
const door = viewVendor ? viewPath[3] : url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
|
|
559
|
-
const opening = viewVendor ? `/${this.served}/${viewVendor}/_twin/view-as/` : doors;
|
|
583
|
+
const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
|
|
584
|
+
const opening = doors;
|
|
560
585
|
const own = this.host.browserOrigin?.() ?? null;
|
|
561
586
|
if (door === 'open' && request.method === 'GET') {
|
|
562
587
|
// framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
|
|
@@ -571,8 +596,6 @@ export class WorldDoors {
|
|
|
571
596
|
history.replaceState(null, '', location.pathname);
|
|
572
597
|
const pass = carried.get('pass') || '';
|
|
573
598
|
const land = carried.get('land') || '';
|
|
574
|
-
const viewAs = carried.get('viewAs') === '1';
|
|
575
|
-
const tell = (data) => { if (viewAs && parent !== window) parent.postMessage({ type: 'volter-view-as', pass, ...data }, ${JSON.stringify(own)}); };
|
|
576
599
|
// resolved as the browser will (encoded dots too), on this origin and under this World
|
|
577
600
|
let to = null;
|
|
578
601
|
// a hosted site lands anywhere on it; the content plane only under this World
|
|
@@ -583,11 +606,9 @@ export class WorldDoors {
|
|
|
583
606
|
.then(async (r) => {
|
|
584
607
|
const opened = await r.json();
|
|
585
608
|
if (!r.ok) throw new Error(opened.error || 'This World did not open.');
|
|
586
|
-
|
|
587
|
-
else if (viewAs) throw new Error('This pass did not sign in to the vendor.');
|
|
588
|
-
else location.replace(to);
|
|
609
|
+
location.replace(to);
|
|
589
610
|
})
|
|
590
|
-
.catch((e) => { document.body.textContent = e.message || 'This World did not answer.';
|
|
611
|
+
.catch((e) => { document.body.textContent = e.message || 'This World did not answer.'; });
|
|
591
612
|
})();
|
|
592
613
|
`;
|
|
593
614
|
return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
@@ -599,21 +620,11 @@ export class WorldDoors {
|
|
|
599
620
|
this.pagePasses.delete(pass);
|
|
600
621
|
if (request.headers.get('sec-fetch-site') !== 'same-origin')
|
|
601
622
|
return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
|
|
602
|
-
if (!given || given.until <= Date.now() || !this.pagesParentLive(given) || (given.site ?? null) !== (site ?? null))
|
|
623
|
+
if (!given || given.until <= Date.now() || !this.pagesParentLive(given) || (given.site ?? null) !== (site ?? null) || given.browser !== browser)
|
|
603
624
|
return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
|
|
604
|
-
const { until: _until,
|
|
605
|
-
if (viewAs) {
|
|
606
|
-
const held = this.pageSession(request, url);
|
|
607
|
-
if (!held)
|
|
608
|
-
return Response.json({ error: 'Open a vendor page from this World first, then choose View as again.' }, { status: 403 });
|
|
609
|
-
if (viewVendor !== viewAs.vendor || !given.session || held.scope !== 'write' || held.session !== given.session)
|
|
610
|
-
return Response.json({ error: 'View as belongs to the browser session that asked for it' }, { status: 403 });
|
|
611
|
-
return this.vendorSession(request, viewAs, given.until);
|
|
612
|
-
}
|
|
613
|
-
if (viewVendor)
|
|
614
|
-
return Response.json({ error: 'this door takes only a View as pass' }, { status: 403 });
|
|
625
|
+
const { until: _until, ...grant } = given;
|
|
615
626
|
// one content session per asker at a scope and framer: a board's frames opening together share it
|
|
616
|
-
const same = (x) => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who && x.site === grant.site;
|
|
627
|
+
const same = (x) => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who && x.site === grant.site && x.browser === grant.browser;
|
|
617
628
|
const now = Date.now();
|
|
618
629
|
const found = Object.entries(this.pagesSessions()).find(([, x]) => x.until > now && same(x))?.[0];
|
|
619
630
|
const id = found ?? mintToken('pgs_');
|
|
@@ -625,22 +636,28 @@ export class WorldDoors {
|
|
|
625
636
|
}
|
|
626
637
|
// A HOSTED SITE: read with a session its World's own origin gave it a pass for (sites/go), never without
|
|
627
638
|
if (site) {
|
|
628
|
-
const held = this.pageSession(request, url, site);
|
|
629
|
-
const back = own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: site, land: `${url.pathname}${url.search}` }).toString()}` : null;
|
|
639
|
+
const held = this.pageSession(request, url, site, browser);
|
|
640
|
+
const back = own && !browser ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: site, land: `${url.pathname}${url.search}` }).toString()}` : null;
|
|
630
641
|
if (!held) {
|
|
631
|
-
if (request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html') && back) {
|
|
642
|
+
if (request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html') && (browser || back)) {
|
|
632
643
|
const shown = site.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
633
|
-
|
|
644
|
+
const opening = browser ? "Open this site from the World's board." : `<a href="${back.replace(/&/g, '&')}">Open it from the World</a>`;
|
|
645
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p><b>${shown}</b> is a site in the World ${this.served}, shown to its people.</p><p>${opening}</p></body>`, { status: 401, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
634
646
|
}
|
|
635
|
-
return Response.json({ error: `${site} is shown to ${this.served}'s people: open it from the World` }, { status: 401 });
|
|
647
|
+
return Response.json({ error: `${site} is shown to ${this.served}'s people: open it from the World${browser ? "'s board" : ''}` }, { status: 401 });
|
|
636
648
|
}
|
|
637
649
|
// read by the site's own pages, or a person's navigation to it: never by another page of the same domain (the
|
|
638
650
|
// session's partition is the domain's, which every World's pages share)
|
|
639
651
|
const fetchSite = request.headers.get('sec-fetch-site');
|
|
640
652
|
const navigation = ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
641
|
-
if (fetchSite !== 'same-origin' && !(navigation && ['none', 'same-site'].includes(fetchSite ?? '')))
|
|
653
|
+
if (fetchSite !== 'same-origin' && !((!browser || request.method === 'GET' || request.method === 'HEAD') && navigation && ['none', 'same-site'].includes(fetchSite ?? '')))
|
|
642
654
|
return Response.json({ error: `${site} is read by its own pages` }, { status: 403 });
|
|
643
|
-
|
|
655
|
+
if (browser) {
|
|
656
|
+
const loading = this.loadBrowser(request, url, site, browser);
|
|
657
|
+
if (loading)
|
|
658
|
+
return loading;
|
|
659
|
+
}
|
|
660
|
+
const answer = await this.site(request, site, browser, held);
|
|
644
661
|
const out = new Headers(answer.headers);
|
|
645
662
|
out.append('content-security-policy', `frame-ancestors 'self'${held.framer ? ` ${held.framer}` : ''}`);
|
|
646
663
|
this.keepCookiesHere(answer, out);
|
|
@@ -665,76 +682,6 @@ export class WorldDoors {
|
|
|
665
682
|
}
|
|
666
683
|
return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
|
|
667
684
|
}
|
|
668
|
-
/** A landing resolved as a browser resolves it, within this vendor's place only. */
|
|
669
|
-
vendorLanding(vendor, land) {
|
|
670
|
-
if (typeof land !== 'string' || !land.startsWith('/') || land.startsWith('//') || /[\\\s]/.test(land))
|
|
671
|
-
return null;
|
|
672
|
-
try {
|
|
673
|
-
const url = new URL(land, 'https://world.local');
|
|
674
|
-
const path = decodeURIComponent(url.pathname);
|
|
675
|
-
const base = `/${this.served}/${vendor}/`;
|
|
676
|
-
if (url.origin !== 'https://world.local' || !url.pathname.startsWith(base) || !path.startsWith(base)
|
|
677
|
-
|| path.includes('\\') || path.split('/').some((p) => p === '.' || p === '..' || p.includes('%')))
|
|
678
|
-
return null;
|
|
679
|
-
return url.pathname + url.search + url.hash;
|
|
680
|
-
}
|
|
681
|
-
catch {
|
|
682
|
-
return null;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
/** The pack's declared browser session, never inferred from its vendor or a sign-in page. */
|
|
686
|
-
async browserSessionOf(vendor) {
|
|
687
|
-
const twin = this.host.twinFetch(vendor);
|
|
688
|
-
if (!twin || !this.host.pagesOrigin?.())
|
|
689
|
-
return null;
|
|
690
|
-
try {
|
|
691
|
-
const answer = await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } }));
|
|
692
|
-
const manifest = answer.ok ? await answer.json() : null;
|
|
693
|
-
if (!manifest?.drawsScreens)
|
|
694
|
-
return null;
|
|
695
|
-
const session = manifest?.browserSession;
|
|
696
|
-
if (!session || typeof session !== 'object')
|
|
697
|
-
return null;
|
|
698
|
-
const { cookie, scope } = session;
|
|
699
|
-
if (typeof cookie !== 'string' || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(cookie)
|
|
700
|
-
|| /^(?:__Host-|(?:__Secure-)?volter_(?:world|pages)$)/i.test(cookie) || (scope !== undefined && typeof scope !== 'string'))
|
|
701
|
-
return null;
|
|
702
|
-
return { cookie, ...(typeof scope === 'string' ? { scope } : {}) };
|
|
703
|
-
}
|
|
704
|
-
catch {
|
|
705
|
-
return null;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
vendorPeople(vendor) {
|
|
709
|
-
return readTree(this.stateOf(vendor), this.controlRoot(vendor))
|
|
710
|
-
.filter((p) => p.type === '_person' && p.deleted !== true && typeof p.email === 'string')
|
|
711
|
-
.map((p) => ({ id: p.id, email: String(p.email), label: String(p.email) }));
|
|
712
|
-
}
|
|
713
|
-
/** The twin receives its own cookie and changes the session in its own process, over a host-only permit. */
|
|
714
|
-
async vendorSession(request, pass, until) {
|
|
715
|
-
const twin = this.host.twinFetch(pass.vendor);
|
|
716
|
-
if (!twin || !await this.browserSessionOf(pass.vendor) || !this.vendorLanding(pass.vendor, pass.land))
|
|
717
|
-
return Response.json({ error: 'that vendor session is unavailable' }, { status: 404 });
|
|
718
|
-
const person = pass.person === null ? null : this.vendorPeople(pass.vendor).find((p) => p.id === pass.person);
|
|
719
|
-
if (person === undefined)
|
|
720
|
-
return Response.json({ error: 'that person is no longer in the World' }, { status: 404 });
|
|
721
|
-
const url = new URL(request.url);
|
|
722
|
-
const headers = new Headers({ [TWIN_PREFIX_HEADER]: `/${this.served}/${pass.vendor}`, 'x-forwarded-host': url.host, 'x-forwarded-proto': url.protocol.slice(0, -1) });
|
|
723
|
-
headers.set(CALLER_HEADER, callerForHeader(pass.asker));
|
|
724
|
-
headers.set('traceparent', traceparentForDelivery(request.headers.get('traceparent')) ?? newTraceparent());
|
|
725
|
-
const cookies = (request.headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
|
|
726
|
-
if (cookies.length)
|
|
727
|
-
headers.set('cookie', cookies.join('; '));
|
|
728
|
-
const answer = await withBrowserSessionPermit(this.stateOf(pass.vendor), this.controlRoot(pass.vendor), { person: pass.person, land: pass.land, until }, async (permit) => {
|
|
729
|
-
headers.set(BROWSER_SESSION_HEADER, permit);
|
|
730
|
-
return twin(new Request(`${url.origin}${BROWSER_SESSION_PATH}`, { method: 'POST', headers, redirect: 'manual' }));
|
|
731
|
-
});
|
|
732
|
-
if (answer.status !== 303)
|
|
733
|
-
return answer;
|
|
734
|
-
const out = new Headers({ 'cache-control': 'no-store' });
|
|
735
|
-
this.keepCookiesHere(answer, out);
|
|
736
|
-
return Response.json({ viewAs: { vendor: pass.vendor, person: pass.person, label: person?.email ?? 'Signed out', land: pass.land } }, { headers: out });
|
|
737
|
-
}
|
|
738
685
|
/** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
|
|
739
686
|
* not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
|
|
740
687
|
* keeps them; each lasts 30 days from its opening. */
|
|
@@ -1140,6 +1087,78 @@ export class WorldDoors {
|
|
|
1140
1087
|
}
|
|
1141
1088
|
return out;
|
|
1142
1089
|
}
|
|
1090
|
+
/** Browser sites include the pack's exact declared hosts, beside the dynamically claimed sites. */
|
|
1091
|
+
async browserSites(origin, listed) {
|
|
1092
|
+
const dynamic = listed ?? await this.claimedHosts(origin);
|
|
1093
|
+
return Object.keys(this.up().services).map(vendor => ({ vendor, hosts: [...new Set([
|
|
1094
|
+
...(dynamic.find(d => d.vendor === vendor)?.hosts ?? []),
|
|
1095
|
+
...(packFacts()[vendor]?.hosts ?? []).flatMap(rule => typeof rule.host === 'string' && !rule.exclude ? [rule.host] : []),
|
|
1096
|
+
])] }));
|
|
1097
|
+
}
|
|
1098
|
+
async browserRoutes(origin) {
|
|
1099
|
+
const listed = await this.claimedHosts(origin);
|
|
1100
|
+
const facts = packFacts();
|
|
1101
|
+
const map = Object.fromEntries(Object.keys(this.up().services).map(vendor => [vendor, vendor]));
|
|
1102
|
+
const claimed = {};
|
|
1103
|
+
// The proxy's static rule order is in the shared table; dynamic order is the same facts order.
|
|
1104
|
+
for (const vendor of Object.keys(facts))
|
|
1105
|
+
if (this.up().services[vendor]) {
|
|
1106
|
+
map[vendor] = vendor;
|
|
1107
|
+
for (const rule of facts[vendor]?.hosts ?? [])
|
|
1108
|
+
if (rule.key)
|
|
1109
|
+
map[rule.key] = vendor;
|
|
1110
|
+
if (facts[vendor]?.hostsClaimed)
|
|
1111
|
+
claimed[vendor] = new Set(listed.find(row => row.vendor === vendor)?.hosts ?? []);
|
|
1112
|
+
}
|
|
1113
|
+
return { sites: await this.browserSites(origin, listed), rules: packHostRules(), map, claimed };
|
|
1114
|
+
}
|
|
1115
|
+
loadBrowser(request, url, site, name) {
|
|
1116
|
+
const browser = worldBrowser(this.up().dirs.data, name);
|
|
1117
|
+
if (!browser)
|
|
1118
|
+
return Response.json({ error: `no browser ${name}` }, { status: 404 });
|
|
1119
|
+
const marker = url.protocol === 'https:' ? '__Host-volter_browser' : 'volter_browser';
|
|
1120
|
+
const id = new RegExp(`(?:^|;\\s*)${marker}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
1121
|
+
const loadId = browserLoadId(browser.version, site);
|
|
1122
|
+
const current = browserLoad(this.up().dirs.data, loadId);
|
|
1123
|
+
const previous = browserLoad(this.up().dirs.data, id);
|
|
1124
|
+
const carried = [current, previous].filter(load => load?.name === name && load.site === site);
|
|
1125
|
+
if (id === loadId && current?.name === name && current.site === site && current.version === browser.version)
|
|
1126
|
+
return null;
|
|
1127
|
+
if (request.method !== 'GET' || !['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? ''))
|
|
1128
|
+
return null;
|
|
1129
|
+
const cookies = browser.state.cookies.filter(c => cookieMatchesHost(c, site));
|
|
1130
|
+
const storage = browser.state.origins.find(o => new URL(o.origin).hostname === site)?.localStorage ?? [];
|
|
1131
|
+
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
|
1132
|
+
const protectedCookie = (key) => /^(?:__Host-)?volter_(?:world|pages|browser)$/.test(key);
|
|
1133
|
+
const expired = new Map();
|
|
1134
|
+
for (const c of [...carried.flatMap(load => load.cookies), ...cookies])
|
|
1135
|
+
expired.set(JSON.stringify([c.name, c.path]), { name: c.name, path: c.path });
|
|
1136
|
+
for (const part of (request.headers.get('cookie') ?? '').split(/;\s*/)) {
|
|
1137
|
+
const key = part.split('=')[0];
|
|
1138
|
+
if (/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key))
|
|
1139
|
+
expired.set(JSON.stringify([key, '/']), { name: key, path: '/' });
|
|
1140
|
+
}
|
|
1141
|
+
for (const c of expired.values()) {
|
|
1142
|
+
if (protectedCookie(c.name))
|
|
1143
|
+
continue;
|
|
1144
|
+
for (const partition of ['', '; Partitioned'])
|
|
1145
|
+
headers.append('set-cookie', `${c.name}=; Path=${c.path}; Max-Age=0; Secure; SameSite=None${partition}`);
|
|
1146
|
+
}
|
|
1147
|
+
for (const c of cookies) {
|
|
1148
|
+
if (/^(?:__Host-)?volter_(?:world|pages|browser)$/.test(c.name))
|
|
1149
|
+
continue;
|
|
1150
|
+
if (c.expires !== -1 && c.expires <= Date.parse(worldNow()) / 1000)
|
|
1151
|
+
continue;
|
|
1152
|
+
headers.append('set-cookie', `${c.name}=${c.value}; Path=${c.path}${c.expires === -1 ? '' : `; Expires=${new Date(c.expires * 1000).toUTCString()}`}${c.httpOnly ? '; HttpOnly' : ''}; Secure; SameSite=None; Partitioned`);
|
|
1153
|
+
}
|
|
1154
|
+
putBrowserLoad(this.up().dirs.data, loadId, { name, site, version: browser.version, cookies: cookies.filter(c => !protectedCookie(c.name)).map(c => ({ name: c.name, path: c.path })) });
|
|
1155
|
+
headers.append('set-cookie', `${marker}=${loadId}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned`);
|
|
1156
|
+
const script = `localStorage.clear(); for (const entry of ${JSON.stringify(storage).replace(/</g, '\\u003c')}) localStorage.setItem(entry.name, entry.value); location.replace(location.href);`;
|
|
1157
|
+
const hash = createHash('sha256').update(script).digest('base64');
|
|
1158
|
+
const framer = this.pageSession(request, url, site, name)?.framer;
|
|
1159
|
+
headers.set('content-security-policy', `default-src 'none'; script-src 'sha256-${hash}'; base-uri 'none'; frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
|
|
1160
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>Loading browser</title><script>${script}</script>`, { headers });
|
|
1161
|
+
}
|
|
1143
1162
|
/** What a twin asks an application (`ctx.ask`) that this World can answer truthfully, where no process routes it (a
|
|
1144
1163
|
* hosted World's isolate): a GET or HEAD of a URL whose hostname one of this World's twins answers (its hostsClaimed
|
|
1145
1164
|
* door: a Cloudflare twin's Worker route or custom domain) is that twin's answer, read as a browser reads the site
|
|
@@ -1170,24 +1189,59 @@ export class WorldDoors {
|
|
|
1170
1189
|
/** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
|
|
1171
1190
|
* the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
|
|
1172
1191
|
* Reads only: the site is the twin's state as the World holds it, never a way to write it. */
|
|
1173
|
-
async site(request, host) {
|
|
1174
|
-
request =
|
|
1175
|
-
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
1192
|
+
async site(request, host, browser, grant) {
|
|
1193
|
+
request = withoutBrowserHeader(request);
|
|
1194
|
+
if (!browser && request.method !== 'GET' && request.method !== 'HEAD')
|
|
1176
1195
|
return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
|
|
1196
|
+
if (browser && !grant)
|
|
1197
|
+
return Response.json({ error: "open this browser site from the World's board" }, { status: 401 });
|
|
1177
1198
|
const url = new URL(request.url);
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1199
|
+
const routes = browser ? await this.browserRoutes(url.origin) : null;
|
|
1200
|
+
let vendor;
|
|
1201
|
+
if (routes)
|
|
1202
|
+
vendor = resolveTwinHost(routes.rules, host, routes.map, url.pathname, routes.claimed)?.origin;
|
|
1203
|
+
else
|
|
1204
|
+
for (const row of await this.claimedHosts(url.origin)) {
|
|
1205
|
+
if (!this.host.twinFetch(row.vendor) || !row.hosts.includes(host))
|
|
1206
|
+
continue;
|
|
1207
|
+
vendor = row.vendor;
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
const twin = vendor ? this.host.twinFetch(vendor) : undefined;
|
|
1211
|
+
if (vendor && twin) {
|
|
1212
|
+
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
1213
|
+
const enforced = browser && grant?.scope === 'read' && await this.enforcesRead(vendor, twin, url.origin);
|
|
1214
|
+
if (browser && grant?.scope === 'read' && !reads && !enforced)
|
|
1215
|
+
return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1216
|
+
const headers = browser ? new Headers(request.headers) : new Headers();
|
|
1217
|
+
for (const key of [TOKEN_HEADER, TWINS_KEY_HEADER, CALLER_HEADER, ORIGINAL_PATH_HEADER, READ_ONLY_HEADER, 'host', TWIN_PREFIX_HEADER])
|
|
1218
|
+
headers.delete(key);
|
|
1183
1219
|
for (const name of ['accept', 'accept-language', 'if-none-match', 'range', 'user-agent']) {
|
|
1184
1220
|
const v = request.headers.get(name);
|
|
1185
1221
|
if (v !== null)
|
|
1186
1222
|
headers.set(name, v);
|
|
1187
1223
|
}
|
|
1224
|
+
if (browser) {
|
|
1225
|
+
const cookies = (request.headers.get('cookie') ?? '').split(/;\s*/).filter(c => !/^(?:__Host-)?volter_(?:world|pages|browser)=/.test(c));
|
|
1226
|
+
if (cookies.length)
|
|
1227
|
+
headers.set('cookie', cookies.join('; '));
|
|
1228
|
+
headers.set('x-volter-world-browser', browser);
|
|
1229
|
+
const template = this.host.sitesTemplate?.();
|
|
1230
|
+
if (template)
|
|
1231
|
+
headers.set(TWIN_SITES_HEADER, template);
|
|
1232
|
+
headers.set('x-forwarded-host', url.host);
|
|
1233
|
+
headers.set('x-forwarded-proto', url.protocol.slice(0, -1));
|
|
1234
|
+
}
|
|
1188
1235
|
headers.set('x-volter-twin-original-host', host);
|
|
1189
|
-
|
|
1190
|
-
|
|
1236
|
+
if (!browser || enforced)
|
|
1237
|
+
headers.set(READ_ONLY_HEADER, '1');
|
|
1238
|
+
if (browser) {
|
|
1239
|
+
if (grant?.who)
|
|
1240
|
+
headers.set(CALLER_HEADER, callerForHeader(grant.who));
|
|
1241
|
+
headers.set('traceparent', traceparentForDelivery(request.headers.get('traceparent')) ?? newTraceparent());
|
|
1242
|
+
}
|
|
1243
|
+
const body = reads ? undefined : await request.arrayBuffer();
|
|
1244
|
+
const answer = await twin(new Request(`${url.origin}${url.pathname}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
1191
1245
|
// a redirect the site answers names its own hostname: in the World it stays under the World's origin
|
|
1192
1246
|
const out = new Headers(answer.headers);
|
|
1193
1247
|
const location = out.get('location');
|
|
@@ -1196,6 +1250,12 @@ export class WorldDoors {
|
|
|
1196
1250
|
const to = new URL(location, `https://${host}`);
|
|
1197
1251
|
if (to.hostname === host)
|
|
1198
1252
|
out.set('location', `${to.pathname}${to.search}${to.hash}`);
|
|
1253
|
+
else if (browser && routes && resolveTwinHost(routes.rules, to.hostname, routes.map, to.pathname, routes.claimed) && routes.sites.some(row => row.hosts.includes(to.hostname))) {
|
|
1254
|
+
const at = this.host.sitesTemplate?.();
|
|
1255
|
+
const next = at ? siteUrlOf(at, to.hostname, browser) : null;
|
|
1256
|
+
if (next)
|
|
1257
|
+
out.set('location', `${next}${to.pathname}${to.search}${to.hash}`);
|
|
1258
|
+
}
|
|
1199
1259
|
}
|
|
1200
1260
|
catch { /* left as answered */ }
|
|
1201
1261
|
}
|
|
@@ -1209,7 +1269,7 @@ export class WorldDoors {
|
|
|
1209
1269
|
return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
|
|
1210
1270
|
}
|
|
1211
1271
|
async handle(request) {
|
|
1212
|
-
request =
|
|
1272
|
+
request = withoutBrowserHeader(request);
|
|
1213
1273
|
try {
|
|
1214
1274
|
if (!this.host.ready() && !new URL(request.url).pathname.startsWith('/-/ping'))
|
|
1215
1275
|
return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
|
|
@@ -1221,7 +1281,7 @@ export class WorldDoors {
|
|
|
1221
1281
|
return await this.pagesPlane(request, url);
|
|
1222
1282
|
const hostedSite = await this.hostedSiteOf(url);
|
|
1223
1283
|
if (hostedSite)
|
|
1224
|
-
return await this.pagesPlane(request, url, hostedSite);
|
|
1284
|
+
return await this.pagesPlane(request, url, hostedSite.site, hostedSite.browser);
|
|
1225
1285
|
if (this.siteShaped(url))
|
|
1226
1286
|
return Response.json({ error: `no site of ${this.served} at ${url.host}` }, { status: 404 });
|
|
1227
1287
|
if (url.pathname.startsWith(`/-/${this.served}/`))
|
|
@@ -1275,7 +1335,7 @@ export class WorldDoors {
|
|
|
1275
1335
|
const answer = twin ? await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1276
1336
|
const listed = answer?.ok ? (await answer.json()).screens : undefined;
|
|
1277
1337
|
if (Array.isArray(listed))
|
|
1278
|
-
screens = listed.filter((x) => typeof x?.id === 'string' && typeof x?.path === 'string').map(({ id, path }) => ({ id, path }));
|
|
1338
|
+
screens = listed.filter((x) => typeof x?.id === 'string' && typeof x?.path === 'string').map(({ id, path, host }) => ({ id, path, ...(typeof host === 'string' ? { host } : {}) }));
|
|
1279
1339
|
}
|
|
1280
1340
|
catch {
|
|
1281
1341
|
screens = [];
|
|
@@ -1307,6 +1367,7 @@ export class WorldDoors {
|
|
|
1307
1367
|
}
|
|
1308
1368
|
/** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
|
|
1309
1369
|
async wire(request, url, rest) {
|
|
1370
|
+
request = withoutBrowserHeader(request);
|
|
1310
1371
|
const [vendor, ...tail] = rest.split('/');
|
|
1311
1372
|
const { scope, via } = this.credential(request);
|
|
1312
1373
|
const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
|
|
@@ -1361,7 +1422,6 @@ export class WorldDoors {
|
|
|
1361
1422
|
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
1362
1423
|
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
1363
1424
|
headers.delete(CALLER_HEADER);
|
|
1364
|
-
headers.delete(BROWSER_SESSION_HEADER);
|
|
1365
1425
|
// the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
|
|
1366
1426
|
// credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
|
|
1367
1427
|
if (via === 'session') {
|
|
@@ -1415,10 +1475,10 @@ export class WorldDoors {
|
|
|
1415
1475
|
// above): caches keep them apart
|
|
1416
1476
|
out.append('vary', 'Sec-Fetch-Dest');
|
|
1417
1477
|
const setCookies = answer.headers.getSetCookie();
|
|
1418
|
-
if (setCookies.some((c) => /^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))) {
|
|
1478
|
+
if (setCookies.some((c) => /^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c))) {
|
|
1419
1479
|
out.delete('set-cookie');
|
|
1420
1480
|
for (const c of setCookies)
|
|
1421
|
-
if (!/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))
|
|
1481
|
+
if (!/^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c))
|
|
1422
1482
|
out.append('set-cookie', c);
|
|
1423
1483
|
}
|
|
1424
1484
|
return new Response(answer.body, { status: answer.status, headers: out });
|
|
@@ -1437,7 +1497,7 @@ export class WorldDoors {
|
|
|
1437
1497
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
1438
1498
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
1439
1499
|
const caller = new Headers(request.headers);
|
|
1440
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER,
|
|
1500
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
1441
1501
|
caller.delete(h);
|
|
1442
1502
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
1443
1503
|
const authorization = caller.get('authorization');
|
|
@@ -1600,21 +1660,39 @@ export class WorldDoors {
|
|
|
1600
1660
|
if (kind === 'sites' && name === 'go' && request.method === 'GET') {
|
|
1601
1661
|
const fetchSite = request.headers.get('sec-fetch-site');
|
|
1602
1662
|
const dest = request.headers.get('sec-fetch-dest');
|
|
1663
|
+
const browser = url.searchParams.get('browser') ?? undefined;
|
|
1664
|
+
if (browser && fetchSite !== 'same-origin' && fetchSite !== 'none')
|
|
1665
|
+
return Response.json({ error: 'a browser site opens only from its World or a typed address (Sec-Fetch-Site: same-origin or none)' }, { status: 403 });
|
|
1603
1666
|
// a person's navigation (from here, from the site's own page linking back, or typed in), or a frame of this World's
|
|
1604
1667
|
// own console (its board, its comparison)
|
|
1605
|
-
const navigated = dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
|
|
1606
|
-
const framed =
|
|
1668
|
+
const navigated = (request.method === 'GET' || request.method === 'HEAD') && dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
|
|
1669
|
+
const framed = fetchSite === 'same-origin' && dest === 'iframe';
|
|
1607
1670
|
if (via === 'session' && !navigated && !framed)
|
|
1608
1671
|
return Response.json({ error: "a hosted site opens on a person's navigation" }, { status: 403 });
|
|
1609
1672
|
const template = this.host.sitesTemplate?.() ?? null;
|
|
1610
1673
|
const hostname = (url.searchParams.get('host') ?? '').toLowerCase();
|
|
1611
|
-
|
|
1612
|
-
|
|
1674
|
+
if (browser) {
|
|
1675
|
+
try {
|
|
1676
|
+
assertBrowserName(browser);
|
|
1677
|
+
}
|
|
1678
|
+
catch (error) {
|
|
1679
|
+
return Response.json({ error: error.message }, { status: 400 });
|
|
1680
|
+
}
|
|
1681
|
+
if (!worldBrowser(this.up().dirs.data, browser))
|
|
1682
|
+
return Response.json({ error: `no browser ${browser}` }, { status: 404 });
|
|
1683
|
+
}
|
|
1684
|
+
const claimed = (await (browser ? this.browserSites(url.origin) : this.claimedHosts(url.origin))).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
|
|
1685
|
+
const at = template && claimed ? siteUrlOf(template, hostname, browser) : null;
|
|
1686
|
+
if (browser && at && (await this.claimedHosts(url.origin)).some(row => row.hosts.some(site => {
|
|
1687
|
+
const claimedAt = siteUrlOf(template, site);
|
|
1688
|
+
return claimedAt && new URL(claimedAt).host === new URL(at).host;
|
|
1689
|
+
})))
|
|
1690
|
+
return Response.json({ error: `browser ${browser}'s address for ${hostname} is owned by a claimed site` }, { status: 404 });
|
|
1613
1691
|
if (!at)
|
|
1614
1692
|
return Response.json({ error: `${hostname || 'that host'} is no site of ${this.served} shown here` }, { status: 404 });
|
|
1615
1693
|
const asked = this.credential(request);
|
|
1616
1694
|
const land = url.searchParams.get('land') ?? '/';
|
|
1617
|
-
const passed = this.mintPagesPass('read', { site: hostname, ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) });
|
|
1695
|
+
const passed = this.mintPagesPass(browser ? scope : 'read', { site: hostname, ...(browser ? { browser } : {}), ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) });
|
|
1618
1696
|
if (!passed)
|
|
1619
1697
|
return Response.json({ error: 'too many sites opened at once: try again in a minute' }, { status: 429 });
|
|
1620
1698
|
return new Response(null, { status: 303, headers: { location: `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass: passed, land: land.startsWith('/') ? land : '/' }).toString()}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
@@ -1633,6 +1711,122 @@ export class WorldDoors {
|
|
|
1633
1711
|
return this.keysDoor(request, name, scope, via, this.credential(request).session);
|
|
1634
1712
|
if (scope === 'read' && request.method !== 'GET')
|
|
1635
1713
|
return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1714
|
+
if (kind === 'browsers') {
|
|
1715
|
+
if (parts.length > 2)
|
|
1716
|
+
return Response.json({ error: 'no such browser door' }, { status: 404 });
|
|
1717
|
+
const headers = { 'cache-control': 'no-store' };
|
|
1718
|
+
if (!name)
|
|
1719
|
+
return request.method === 'GET'
|
|
1720
|
+
? Response.json({ browsers: worldBrowsers(this.up().dirs.data).map(b => b.name) }, { headers })
|
|
1721
|
+
: Response.json({ error: 'list browsers with GET' }, { status: 405 });
|
|
1722
|
+
try {
|
|
1723
|
+
assertBrowserName(name);
|
|
1724
|
+
}
|
|
1725
|
+
catch (error) {
|
|
1726
|
+
return Response.json({ error: error.message }, { status: 400 });
|
|
1727
|
+
}
|
|
1728
|
+
return this.serialized('browsers', async () => {
|
|
1729
|
+
const data = this.host.reload().dirs.data;
|
|
1730
|
+
if (request.method === 'GET') {
|
|
1731
|
+
const b = worldBrowser(data, name);
|
|
1732
|
+
return b ? Response.json(b.state, { headers }) : Response.json({ error: `no browser ${name}` }, { status: 404 });
|
|
1733
|
+
}
|
|
1734
|
+
if (request.method === 'DELETE') {
|
|
1735
|
+
if (!worldBrowser(data, name))
|
|
1736
|
+
return Response.json({ error: `no browser ${name}` }, { status: 404 });
|
|
1737
|
+
deleteWorldBrowser(data, name);
|
|
1738
|
+
if (this.up().envFile)
|
|
1739
|
+
materializeBrowserFiles(data, this.worldRoot, this.name, loadWorldConfig(this.configRef, this.worldRoot).config.id);
|
|
1740
|
+
return new Response(null, { status: 204, headers });
|
|
1741
|
+
}
|
|
1742
|
+
if (request.method === 'PUT') {
|
|
1743
|
+
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
|
|
1744
|
+
return Response.json({ error: 'browser state is application/json' }, { status: 415 });
|
|
1745
|
+
if (!worldBrowser(data, name) && worldBrowsers(data).length >= BROWSER_COUNT_LIMIT)
|
|
1746
|
+
return Response.json({ error: `a World holds at most ${BROWSER_COUNT_LIMIT} browsers; delete one before adding another` }, { status: 409 });
|
|
1747
|
+
const oversized = () => Response.json({ error: `browser state PUT body is limited to ${BROWSER_STATE_BYTES_LIMIT} bytes (1 MiB)` }, { status: 413 });
|
|
1748
|
+
const length = request.headers.get('content-length');
|
|
1749
|
+
if (length && /^\d+$/.test(length) && Number(length) > BROWSER_STATE_BYTES_LIMIT)
|
|
1750
|
+
return oversized();
|
|
1751
|
+
let state;
|
|
1752
|
+
try {
|
|
1753
|
+
const reader = request.body?.getReader();
|
|
1754
|
+
const chunks = [];
|
|
1755
|
+
let bytes = 0;
|
|
1756
|
+
if (reader) {
|
|
1757
|
+
try {
|
|
1758
|
+
for (;;) {
|
|
1759
|
+
const chunk = await reader.read();
|
|
1760
|
+
if (chunk.done)
|
|
1761
|
+
break;
|
|
1762
|
+
bytes += chunk.value.byteLength;
|
|
1763
|
+
if (bytes > BROWSER_STATE_BYTES_LIMIT) {
|
|
1764
|
+
await reader.cancel().catch(() => { });
|
|
1765
|
+
return oversized();
|
|
1766
|
+
}
|
|
1767
|
+
chunks.push(chunk.value);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
finally {
|
|
1771
|
+
reader.releaseLock();
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
const body = new Uint8Array(bytes);
|
|
1775
|
+
let offset = 0;
|
|
1776
|
+
for (const chunk of chunks) {
|
|
1777
|
+
body.set(chunk, offset);
|
|
1778
|
+
offset += chunk.byteLength;
|
|
1779
|
+
}
|
|
1780
|
+
state = validateBrowserState(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)));
|
|
1781
|
+
}
|
|
1782
|
+
catch (error) {
|
|
1783
|
+
return Response.json({ error: error.message }, { status: 400 });
|
|
1784
|
+
}
|
|
1785
|
+
const template = this.host.sitesTemplate?.();
|
|
1786
|
+
if (template) {
|
|
1787
|
+
const sites = await this.browserSites(url.origin);
|
|
1788
|
+
const claimed = await this.claimedHosts(url.origin);
|
|
1789
|
+
const addresses = new Map();
|
|
1790
|
+
for (const { hosts } of claimed)
|
|
1791
|
+
for (const site of hosts) {
|
|
1792
|
+
const at = siteUrlOf(template, site);
|
|
1793
|
+
if (at)
|
|
1794
|
+
addresses.set(new URL(at).host, `claimed site ${site}`);
|
|
1795
|
+
}
|
|
1796
|
+
for (const other of worldBrowsers(data))
|
|
1797
|
+
if (other.name !== name)
|
|
1798
|
+
for (const { hosts } of sites)
|
|
1799
|
+
for (const site of hosts) {
|
|
1800
|
+
const at = siteUrlOf(template, site, other.name);
|
|
1801
|
+
if (at)
|
|
1802
|
+
addresses.set(new URL(at).host, `browser ${other.name}, site ${site}`);
|
|
1803
|
+
}
|
|
1804
|
+
let fits = false;
|
|
1805
|
+
for (const { hosts } of sites)
|
|
1806
|
+
for (const site of hosts) {
|
|
1807
|
+
const at = siteUrlOf(template, site, name);
|
|
1808
|
+
if (!at)
|
|
1809
|
+
continue;
|
|
1810
|
+
fits = true;
|
|
1811
|
+
const host = new URL(at).host;
|
|
1812
|
+
const identity = `browser ${name}, site ${site}`;
|
|
1813
|
+
if (addresses.has(host) && addresses.get(host) !== identity)
|
|
1814
|
+
return Response.json({ error: `${identity} collides with ${addresses.get(host)} at ${host}` }, { status: 400 });
|
|
1815
|
+
addresses.set(host, identity);
|
|
1816
|
+
}
|
|
1817
|
+
if (!fits)
|
|
1818
|
+
return Response.json({ error: sites.some(row => row.hosts.length)
|
|
1819
|
+
? `no site of this World fits browser ${name} (DNS labels are at most 63 characters)`
|
|
1820
|
+
: `this World has no declared or claimed exact-host site for browser ${name}` }, { status: 400 });
|
|
1821
|
+
}
|
|
1822
|
+
putWorldBrowser(data, name, state);
|
|
1823
|
+
if (this.up().envFile)
|
|
1824
|
+
materializeBrowserFiles(data, this.worldRoot, this.name, loadWorldConfig(this.configRef, this.worldRoot).config.id);
|
|
1825
|
+
return Response.json(state, { headers });
|
|
1826
|
+
}
|
|
1827
|
+
return Response.json({ error: 'browser state takes GET, PUT or DELETE' }, { status: 405 });
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1636
1830
|
if (kind === 'clock')
|
|
1637
1831
|
return await this.clockDoor(request, name);
|
|
1638
1832
|
if (kind === 'branches')
|
|
@@ -2049,33 +2243,6 @@ export class WorldDoors {
|
|
|
2049
2243
|
return Response.json({ world: this.served, twins });
|
|
2050
2244
|
}
|
|
2051
2245
|
if (kind === 'twins' && name) {
|
|
2052
|
-
if (parts.length === 3 && (verb === 'people' || verb === 'session')) {
|
|
2053
|
-
const session = Object.hasOwn(this.up().services, name) ? await this.browserSessionOf(name) : null;
|
|
2054
|
-
if (verb === 'people' && request.method === 'GET')
|
|
2055
|
-
return Response.json({ viewAs: session !== null, people: session ? this.vendorPeople(name).map(({ id, label }) => ({ id, label })) : [] }, { headers: { 'cache-control': 'no-store' } });
|
|
2056
|
-
if (verb === 'session' && request.method === 'POST') {
|
|
2057
|
-
if (!session)
|
|
2058
|
-
return Response.json({ error: `${name} needs a declared browser session, a drawn screen and a content plane for View as` }, { status: 409 });
|
|
2059
|
-
if (via !== 'session' || presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }).length || crossSite || site !== 'same-origin')
|
|
2060
|
-
return Response.json({ error: 'View as is opened from this World’s own page' }, { status: 403 });
|
|
2061
|
-
const body = await request.json().catch(() => null);
|
|
2062
|
-
const land = this.vendorLanding(name, body?.land);
|
|
2063
|
-
if (!body || (body.person !== null && typeof body.person !== 'string') || !land)
|
|
2064
|
-
return Response.json({ error: 'a session names a person (or null) and a path within that vendor' }, { status: 400 });
|
|
2065
|
-
if (body.person !== null && !this.vendorPeople(name).some((p) => p.id === body.person))
|
|
2066
|
-
return Response.json({ error: 'no such person in this World' }, { status: 404 });
|
|
2067
|
-
const at = this.host.pagesOrigin?.();
|
|
2068
|
-
if (!at)
|
|
2069
|
-
return Response.json({ error: 'View as needs this World’s content plane' }, { status: 409 });
|
|
2070
|
-
const asked = this.credential(request);
|
|
2071
|
-
if (!asked.session)
|
|
2072
|
-
return Response.json({ error: 'View as needs this World’s browser session' }, { status: 403 });
|
|
2073
|
-
const pass = this.mintPagesPass('write', { session: asked.session, ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) }, { vendor: name, person: body.person, land, asker: this.callerOf(request) });
|
|
2074
|
-
if (!pass)
|
|
2075
|
-
return Response.json({ error: 'too many pages opened at once: try again in a minute' }, { status: 429 });
|
|
2076
|
-
return Response.json({ url: `${at}/${this.served}/${name}/_twin/view-as/open#${new URLSearchParams({ pass, land, viewAs: '1' }).toString()}` }, { headers: { 'cache-control': 'no-store' } });
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2079
2246
|
if (verb === 'root' && request.method === 'GET')
|
|
2080
2247
|
return Response.json({ twin: name, root: rootForControlRoot(this.controlRoot(name), name) ?? null, credential: sealedCredentialInfo(this.worldRoot, name) });
|
|
2081
2248
|
if (verb === 'root' && request.method === 'PUT') {
|
|
@@ -2415,7 +2582,11 @@ export class WorldDoors {
|
|
|
2415
2582
|
const key = url.searchParams.get('frame') ?? '';
|
|
2416
2583
|
if (!key)
|
|
2417
2584
|
return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
|
|
2418
|
-
const
|
|
2585
|
+
const { frames } = await this.boardFrames(url, fork);
|
|
2586
|
+
const first = worldBrowsers(this.up().dirs.data)[0]?.name;
|
|
2587
|
+
const keyOf = (frame) => boardFrameKey({ vendor: frame.vendor, id: frame.id, ...(typeof frame.browser === 'string' ? { browser: frame.browser } : {}) });
|
|
2588
|
+
const found = frames.find(({ frame }) => keyOf(frame) === key)
|
|
2589
|
+
?? frames.find(({ frame }) => frame.browser === first && `${frame.vendor}:${frame.id}` === key);
|
|
2419
2590
|
if (!found)
|
|
2420
2591
|
return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
|
|
2421
2592
|
const acts = new Map();
|
|
@@ -2434,20 +2605,30 @@ export class WorldDoors {
|
|
|
2434
2605
|
}
|
|
2435
2606
|
/** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
|
|
2436
2607
|
async boardFrames(url, fork = false) {
|
|
2437
|
-
const
|
|
2608
|
+
const listedHosts = await this.claimedHosts(url.origin);
|
|
2609
|
+
const claimed = new Map(listedHosts.map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
2438
2610
|
const sitesAt = this.host.sitesTemplate?.() ?? null;
|
|
2611
|
+
const browsers = worldBrowsers(this.up().dirs.data).map(b => b.name);
|
|
2612
|
+
if (!browsers.length)
|
|
2613
|
+
browsers.push(undefined);
|
|
2614
|
+
const browserHosts = browsers[0] ? new Map((await this.browserSites(url.origin, listedHosts)).map(({ vendor, hosts }) => [vendor, new Set(hosts)])) : claimed;
|
|
2439
2615
|
// a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
|
|
2440
2616
|
// which hands its address a one-time read pass (sites/go); one on this machine opens at its address
|
|
2441
|
-
const siteAt = (hostname, rest) => {
|
|
2617
|
+
const siteAt = (hostname, rest, browser) => {
|
|
2442
2618
|
if (!sitesAt)
|
|
2443
2619
|
return null;
|
|
2444
|
-
const direct = siteUrlOf(sitesAt, hostname);
|
|
2620
|
+
const direct = siteUrlOf(sitesAt, hostname, browser);
|
|
2445
2621
|
if (!direct)
|
|
2446
2622
|
return null;
|
|
2447
|
-
if (
|
|
2623
|
+
if (browser && [...claimed.values()].some(hosts => [...hosts].some(site => {
|
|
2624
|
+
const claimedAt = siteUrlOf(sitesAt, site);
|
|
2625
|
+
return claimedAt && new URL(claimedAt).host === new URL(direct).host;
|
|
2626
|
+
})))
|
|
2627
|
+
return null;
|
|
2628
|
+
if (!browser && sitesAt.includes('{host}'))
|
|
2448
2629
|
return `${direct}${rest}`;
|
|
2449
2630
|
const own = this.host.browserOrigin?.() ?? null;
|
|
2450
|
-
return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
|
|
2631
|
+
return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, ...(browser ? { browser } : {}), land: rest }).toString()}` : null;
|
|
2451
2632
|
};
|
|
2452
2633
|
// inherited history is the parent log: the branch's own ledger starts at its fork, even after a cut
|
|
2453
2634
|
const delta = diffWorld(this.name, { root: this.worldRoot, ...(fork ? { base: 'world-boot' } : {}) });
|
|
@@ -2466,21 +2647,52 @@ export class WorldDoors {
|
|
|
2466
2647
|
}
|
|
2467
2648
|
// a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
|
|
2468
2649
|
// one), whose changes are all of the vendor's
|
|
2469
|
-
|
|
2650
|
+
const fallback = !listed.length;
|
|
2651
|
+
let homeHost;
|
|
2652
|
+
if (fallback) {
|
|
2470
2653
|
const screens = await this.screensOf(vendor);
|
|
2471
2654
|
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
2655
|
+
homeHost = home?.host;
|
|
2472
2656
|
if (home)
|
|
2473
|
-
|
|
2657
|
+
listed = [{ id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, covers: [], drawn: true }];
|
|
2474
2658
|
}
|
|
2475
2659
|
for (const f of listed) {
|
|
2660
|
+
const covered = new Set(f.covers.map(c => `${c.type}:${c.id}`));
|
|
2661
|
+
const mine = changes.filter(x => x.service === vendor && (fallback || covered.has(`${x.action.subject.type}:${x.action.subject.id}`)));
|
|
2662
|
+
const add = (at, browser, unseen) => {
|
|
2663
|
+
frames.push({ frame: { vendor, ...(browser ? { browser } : {}), id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(f.parent ? { parent: f.parent } : {}), ...(f.ours ? { ours: true } : {}), ...(unseen ? { unseen } : {}) }, mine });
|
|
2664
|
+
};
|
|
2665
|
+
let plain = false;
|
|
2666
|
+
const openedBrowsers = [];
|
|
2667
|
+
for (const browser of browsers) {
|
|
2668
|
+
if (!browser) {
|
|
2669
|
+
plain = true;
|
|
2670
|
+
continue;
|
|
2671
|
+
}
|
|
2672
|
+
let opened = null;
|
|
2673
|
+
try {
|
|
2674
|
+
const u = new URL(f.url, homeHost ? `https://${homeHost}` : undefined);
|
|
2675
|
+
if (browserHosts.get(vendor)?.has(u.hostname.toLowerCase()))
|
|
2676
|
+
opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`, browser);
|
|
2677
|
+
}
|
|
2678
|
+
catch { /* this page has no site address */ }
|
|
2679
|
+
if (opened)
|
|
2680
|
+
openedBrowsers.push({ browser, at: opened });
|
|
2681
|
+
else
|
|
2682
|
+
plain = true;
|
|
2683
|
+
}
|
|
2684
|
+
for (const opened of openedBrowsers)
|
|
2685
|
+
add({ site: opened.at }, opened.browser);
|
|
2686
|
+
if (!plain)
|
|
2687
|
+
continue;
|
|
2476
2688
|
let at = null;
|
|
2477
|
-
// why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
|
|
2478
2689
|
let unseen;
|
|
2479
2690
|
try {
|
|
2480
2691
|
const u = new URL(f.url);
|
|
2481
2692
|
if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) {
|
|
2482
2693
|
const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`);
|
|
2483
|
-
|
|
2694
|
+
if (opened)
|
|
2695
|
+
at = { site: opened };
|
|
2484
2696
|
}
|
|
2485
2697
|
else if (f.drawn)
|
|
2486
2698
|
at = { screen: `${u.pathname}${u.search}` };
|
|
@@ -2488,13 +2700,18 @@ export class WorldDoors {
|
|
|
2488
2700
|
unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
2489
2701
|
}
|
|
2490
2702
|
catch {
|
|
2491
|
-
|
|
2492
|
-
|
|
2703
|
+
if (fallback)
|
|
2704
|
+
at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` };
|
|
2705
|
+
else
|
|
2706
|
+
unseen = 'not-drawn';
|
|
2493
2707
|
}
|
|
2494
|
-
|
|
2495
|
-
|
|
2708
|
+
if (fallback)
|
|
2709
|
+
at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` };
|
|
2710
|
+
add(at, undefined, unseen);
|
|
2496
2711
|
}
|
|
2497
2712
|
}
|
|
2713
|
+
if (browsers[0])
|
|
2714
|
+
frames.sort((a, b) => String(a.frame.browser ?? '').localeCompare(String(b.frame.browser ?? '')));
|
|
2498
2715
|
return { delta, frames };
|
|
2499
2716
|
}
|
|
2500
2717
|
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|