@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/src/served-world.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } from '@volter/world-core/runtime';
|
|
1
|
+
import { boardFrameKey, getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } 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
|
|
@@ -32,9 +31,11 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
|
|
|
32
31
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
33
32
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
34
33
|
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, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
|
|
35
|
-
import {
|
|
34
|
+
import { resolveTwinHost, type HostPredicate } 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.ts';
|
|
36
37
|
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
37
|
-
import { packFacts } from './pack-facts.ts';
|
|
38
|
+
import { packFacts, packHostRules } from './pack-facts.ts';
|
|
38
39
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
|
|
39
40
|
import { createHash } from 'node:crypto';
|
|
40
41
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -53,6 +54,16 @@ import { assertChangesetRules, type WorldChangesetRules, type WorldInstance } fr
|
|
|
53
54
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
54
55
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
55
56
|
export const TWINS_KEY_HEADER = 'x-twins-key';
|
|
57
|
+
// Browser input policy; these caps bound accepted input, independently of infrastructure limits.
|
|
58
|
+
const BROWSER_COUNT_LIMIT = 32;
|
|
59
|
+
const BROWSER_STATE_BYTES_LIMIT = 1024 * 1024;
|
|
60
|
+
/** This fact belongs to the World router, never to a caller. */
|
|
61
|
+
function withoutBrowserHeader(request: Request): Request {
|
|
62
|
+
if (!request.headers.has('x-volter-world-browser')) return request;
|
|
63
|
+
const headers = new Headers(request.headers);
|
|
64
|
+
headers.delete('x-volter-world-browser');
|
|
65
|
+
return new Request(request, { headers });
|
|
66
|
+
}
|
|
56
67
|
class Booting extends Error { constructor() { super('the world is still booting'); } }
|
|
57
68
|
|
|
58
69
|
/** fetch that waits out a world still booting: a refused connection is retried for `forMs`. */
|
|
@@ -91,7 +102,7 @@ export function boardFile(worldRoot: string): string { return join(worldRoot, st
|
|
|
91
102
|
/** A board as the World keeps it: a frame or a section is named `<vendor>:<frame id>` or `<vendor>:section:<section id>`.
|
|
92
103
|
* The first board is everything the packs lay out; another holds what it includes, a section with its frames to come. */
|
|
93
104
|
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 }> };
|
|
94
|
-
const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
|
|
105
|
+
const BOARD_KEY = /^(?:[a-z0-9-]+|~browser:[a-z0-9-]+:[a-z0-9-]+):[^\s]{1,300}$/;
|
|
95
106
|
/** The largest board document a World keeps. */
|
|
96
107
|
const BOARD_BYTES = 256 * 1024;
|
|
97
108
|
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
@@ -120,7 +131,7 @@ function boardsOf(body: unknown): SavedBoard[] | string {
|
|
|
120
131
|
for (const [k, v] of Object.entries(sections)) {
|
|
121
132
|
const r = v as Record<string, unknown> | null;
|
|
122
133
|
// a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
|
|
123
|
-
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))
|
|
134
|
+
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))
|
|
124
135
|
|| !['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)
|
|
125
136
|
|| (r.gone !== true && ((r.w as number) < 100 || (r.h as number) < 100))
|
|
126
137
|
|| (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';
|
|
@@ -170,9 +181,8 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
|
|
|
170
181
|
/** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
|
|
171
182
|
* session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
|
|
172
183
|
* is this machine's own page (attributed to the machine). */
|
|
173
|
-
type VendorSessionPass = { vendor: string; person: string | null; land: string; asker: string };
|
|
174
184
|
type PagesGrant = { scope: 'read' | 'write'; session?: string; who?: string; framer?: string; local?: true; /** the hosted site it
|
|
175
|
-
* opens (a hostname this World's twins answer), not the content plane */ site?: string; /** a parent World's session that asked
|
|
185
|
+
* opens (a hostname this World's twins answer), not the content plane */ site?: string; browser?: string; /** a parent World's session that asked
|
|
176
186
|
* for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
|
|
177
187
|
* cannot tell when it ends, so such a session lasts an hour */ asker?: string };
|
|
178
188
|
type HeldSession = { scope: 'read' | 'write'; until: number; /** opened by this machine's own page (localSession) */ local?: true; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
|
|
@@ -336,7 +346,7 @@ function streamEnv(id: string, protocol: string): Record<string, string> {
|
|
|
336
346
|
|
|
337
347
|
/** What the doors need of a world: where each twin's state lives and which twins it has. A booted
|
|
338
348
|
* local instance is one; a hosted World describes itself. */
|
|
339
|
-
export type WorldLayout = { dirs: { data: string }; services: Record<string, { url?: string; protocol?: unknown }> };
|
|
349
|
+
export type WorldLayout = { dirs: { data: string }; envFile?: string; services: Record<string, { url?: string; protocol?: unknown }> };
|
|
340
350
|
/** The host a world's doors run in: its layout, and how a request reaches a twin's wire — a local
|
|
341
351
|
* host forwards to the twin's own port, a hosted World calls the pack in-process. */
|
|
342
352
|
export type DoorHost = {
|
|
@@ -453,7 +463,7 @@ export class WorldDoors {
|
|
|
453
463
|
* token: it uses the twins at its scope and nothing else. One per session that gave it a pass (every frame of a board
|
|
454
464
|
* shares it), kept in the World's store, ended with that session; framed only by the console that asked. */
|
|
455
465
|
private onPages(url: URL): boolean { const at = this.host.pagesOrigin?.() ?? null; return at !== null && url.host === new URL(at).host; }
|
|
456
|
-
private readonly pagePasses = new Map<string, PagesGrant & { until: number
|
|
466
|
+
private readonly pagePasses = new Map<string, PagesGrant & { until: number }>();
|
|
457
467
|
private pagesPath(): string { return join(this.worldRoot, stateDirName(), 'pages-sessions.json'); }
|
|
458
468
|
private pagesHeld: Record<string, PagesGrant & { until: number }> | null = null;
|
|
459
469
|
private pagesSessions(): Record<string, PagesGrant & { until: number }> {
|
|
@@ -492,14 +502,14 @@ export class WorldDoors {
|
|
|
492
502
|
return pass ? `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}` : null;
|
|
493
503
|
}
|
|
494
504
|
/** A one-time pass (a minute long) for the content plane or a hosted site; null when its asker holds fifty unused. */
|
|
495
|
-
private mintPagesPass(scope: 'read' | 'write', grant: Omit<PagesGrant, 'scope'
|
|
505
|
+
private mintPagesPass(scope: 'read' | 'write', grant: Omit<PagesGrant, 'scope'>): string | null {
|
|
496
506
|
const now = Date.now();
|
|
497
507
|
for (const [k, v] of this.pagePasses) if (v.until <= now) this.pagePasses.delete(k);
|
|
498
508
|
const askerOf = (g: Omit<PagesGrant, 'scope'>): string => g.session ?? g.asker ?? g.framer ?? 'token';
|
|
499
509
|
const asker = askerOf(grant);
|
|
500
510
|
if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000) return null;
|
|
501
511
|
const pass = mintToken('pass_');
|
|
502
|
-
this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000
|
|
512
|
+
this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
|
|
503
513
|
return pass;
|
|
504
514
|
}
|
|
505
515
|
/** The hostname a hosted site address names (`{site}` template: `<label>--<world>--<org>.<suffix>`) among those this
|
|
@@ -507,27 +517,41 @@ export class WorldDoors {
|
|
|
507
517
|
/** Whether an address has a hosted site's shape here (`<label>--<world>--<org>.<suffix>`), claimed or not. */
|
|
508
518
|
private siteShaped(url: URL): boolean {
|
|
509
519
|
const template = this.host.sitesTemplate?.() ?? null;
|
|
510
|
-
if (!template
|
|
511
|
-
const
|
|
512
|
-
|
|
520
|
+
if (!template) return false;
|
|
521
|
+
const flat = template.includes('{site}');
|
|
522
|
+
if (!flat && !template.includes('{host}')) return false;
|
|
523
|
+
const after = new URL(template.replace(flat ? '{site}' : '{host}', 'x')).host.slice(1);
|
|
524
|
+
if (!url.host.endsWith(after) || url.host.length <= after.length) return false;
|
|
525
|
+
const label = url.host.slice(0, -after.length);
|
|
526
|
+
return flat || (!label.includes('.') && label.includes('--'));
|
|
513
527
|
}
|
|
514
|
-
private async hostedSiteOf(url: URL): Promise<string | null> {
|
|
528
|
+
private async hostedSiteOf(url: URL): Promise<{ site: string; browser?: string } | null> {
|
|
515
529
|
if (!this.siteShaped(url)) return null;
|
|
516
|
-
const template = this.host.sitesTemplate
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
530
|
+
const template = this.host.sitesTemplate!()!;
|
|
531
|
+
const claimed = await this.claimedHosts(url.origin);
|
|
532
|
+
for (const { hosts } of claimed) for (const site of hosts) {
|
|
533
|
+
const at = siteUrlOf(template, site);
|
|
534
|
+
if (at && new URL(at).host === url.host) return { site };
|
|
535
|
+
}
|
|
536
|
+
const browsers = worldBrowsers(this.up().dirs.data);
|
|
537
|
+
if (!browsers.length) return null;
|
|
538
|
+
const sites = await this.browserSites(url.origin, claimed);
|
|
539
|
+
for (const browser of browsers) {
|
|
540
|
+
for (const { hosts } of sites) for (const site of hosts) {
|
|
541
|
+
const at = siteUrlOf(template, site, browser.name);
|
|
542
|
+
if (at && new URL(at).host === url.host) return { site, browser: browser.name };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
522
545
|
return null;
|
|
523
546
|
}
|
|
547
|
+
|
|
524
548
|
private pagesCookie(url: URL): string { return url.protocol === 'https:' ? '__Host-volter_pages' : 'volter_pages'; }
|
|
525
|
-
private pageSession(request: Request, url: URL, site?: string): ({ id: string } & PagesGrant) | null {
|
|
549
|
+
private pageSession(request: Request, url: URL, site?: string, browser?: string): ({ id: string } & PagesGrant) | null {
|
|
526
550
|
const id = new RegExp(`(?:^|;\\s*)${this.pagesCookie(url)}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
527
551
|
const held = id && Object.hasOwn(this.pagesSessions(), id) ? this.pagesSessions()[id]! : undefined;
|
|
528
552
|
if (!id || !held || held.until <= Date.now() || !this.pagesParentLive(held)) return null;
|
|
529
553
|
// a session opens only where its pass was for: the content plane, or that one hosted site
|
|
530
|
-
if ((held.site ?? null) !== (site ?? null)) return null;
|
|
554
|
+
if ((held.site ?? null) !== (site ?? null) || held.browser !== browser) return null;
|
|
531
555
|
const { until: _until, ...grant } = held;
|
|
532
556
|
return { id, ...grant };
|
|
533
557
|
}
|
|
@@ -538,17 +562,15 @@ export class WorldDoors {
|
|
|
538
562
|
if (!cookies.length) return;
|
|
539
563
|
out.delete('set-cookie');
|
|
540
564
|
for (const c of cookies) {
|
|
541
|
-
if (/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c)) continue;
|
|
565
|
+
if (/^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c)) continue;
|
|
542
566
|
out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned|domain=[^;]*)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
|
|
543
567
|
}
|
|
544
568
|
}
|
|
545
569
|
/** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
|
|
546
|
-
private async pagesPlane(request: Request, url: URL, /** a hosted site served here, not the content plane */ site?: string): Promise<Response> {
|
|
570
|
+
private async pagesPlane(request: Request, url: URL, /** a hosted site served here, not the content plane */ site?: string, browser?: string): Promise<Response> {
|
|
547
571
|
const doors = `/-/${this.served}/pages/`;
|
|
548
|
-
const
|
|
549
|
-
const
|
|
550
|
-
const door = viewVendor ? viewPath[3] : url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
|
|
551
|
-
const opening = viewVendor ? `/${this.served}/${viewVendor}/_twin/view-as/` : doors;
|
|
572
|
+
const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
|
|
573
|
+
const opening = doors;
|
|
552
574
|
const own = this.host.browserOrigin?.() ?? null;
|
|
553
575
|
if (door === 'open' && request.method === 'GET') {
|
|
554
576
|
// framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
|
|
@@ -564,8 +586,6 @@ export class WorldDoors {
|
|
|
564
586
|
history.replaceState(null, '', location.pathname);
|
|
565
587
|
const pass = carried.get('pass') || '';
|
|
566
588
|
const land = carried.get('land') || '';
|
|
567
|
-
const viewAs = carried.get('viewAs') === '1';
|
|
568
|
-
const tell = (data) => { if (viewAs && parent !== window) parent.postMessage({ type: 'volter-view-as', pass, ...data }, ${JSON.stringify(own)}); };
|
|
569
589
|
// resolved as the browser will (encoded dots too), on this origin and under this World
|
|
570
590
|
let to = null;
|
|
571
591
|
// a hosted site lands anywhere on it; the content plane only under this World
|
|
@@ -576,11 +596,9 @@ export class WorldDoors {
|
|
|
576
596
|
.then(async (r) => {
|
|
577
597
|
const opened = await r.json();
|
|
578
598
|
if (!r.ok) throw new Error(opened.error || 'This World did not open.');
|
|
579
|
-
|
|
580
|
-
else if (viewAs) throw new Error('This pass did not sign in to the vendor.');
|
|
581
|
-
else location.replace(to);
|
|
599
|
+
location.replace(to);
|
|
582
600
|
})
|
|
583
|
-
.catch((e) => { document.body.textContent = e.message || 'This World did not answer.';
|
|
601
|
+
.catch((e) => { document.body.textContent = e.message || 'This World did not answer.'; });
|
|
584
602
|
})();
|
|
585
603
|
`;
|
|
586
604
|
return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
@@ -591,17 +609,10 @@ export class WorldDoors {
|
|
|
591
609
|
const given = this.pagePasses.get(pass);
|
|
592
610
|
this.pagePasses.delete(pass);
|
|
593
611
|
if (request.headers.get('sec-fetch-site') !== 'same-origin') return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
|
|
594
|
-
if (!given || given.until <= Date.now() || !this.pagesParentLive(given) || (given.site ?? null) !== (site ?? null)) return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
|
|
595
|
-
const { until: _until,
|
|
596
|
-
if (viewAs) {
|
|
597
|
-
const held = this.pageSession(request, url);
|
|
598
|
-
if (!held) return Response.json({ error: 'Open a vendor page from this World first, then choose View as again.' }, { status: 403 });
|
|
599
|
-
if (viewVendor !== viewAs.vendor || !given.session || held.scope !== 'write' || held.session !== given.session) return Response.json({ error: 'View as belongs to the browser session that asked for it' }, { status: 403 });
|
|
600
|
-
return this.vendorSession(request, viewAs, given.until);
|
|
601
|
-
}
|
|
602
|
-
if (viewVendor) return Response.json({ error: 'this door takes only a View as pass' }, { status: 403 });
|
|
612
|
+
if (!given || given.until <= Date.now() || !this.pagesParentLive(given) || (given.site ?? null) !== (site ?? null) || given.browser !== browser) return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
|
|
613
|
+
const { until: _until, ...grant } = given;
|
|
603
614
|
// one content session per asker at a scope and framer: a board's frames opening together share it
|
|
604
|
-
const same = (x: PagesGrant): boolean => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who && x.site === grant.site;
|
|
615
|
+
const same = (x: PagesGrant): boolean => 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;
|
|
605
616
|
const now = Date.now();
|
|
606
617
|
const found = Object.entries(this.pagesSessions()).find(([, x]) => x.until > now && same(x))?.[0];
|
|
607
618
|
const id = found ?? mintToken('pgs_');
|
|
@@ -613,21 +624,26 @@ export class WorldDoors {
|
|
|
613
624
|
}
|
|
614
625
|
// A HOSTED SITE: read with a session its World's own origin gave it a pass for (sites/go), never without
|
|
615
626
|
if (site) {
|
|
616
|
-
const held = this.pageSession(request, url, site);
|
|
617
|
-
const back = own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: site, land: `${url.pathname}${url.search}` }).toString()}` : null;
|
|
627
|
+
const held = this.pageSession(request, url, site, browser);
|
|
628
|
+
const back = own && !browser ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: site, land: `${url.pathname}${url.search}` }).toString()}` : null;
|
|
618
629
|
if (!held) {
|
|
619
|
-
if (request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html') && back) {
|
|
630
|
+
if (request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html') && (browser || back)) {
|
|
620
631
|
const shown = site.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
621
|
-
|
|
632
|
+
const opening = browser ? "Open this site from the World's board." : `<a href="${back!.replace(/&/g, '&')}">Open it from the World</a>`;
|
|
633
|
+
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' } });
|
|
622
634
|
}
|
|
623
|
-
return Response.json({ error: `${site} is shown to ${this.served}'s people: open it from the World` }, { status: 401 });
|
|
635
|
+
return Response.json({ error: `${site} is shown to ${this.served}'s people: open it from the World${browser ? "'s board" : ''}` }, { status: 401 });
|
|
624
636
|
}
|
|
625
637
|
// read by the site's own pages, or a person's navigation to it: never by another page of the same domain (the
|
|
626
638
|
// session's partition is the domain's, which every World's pages share)
|
|
627
639
|
const fetchSite = request.headers.get('sec-fetch-site');
|
|
628
640
|
const navigation = ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
629
|
-
if (fetchSite !== 'same-origin' && !(navigation && ['none', 'same-site'].includes(fetchSite ?? ''))) return Response.json({ error: `${site} is read by its own pages` }, { status: 403 });
|
|
630
|
-
|
|
641
|
+
if (fetchSite !== 'same-origin' && !((!browser || request.method === 'GET' || request.method === 'HEAD') && navigation && ['none', 'same-site'].includes(fetchSite ?? ''))) return Response.json({ error: `${site} is read by its own pages` }, { status: 403 });
|
|
642
|
+
if (browser) {
|
|
643
|
+
const loading = this.loadBrowser(request, url, site, browser);
|
|
644
|
+
if (loading) return loading;
|
|
645
|
+
}
|
|
646
|
+
const answer = await this.site(request, site, browser, held);
|
|
631
647
|
const out = new Headers(answer.headers);
|
|
632
648
|
out.append('content-security-policy', `frame-ancestors 'self'${held.framer ? ` ${held.framer}` : ''}`);
|
|
633
649
|
this.keepCookiesHere(answer, out);
|
|
@@ -652,61 +668,6 @@ export class WorldDoors {
|
|
|
652
668
|
return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
|
|
653
669
|
}
|
|
654
670
|
|
|
655
|
-
/** A landing resolved as a browser resolves it, within this vendor's place only. */
|
|
656
|
-
private vendorLanding(vendor: string, land: unknown): string | null {
|
|
657
|
-
if (typeof land !== 'string' || !land.startsWith('/') || land.startsWith('//') || /[\\\s]/.test(land)) return null;
|
|
658
|
-
try {
|
|
659
|
-
const url = new URL(land, 'https://world.local');
|
|
660
|
-
const path = decodeURIComponent(url.pathname);
|
|
661
|
-
const base = `/${this.served}/${vendor}/`;
|
|
662
|
-
if (url.origin !== 'https://world.local' || !url.pathname.startsWith(base) || !path.startsWith(base)
|
|
663
|
-
|| path.includes('\\') || path.split('/').some((p) => p === '.' || p === '..' || p.includes('%'))) return null;
|
|
664
|
-
return url.pathname + url.search + url.hash;
|
|
665
|
-
} catch { return null; }
|
|
666
|
-
}
|
|
667
|
-
/** The pack's declared browser session, never inferred from its vendor or a sign-in page. */
|
|
668
|
-
private async browserSessionOf(vendor: string): Promise<NonNullable<DerivedManifest['browserSession']> | null> {
|
|
669
|
-
const twin = this.host.twinFetch(vendor);
|
|
670
|
-
if (!twin || !this.host.pagesOrigin?.()) return null;
|
|
671
|
-
try {
|
|
672
|
-
const answer = await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } }));
|
|
673
|
-
const manifest = answer.ok ? await answer.json() as { browserSession?: unknown; drawsScreens?: boolean } : null;
|
|
674
|
-
if (!manifest?.drawsScreens) return null;
|
|
675
|
-
const session = manifest?.browserSession;
|
|
676
|
-
if (!session || typeof session !== 'object') return null;
|
|
677
|
-
const { cookie, scope } = session as { cookie?: unknown; scope?: unknown };
|
|
678
|
-
if (typeof cookie !== 'string' || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(cookie)
|
|
679
|
-
|| /^(?:__Host-|(?:__Secure-)?volter_(?:world|pages)$)/i.test(cookie) || (scope !== undefined && typeof scope !== 'string')) return null;
|
|
680
|
-
return { cookie, ...(typeof scope === 'string' ? { scope } : {}) };
|
|
681
|
-
} catch { return null; }
|
|
682
|
-
}
|
|
683
|
-
private vendorPeople(vendor: string): Array<{ id: string; label: string; email: string }> {
|
|
684
|
-
return readTree(this.stateOf(vendor), this.controlRoot(vendor))
|
|
685
|
-
.filter((p) => p.type === '_person' && p.deleted !== true && typeof p.email === 'string')
|
|
686
|
-
.map((p) => ({ id: p.id, email: String(p.email), label: String(p.email) }));
|
|
687
|
-
}
|
|
688
|
-
/** The twin receives its own cookie and changes the session in its own process, over a host-only permit. */
|
|
689
|
-
private async vendorSession(request: Request, pass: VendorSessionPass, until: number): Promise<Response> {
|
|
690
|
-
const twin = this.host.twinFetch(pass.vendor);
|
|
691
|
-
if (!twin || !await this.browserSessionOf(pass.vendor) || !this.vendorLanding(pass.vendor, pass.land)) return Response.json({ error: 'that vendor session is unavailable' }, { status: 404 });
|
|
692
|
-
const person = pass.person === null ? null : this.vendorPeople(pass.vendor).find((p) => p.id === pass.person);
|
|
693
|
-
if (person === undefined) return Response.json({ error: 'that person is no longer in the World' }, { status: 404 });
|
|
694
|
-
const url = new URL(request.url);
|
|
695
|
-
const headers = new Headers({ [TWIN_PREFIX_HEADER]: `/${this.served}/${pass.vendor}`, 'x-forwarded-host': url.host, 'x-forwarded-proto': url.protocol.slice(0, -1) });
|
|
696
|
-
headers.set(CALLER_HEADER, callerForHeader(pass.asker));
|
|
697
|
-
headers.set('traceparent', traceparentForDelivery(request.headers.get('traceparent')) ?? newTraceparent());
|
|
698
|
-
const cookies = (request.headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
|
|
699
|
-
if (cookies.length) headers.set('cookie', cookies.join('; '));
|
|
700
|
-
const answer = await withBrowserSessionPermit(this.stateOf(pass.vendor), this.controlRoot(pass.vendor), { person: pass.person, land: pass.land, until }, async (permit) => {
|
|
701
|
-
headers.set(BROWSER_SESSION_HEADER, permit);
|
|
702
|
-
return twin(new Request(`${url.origin}${BROWSER_SESSION_PATH}`, { method: 'POST', headers, redirect: 'manual' }));
|
|
703
|
-
});
|
|
704
|
-
if (answer.status !== 303) return answer;
|
|
705
|
-
const out = new Headers({ 'cache-control': 'no-store' });
|
|
706
|
-
this.keepCookiesHere(answer, out);
|
|
707
|
-
return Response.json({ viewAs: { vendor: pass.vendor, person: pass.person, label: person?.email ?? 'Signed out', land: pass.land } }, { headers: out });
|
|
708
|
-
}
|
|
709
|
-
|
|
710
671
|
/** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
|
|
711
672
|
* not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
|
|
712
673
|
* keeps them; each lasts 30 days from its opening. */
|
|
@@ -1027,6 +988,68 @@ export class WorldDoors {
|
|
|
1027
988
|
return out;
|
|
1028
989
|
}
|
|
1029
990
|
|
|
991
|
+
/** Browser sites include the pack's exact declared hosts, beside the dynamically claimed sites. */
|
|
992
|
+
private async browserSites(origin: string, listed?: Array<{ vendor: string; hosts: string[] }>): Promise<Array<{ vendor: string; hosts: string[] }>> {
|
|
993
|
+
const dynamic = listed ?? await this.claimedHosts(origin);
|
|
994
|
+
return Object.keys(this.up().services).map(vendor => ({ vendor, hosts: [...new Set([
|
|
995
|
+
...(dynamic.find(d => d.vendor === vendor)?.hosts ?? []),
|
|
996
|
+
...(packFacts()[vendor]?.hosts ?? []).flatMap(rule => typeof rule.host === 'string' && !rule.exclude ? [rule.host] : []),
|
|
997
|
+
])] }));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
private async browserRoutes(origin: string): Promise<{ sites: Array<{ vendor: string; hosts: string[] }>; rules: Record<string, HostPredicate>; map: Record<string, string>; claimed: Record<string, Set<string>> }> {
|
|
1001
|
+
const listed = await this.claimedHosts(origin);
|
|
1002
|
+
const facts = packFacts();
|
|
1003
|
+
const map: Record<string, string> = Object.fromEntries(Object.keys(this.up().services).map(vendor => [vendor, vendor]));
|
|
1004
|
+
const claimed: Record<string, Set<string>> = {};
|
|
1005
|
+
// The proxy's static rule order is in the shared table; dynamic order is the same facts order.
|
|
1006
|
+
for (const vendor of Object.keys(facts)) if (this.up().services[vendor]) {
|
|
1007
|
+
map[vendor] = vendor;
|
|
1008
|
+
for (const rule of facts[vendor]?.hosts ?? []) if (rule.key) map[rule.key] = vendor;
|
|
1009
|
+
if (facts[vendor]?.hostsClaimed) claimed[vendor] = new Set(listed.find(row => row.vendor === vendor)?.hosts ?? []);
|
|
1010
|
+
}
|
|
1011
|
+
return { sites: await this.browserSites(origin, listed), rules: packHostRules(), map, claimed };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private loadBrowser(request: Request, url: URL, site: string, name: string): Response | null {
|
|
1015
|
+
const browser = worldBrowser(this.up().dirs.data, name);
|
|
1016
|
+
if (!browser) return Response.json({ error: `no browser ${name}` }, { status: 404 });
|
|
1017
|
+
const marker = url.protocol === 'https:' ? '__Host-volter_browser' : 'volter_browser';
|
|
1018
|
+
const id = new RegExp(`(?:^|;\\s*)${marker}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
1019
|
+
const loadId = browserLoadId(browser.version, site);
|
|
1020
|
+
const current = browserLoad(this.up().dirs.data, loadId);
|
|
1021
|
+
const previous = browserLoad(this.up().dirs.data, id);
|
|
1022
|
+
const carried = [current, previous].filter(load => load?.name === name && load.site === site);
|
|
1023
|
+
if (id === loadId && current?.name === name && current.site === site && current.version === browser.version) return null;
|
|
1024
|
+
if (request.method !== 'GET' || !['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '')) return null;
|
|
1025
|
+
const cookies = browser.state.cookies.filter(c => cookieMatchesHost(c, site));
|
|
1026
|
+
const storage = browser.state.origins.find(o => new URL(o.origin).hostname === site)?.localStorage ?? [];
|
|
1027
|
+
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
|
1028
|
+
const protectedCookie = (key: string): boolean => /^(?:__Host-)?volter_(?:world|pages|browser)$/.test(key);
|
|
1029
|
+
const expired = new Map<string, { name: string; path: string }>();
|
|
1030
|
+
for (const c of [...carried.flatMap(load => load!.cookies), ...cookies]) expired.set(JSON.stringify([c.name, c.path]), { name: c.name, path: c.path });
|
|
1031
|
+
for (const part of (request.headers.get('cookie') ?? '').split(/;\s*/)) {
|
|
1032
|
+
const key = part.split('=')[0]!;
|
|
1033
|
+
if (/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key)) expired.set(JSON.stringify([key, '/']), { name: key, path: '/' });
|
|
1034
|
+
}
|
|
1035
|
+
for (const c of expired.values()) {
|
|
1036
|
+
if (protectedCookie(c.name)) continue;
|
|
1037
|
+
for (const partition of ['', '; Partitioned']) headers.append('set-cookie', `${c.name}=; Path=${c.path}; Max-Age=0; Secure; SameSite=None${partition}`);
|
|
1038
|
+
}
|
|
1039
|
+
for (const c of cookies) {
|
|
1040
|
+
if (/^(?:__Host-)?volter_(?:world|pages|browser)$/.test(c.name)) continue;
|
|
1041
|
+
if (c.expires !== -1 && c.expires <= Date.parse(worldNow()) / 1000) continue;
|
|
1042
|
+
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`);
|
|
1043
|
+
}
|
|
1044
|
+
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 })) });
|
|
1045
|
+
headers.append('set-cookie', `${marker}=${loadId}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned`);
|
|
1046
|
+
const script = `localStorage.clear(); for (const entry of ${JSON.stringify(storage).replace(/</g, '\\u003c')}) localStorage.setItem(entry.name, entry.value); location.replace(location.href);`;
|
|
1047
|
+
const hash = createHash('sha256').update(script).digest('base64');
|
|
1048
|
+
const framer = this.pageSession(request, url, site, name)?.framer;
|
|
1049
|
+
headers.set('content-security-policy', `default-src 'none'; script-src 'sha256-${hash}'; base-uri 'none'; frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
|
|
1050
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>Loading browser</title><script>${script}</script>`, { headers });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1030
1053
|
/** What a twin asks an application (`ctx.ask`) that this World can answer truthfully, where no process routes it (a
|
|
1031
1054
|
* hosted World's isolate): a GET or HEAD of a URL whose hostname one of this World's twins answers (its hostsClaimed
|
|
1032
1055
|
* door: a Cloudflare twin's Worker route or custom domain) is that twin's answer, read as a browser reads the site
|
|
@@ -1050,22 +1073,48 @@ export class WorldDoors {
|
|
|
1050
1073
|
/** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
|
|
1051
1074
|
* the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
|
|
1052
1075
|
* Reads only: the site is the twin's state as the World holds it, never a way to write it. */
|
|
1053
|
-
async site(request: Request, host: string): Promise<Response> {
|
|
1054
|
-
request =
|
|
1055
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
|
|
1076
|
+
async site(request: Request, host: string, browser?: string, grant?: PagesGrant): Promise<Response> {
|
|
1077
|
+
request = withoutBrowserHeader(request);
|
|
1078
|
+
if (!browser && request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
|
|
1079
|
+
if (browser && !grant) return Response.json({ error: "open this browser site from the World's board" }, { status: 401 });
|
|
1056
1080
|
const url = new URL(request.url);
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1081
|
+
const routes = browser ? await this.browserRoutes(url.origin) : null;
|
|
1082
|
+
let vendor: string | undefined;
|
|
1083
|
+
if (routes) vendor = resolveTwinHost(routes.rules, host, routes.map, url.pathname, routes.claimed)?.origin;
|
|
1084
|
+
else for (const row of await this.claimedHosts(url.origin)) {
|
|
1085
|
+
if (!this.host.twinFetch(row.vendor) || !row.hosts.includes(host)) continue;
|
|
1086
|
+
vendor = row.vendor;
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
const twin = vendor ? this.host.twinFetch(vendor) : undefined;
|
|
1090
|
+
if (vendor && twin) {
|
|
1091
|
+
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
1092
|
+
const enforced = browser && grant?.scope === 'read' && await this.enforcesRead(vendor, twin, url.origin);
|
|
1093
|
+
if (browser && grant?.scope === 'read' && !reads && !enforced) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1094
|
+
const headers = browser ? new Headers(request.headers) : new Headers();
|
|
1095
|
+
for (const key of [TOKEN_HEADER, TWINS_KEY_HEADER, CALLER_HEADER, ORIGINAL_PATH_HEADER, READ_ONLY_HEADER, 'host', TWIN_PREFIX_HEADER]) headers.delete(key);
|
|
1061
1096
|
for (const name of ['accept', 'accept-language', 'if-none-match', 'range', 'user-agent']) { const v = request.headers.get(name); if (v !== null) headers.set(name, v); }
|
|
1097
|
+
if (browser) {
|
|
1098
|
+
const cookies = (request.headers.get('cookie') ?? '').split(/;\s*/).filter(c => !/^(?:__Host-)?volter_(?:world|pages|browser)=/.test(c));
|
|
1099
|
+
if (cookies.length) headers.set('cookie', cookies.join('; '));
|
|
1100
|
+
headers.set('x-volter-world-browser', browser);
|
|
1101
|
+
const template = this.host.sitesTemplate?.();
|
|
1102
|
+
if (template) headers.set(TWIN_SITES_HEADER, template);
|
|
1103
|
+
headers.set('x-forwarded-host', url.host);
|
|
1104
|
+
headers.set('x-forwarded-proto', url.protocol.slice(0, -1));
|
|
1105
|
+
}
|
|
1062
1106
|
headers.set('x-volter-twin-original-host', host);
|
|
1063
|
-
headers.set(READ_ONLY_HEADER, '1');
|
|
1064
|
-
|
|
1107
|
+
if (!browser || enforced) headers.set(READ_ONLY_HEADER, '1');
|
|
1108
|
+
if (browser) {
|
|
1109
|
+
if (grant?.who) headers.set(CALLER_HEADER, callerForHeader(grant.who));
|
|
1110
|
+
headers.set('traceparent', traceparentForDelivery(request.headers.get('traceparent')) ?? newTraceparent());
|
|
1111
|
+
}
|
|
1112
|
+
const body = reads ? undefined : await request.arrayBuffer();
|
|
1113
|
+
const answer = await twin(new Request(`${url.origin}${url.pathname}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
1065
1114
|
// a redirect the site answers names its own hostname: in the World it stays under the World's origin
|
|
1066
1115
|
const out = new Headers(answer.headers);
|
|
1067
1116
|
const location = out.get('location');
|
|
1068
|
-
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 */ } }
|
|
1117
|
+
if (location) { try { const to = new URL(location, `https://${host}`); if (to.hostname === host) out.set('location', `${to.pathname}${to.search}${to.hash}`); else if (browser && routes && resolveTwinHost(routes.rules, to.hostname, routes.map, to.pathname, routes.claimed) && routes.sites.some(row => row.hosts.includes(to.hostname))) { const at = this.host.sitesTemplate?.(); const next = at ? siteUrlOf(at, to.hostname, browser) : null; if (next) out.set('location', `${next}${to.pathname}${to.search}${to.hash}`); } } catch { /* left as answered */ } }
|
|
1069
1118
|
return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
1070
1119
|
}
|
|
1071
1120
|
// 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)
|
|
@@ -1077,7 +1126,7 @@ export class WorldDoors {
|
|
|
1077
1126
|
}
|
|
1078
1127
|
|
|
1079
1128
|
async handle(request: Request): Promise<Response> {
|
|
1080
|
-
request =
|
|
1129
|
+
request = withoutBrowserHeader(request);
|
|
1081
1130
|
try {
|
|
1082
1131
|
if (!this.host.ready() && !new URL(request.url).pathname.startsWith('/-/ping')) return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
|
|
1083
1132
|
const url = new URL(request.url);
|
|
@@ -1085,7 +1134,7 @@ export class WorldDoors {
|
|
|
1085
1134
|
if (url.pathname === '/-/ping') return Response.json({ ok: true, world: this.served, worlds: [this.served] });
|
|
1086
1135
|
if (this.onPages(url)) return await this.pagesPlane(request, url);
|
|
1087
1136
|
const hostedSite = await this.hostedSiteOf(url);
|
|
1088
|
-
if (hostedSite) return await this.pagesPlane(request, url, hostedSite);
|
|
1137
|
+
if (hostedSite) return await this.pagesPlane(request, url, hostedSite.site, hostedSite.browser);
|
|
1089
1138
|
if (this.siteShaped(url)) return Response.json({ error: `no site of ${this.served} at ${url.host}` }, { status: 404 });
|
|
1090
1139
|
if (url.pathname.startsWith(`/-/${this.served}/`)) return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
|
|
1091
1140
|
// the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
|
|
@@ -1125,16 +1174,16 @@ export class WorldDoors {
|
|
|
1125
1174
|
|
|
1126
1175
|
/** A twin's workspaces as its discovery door lists them (`GET /twin`'s `screens`: each built workspace screen's id and
|
|
1127
1176
|
* path), which a person is shown at `/<served>/<vendor><path>`. Asked again after a minute, as `enforcesRead` is. */
|
|
1128
|
-
private readonly workspaces = new Map<string, { screens: Array<{ id: string; path: string }>; at: number }>();
|
|
1129
|
-
private async screensOf(vendor: string): Promise<Array<{ id: string; path: string }>> {
|
|
1177
|
+
private readonly workspaces = new Map<string, { screens: Array<{ id: string; path: string; host?: string }>; at: number }>();
|
|
1178
|
+
private async screensOf(vendor: string): Promise<Array<{ id: string; path: string; host?: string }>> {
|
|
1130
1179
|
const known = this.workspaces.get(vendor);
|
|
1131
1180
|
if (known !== undefined && Date.now() - known.at < 60_000) return known.screens;
|
|
1132
|
-
let screens: Array<{ id: string; path: string }> = [];
|
|
1181
|
+
let screens: Array<{ id: string; path: string; host?: string }> = [];
|
|
1133
1182
|
const twin = this.host.twinFetch(vendor);
|
|
1134
1183
|
try {
|
|
1135
1184
|
const answer = twin ? await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1136
1185
|
const listed = answer?.ok ? ((await answer.json()) as { screens?: unknown }).screens : undefined;
|
|
1137
|
-
if (Array.isArray(listed)) screens = listed.filter((x): x is { id: string; path: string } => typeof x?.id === 'string' && typeof x?.path === 'string').map(({ id, path }) => ({ id, path }));
|
|
1186
|
+
if (Array.isArray(listed)) screens = listed.filter((x): x is { id: string; path: string; host?: string } => typeof x?.id === 'string' && typeof x?.path === 'string').map(({ id, path, host }) => ({ id, path, ...(typeof host === 'string' ? { host } : {}) }));
|
|
1138
1187
|
} catch { screens = []; }
|
|
1139
1188
|
this.workspaces.set(vendor, { screens, at: Date.now() });
|
|
1140
1189
|
return screens;
|
|
@@ -1161,6 +1210,7 @@ export class WorldDoors {
|
|
|
1161
1210
|
|
|
1162
1211
|
/** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
|
|
1163
1212
|
private async wire(request: Request, url: URL, rest: string): Promise<Response> {
|
|
1213
|
+
request = withoutBrowserHeader(request);
|
|
1164
1214
|
const [vendor, ...tail] = rest.split('/');
|
|
1165
1215
|
const { scope, via } = this.credential(request);
|
|
1166
1216
|
const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
|
|
@@ -1205,7 +1255,6 @@ export class WorldDoors {
|
|
|
1205
1255
|
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
1206
1256
|
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
1207
1257
|
headers.delete(CALLER_HEADER);
|
|
1208
|
-
headers.delete(BROWSER_SESSION_HEADER);
|
|
1209
1258
|
// the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
|
|
1210
1259
|
// credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
|
|
1211
1260
|
if (via === 'session') { headers.delete(ORIGINAL_PATH_HEADER); headers.delete('x-volter-twin-original-host'); }
|
|
@@ -1243,9 +1292,9 @@ export class WorldDoors {
|
|
|
1243
1292
|
// above): caches keep them apart
|
|
1244
1293
|
out.append('vary', 'Sec-Fetch-Dest');
|
|
1245
1294
|
const setCookies = answer.headers.getSetCookie();
|
|
1246
|
-
if (setCookies.some((c) => /^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))) {
|
|
1295
|
+
if (setCookies.some((c) => /^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c))) {
|
|
1247
1296
|
out.delete('set-cookie');
|
|
1248
|
-
for (const c of setCookies) if (!/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c)) out.append('set-cookie', c);
|
|
1297
|
+
for (const c of setCookies) if (!/^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c)) out.append('set-cookie', c);
|
|
1249
1298
|
}
|
|
1250
1299
|
return new Response(answer.body, { status: answer.status, headers: out });
|
|
1251
1300
|
}
|
|
@@ -1265,7 +1314,7 @@ export class WorldDoors {
|
|
|
1265
1314
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
1266
1315
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
1267
1316
|
const caller = new Headers(request.headers);
|
|
1268
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER,
|
|
1317
|
+
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']) caller.delete(h);
|
|
1269
1318
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
1270
1319
|
const authorization = caller.get('authorization');
|
|
1271
1320
|
const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
|
|
@@ -1393,19 +1442,29 @@ export class WorldDoors {
|
|
|
1393
1442
|
if (kind === 'sites' && name === 'go' && request.method === 'GET') {
|
|
1394
1443
|
const fetchSite = request.headers.get('sec-fetch-site');
|
|
1395
1444
|
const dest = request.headers.get('sec-fetch-dest');
|
|
1445
|
+
const browser = url.searchParams.get('browser') ?? undefined;
|
|
1446
|
+
if (browser && fetchSite !== 'same-origin' && fetchSite !== 'none') 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 });
|
|
1396
1447
|
// a person's navigation (from here, from the site's own page linking back, or typed in), or a frame of this World's
|
|
1397
1448
|
// own console (its board, its comparison)
|
|
1398
|
-
const navigated = dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
|
|
1399
|
-
const framed =
|
|
1449
|
+
const navigated = (request.method === 'GET' || request.method === 'HEAD') && dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
|
|
1450
|
+
const framed = fetchSite === 'same-origin' && dest === 'iframe';
|
|
1400
1451
|
if (via === 'session' && !navigated && !framed) return Response.json({ error: "a hosted site opens on a person's navigation" }, { status: 403 });
|
|
1401
1452
|
const template = this.host.sitesTemplate?.() ?? null;
|
|
1402
1453
|
const hostname = (url.searchParams.get('host') ?? '').toLowerCase();
|
|
1403
|
-
|
|
1404
|
-
|
|
1454
|
+
if (browser) {
|
|
1455
|
+
try { assertBrowserName(browser); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 400 }); }
|
|
1456
|
+
if (!worldBrowser(this.up().dirs.data, browser)) return Response.json({ error: `no browser ${browser}` }, { status: 404 });
|
|
1457
|
+
}
|
|
1458
|
+
const claimed = (await (browser ? this.browserSites(url.origin) : this.claimedHosts(url.origin))).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
|
|
1459
|
+
const at = template && claimed ? siteUrlOf(template, hostname, browser) : null;
|
|
1460
|
+
if (browser && at && (await this.claimedHosts(url.origin)).some(row => row.hosts.some(site => {
|
|
1461
|
+
const claimedAt = siteUrlOf(template!, site);
|
|
1462
|
+
return claimedAt && new URL(claimedAt).host === new URL(at).host;
|
|
1463
|
+
}))) return Response.json({ error: `browser ${browser}'s address for ${hostname} is owned by a claimed site` }, { status: 404 });
|
|
1405
1464
|
if (!at) return Response.json({ error: `${hostname || 'that host'} is no site of ${this.served} shown here` }, { status: 404 });
|
|
1406
1465
|
const asked = this.credential(request);
|
|
1407
1466
|
const land = url.searchParams.get('land') ?? '/';
|
|
1408
|
-
const passed = this.mintPagesPass('read', { site: hostname, ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin!()! } : {}) });
|
|
1467
|
+
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!()! } : {}) });
|
|
1409
1468
|
if (!passed) return Response.json({ error: 'too many sites opened at once: try again in a minute' }, { status: 429 });
|
|
1410
1469
|
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' } });
|
|
1411
1470
|
}
|
|
@@ -1419,6 +1478,87 @@ export class WorldDoors {
|
|
|
1419
1478
|
if (kind === 'keys') return this.keysDoor(request, name, scope, via, this.credential(request).session);
|
|
1420
1479
|
if (scope === 'read' && request.method !== 'GET') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1421
1480
|
|
|
1481
|
+
if (kind === 'browsers') {
|
|
1482
|
+
if (parts.length > 2) return Response.json({ error: 'no such browser door' }, { status: 404 });
|
|
1483
|
+
const headers = { 'cache-control': 'no-store' };
|
|
1484
|
+
if (!name) return request.method === 'GET'
|
|
1485
|
+
? Response.json({ browsers: worldBrowsers(this.up().dirs.data).map(b => b.name) }, { headers })
|
|
1486
|
+
: Response.json({ error: 'list browsers with GET' }, { status: 405 });
|
|
1487
|
+
try { assertBrowserName(name); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 400 }); }
|
|
1488
|
+
return this.serialized('browsers', async () => {
|
|
1489
|
+
const data = this.host.reload().dirs.data;
|
|
1490
|
+
if (request.method === 'GET') {
|
|
1491
|
+
const b = worldBrowser(data, name);
|
|
1492
|
+
return b ? Response.json(b.state, { headers }) : Response.json({ error: `no browser ${name}` }, { status: 404 });
|
|
1493
|
+
}
|
|
1494
|
+
if (request.method === 'DELETE') {
|
|
1495
|
+
if (!worldBrowser(data, name)) return Response.json({ error: `no browser ${name}` }, { status: 404 });
|
|
1496
|
+
deleteWorldBrowser(data, name);
|
|
1497
|
+
if (this.up().envFile) materializeBrowserFiles(data, this.worldRoot, this.name, loadWorldConfig(this.configRef, this.worldRoot).config.id);
|
|
1498
|
+
return new Response(null, { status: 204, headers });
|
|
1499
|
+
}
|
|
1500
|
+
if (request.method === 'PUT') {
|
|
1501
|
+
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'browser state is application/json' }, { status: 415 });
|
|
1502
|
+
if (!worldBrowser(data, name) && worldBrowsers(data).length >= BROWSER_COUNT_LIMIT) return Response.json({ error: `a World holds at most ${BROWSER_COUNT_LIMIT} browsers; delete one before adding another` }, { status: 409 });
|
|
1503
|
+
const oversized = (): Response => Response.json({ error: `browser state PUT body is limited to ${BROWSER_STATE_BYTES_LIMIT} bytes (1 MiB)` }, { status: 413 });
|
|
1504
|
+
const length = request.headers.get('content-length');
|
|
1505
|
+
if (length && /^\d+$/.test(length) && Number(length) > BROWSER_STATE_BYTES_LIMIT) return oversized();
|
|
1506
|
+
let state;
|
|
1507
|
+
try {
|
|
1508
|
+
const reader = request.body?.getReader();
|
|
1509
|
+
const chunks: Uint8Array[] = [];
|
|
1510
|
+
let bytes = 0;
|
|
1511
|
+
if (reader) {
|
|
1512
|
+
try {
|
|
1513
|
+
for (;;) {
|
|
1514
|
+
const chunk = await reader.read();
|
|
1515
|
+
if (chunk.done) break;
|
|
1516
|
+
bytes += chunk.value.byteLength;
|
|
1517
|
+
if (bytes > BROWSER_STATE_BYTES_LIMIT) { await reader.cancel().catch(() => {}); return oversized(); }
|
|
1518
|
+
chunks.push(chunk.value);
|
|
1519
|
+
}
|
|
1520
|
+
} finally { reader.releaseLock(); }
|
|
1521
|
+
}
|
|
1522
|
+
const body = new Uint8Array(bytes);
|
|
1523
|
+
let offset = 0;
|
|
1524
|
+
for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
|
|
1525
|
+
state = validateBrowserState(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)));
|
|
1526
|
+
} catch (error) { return Response.json({ error: (error as Error).message }, { status: 400 }); }
|
|
1527
|
+
const template = this.host.sitesTemplate?.();
|
|
1528
|
+
if (template) {
|
|
1529
|
+
const sites = await this.browserSites(url.origin);
|
|
1530
|
+
const claimed = await this.claimedHosts(url.origin);
|
|
1531
|
+
const addresses = new Map<string, string>();
|
|
1532
|
+
for (const { hosts } of claimed) for (const site of hosts) {
|
|
1533
|
+
const at = siteUrlOf(template, site);
|
|
1534
|
+
if (at) addresses.set(new URL(at).host, `claimed site ${site}`);
|
|
1535
|
+
}
|
|
1536
|
+
for (const other of worldBrowsers(data)) if (other.name !== name) for (const { hosts } of sites) for (const site of hosts) {
|
|
1537
|
+
const at = siteUrlOf(template, site, other.name);
|
|
1538
|
+
if (at) addresses.set(new URL(at).host, `browser ${other.name}, site ${site}`);
|
|
1539
|
+
}
|
|
1540
|
+
let fits = false;
|
|
1541
|
+
for (const { hosts } of sites) for (const site of hosts) {
|
|
1542
|
+
const at = siteUrlOf(template, site, name);
|
|
1543
|
+
if (!at) continue;
|
|
1544
|
+
fits = true;
|
|
1545
|
+
const host = new URL(at).host;
|
|
1546
|
+
const identity = `browser ${name}, site ${site}`;
|
|
1547
|
+
if (addresses.has(host) && addresses.get(host) !== identity) return Response.json({ error: `${identity} collides with ${addresses.get(host)} at ${host}` }, { status: 400 });
|
|
1548
|
+
addresses.set(host, identity);
|
|
1549
|
+
}
|
|
1550
|
+
if (!fits) return Response.json({ error: sites.some(row => row.hosts.length)
|
|
1551
|
+
? `no site of this World fits browser ${name} (DNS labels are at most 63 characters)`
|
|
1552
|
+
: `this World has no declared or claimed exact-host site for browser ${name}` }, { status: 400 });
|
|
1553
|
+
}
|
|
1554
|
+
putWorldBrowser(data, name, state);
|
|
1555
|
+
if (this.up().envFile) materializeBrowserFiles(data, this.worldRoot, this.name, loadWorldConfig(this.configRef, this.worldRoot).config.id);
|
|
1556
|
+
return Response.json(state, { headers });
|
|
1557
|
+
}
|
|
1558
|
+
return Response.json({ error: 'browser state takes GET, PUT or DELETE' }, { status: 405 });
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1422
1562
|
if (kind === 'clock') return await this.clockDoor(request, name);
|
|
1423
1563
|
if (kind === 'branches') return await this.branchesDoor(request, url, parts.slice(1), via);
|
|
1424
1564
|
// each twin's history cut at an instant: where a branch "as of" that instant starts
|
|
@@ -1740,26 +1880,6 @@ export class WorldDoors {
|
|
|
1740
1880
|
return Response.json({ world: this.served, twins });
|
|
1741
1881
|
}
|
|
1742
1882
|
if (kind === 'twins' && name) {
|
|
1743
|
-
if (parts.length === 3 && (verb === 'people' || verb === 'session')) {
|
|
1744
|
-
const session = Object.hasOwn(this.up().services, name) ? await this.browserSessionOf(name) : null;
|
|
1745
|
-
if (verb === 'people' && request.method === 'GET') return Response.json({ viewAs: session !== null, people: session ? this.vendorPeople(name).map(({ id, label }) => ({ id, label })) : [] }, { headers: { 'cache-control': 'no-store' } });
|
|
1746
|
-
if (verb === 'session' && request.method === 'POST') {
|
|
1747
|
-
if (!session) return Response.json({ error: `${name} needs a declared browser session, a drawn screen and a content plane for View as` }, { status: 409 });
|
|
1748
|
-
if (via !== 'session' || presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }).length || crossSite || site !== 'same-origin') return Response.json({ error: 'View as is opened from this World’s own page' }, { status: 403 });
|
|
1749
|
-
const body = await request.json().catch(() => null) as { person?: unknown; land?: unknown } | null;
|
|
1750
|
-
const land = this.vendorLanding(name, body?.land);
|
|
1751
|
-
if (!body || (body.person !== null && typeof body.person !== 'string') || !land) return Response.json({ error: 'a session names a person (or null) and a path within that vendor' }, { status: 400 });
|
|
1752
|
-
if (body.person !== null && !this.vendorPeople(name).some((p) => p.id === body.person)) return Response.json({ error: 'no such person in this World' }, { status: 404 });
|
|
1753
|
-
const at = this.host.pagesOrigin?.();
|
|
1754
|
-
if (!at) return Response.json({ error: 'View as needs this World’s content plane' }, { status: 409 });
|
|
1755
|
-
const asked = this.credential(request);
|
|
1756
|
-
if (!asked.session) return Response.json({ error: 'View as needs this World’s browser session' }, { status: 403 });
|
|
1757
|
-
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)! });
|
|
1758
|
-
if (!pass) return Response.json({ error: 'too many pages opened at once: try again in a minute' }, { status: 429 });
|
|
1759
|
-
return Response.json({ url: `${at}/${this.served}/${name}/_twin/view-as/open#${new URLSearchParams({ pass, land, viewAs: '1' }).toString()}` }, { headers: { 'cache-control': 'no-store' } });
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
1883
|
if (verb === 'root' && request.method === 'GET') return Response.json({ twin: name, root: rootForControlRoot(this.controlRoot(name), name) ?? null, credential: sealedCredentialInfo(this.worldRoot, name) });
|
|
1764
1884
|
if (verb === 'root' && request.method === 'PUT') {
|
|
1765
1885
|
const body = (await request.json()) as RootConfig | null;
|
|
@@ -2030,7 +2150,11 @@ export class WorldDoors {
|
|
|
2030
2150
|
private async boardChanges(url: URL, fork = false): Promise<Response> {
|
|
2031
2151
|
const key = url.searchParams.get('frame') ?? '';
|
|
2032
2152
|
if (!key) return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
|
|
2033
|
-
const
|
|
2153
|
+
const { frames } = await this.boardFrames(url, fork);
|
|
2154
|
+
const first = worldBrowsers(this.up().dirs.data)[0]?.name;
|
|
2155
|
+
const keyOf = (frame: (typeof frames)[number]['frame']): string => boardFrameKey({ vendor: frame.vendor, id: frame.id, ...(typeof frame.browser === 'string' ? { browser: frame.browser } : {}) });
|
|
2156
|
+
const found = frames.find(({ frame }) => keyOf(frame) === key)
|
|
2157
|
+
?? frames.find(({ frame }) => frame.browser === first && `${frame.vendor}:${frame.id}` === key);
|
|
2034
2158
|
if (!found) return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
|
|
2035
2159
|
const acts = new Map<string, { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> }>();
|
|
2036
2160
|
for (const c of found.mine) {
|
|
@@ -2046,17 +2170,25 @@ export class WorldDoors {
|
|
|
2046
2170
|
}
|
|
2047
2171
|
/** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
|
|
2048
2172
|
private async boardFrames(url: URL, fork = false): Promise<{ delta: ReturnType<typeof diffWorld>; frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: Array<ReturnType<typeof diffWorld>['actions'][number]> }> }> {
|
|
2049
|
-
const
|
|
2173
|
+
const listedHosts = await this.claimedHosts(url.origin);
|
|
2174
|
+
const claimed = new Map(listedHosts.map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
2050
2175
|
const sitesAt = this.host.sitesTemplate?.() ?? null;
|
|
2176
|
+
const browsers: Array<string | undefined> = worldBrowsers(this.up().dirs.data).map(b => b.name);
|
|
2177
|
+
if (!browsers.length) browsers.push(undefined);
|
|
2178
|
+
const browserHosts = browsers[0] ? new Map((await this.browserSites(url.origin, listedHosts)).map(({ vendor, hosts }) => [vendor, new Set(hosts)])) : claimed;
|
|
2051
2179
|
// a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
|
|
2052
2180
|
// which hands its address a one-time read pass (sites/go); one on this machine opens at its address
|
|
2053
|
-
const siteAt = (hostname: string, rest: string): string | null => {
|
|
2181
|
+
const siteAt = (hostname: string, rest: string, browser?: string): string | null => {
|
|
2054
2182
|
if (!sitesAt) return null;
|
|
2055
|
-
const direct = siteUrlOf(sitesAt, hostname);
|
|
2183
|
+
const direct = siteUrlOf(sitesAt, hostname, browser);
|
|
2056
2184
|
if (!direct) return null;
|
|
2057
|
-
if (
|
|
2185
|
+
if (browser && [...claimed.values()].some(hosts => [...hosts].some(site => {
|
|
2186
|
+
const claimedAt = siteUrlOf(sitesAt, site);
|
|
2187
|
+
return claimedAt && new URL(claimedAt).host === new URL(direct).host;
|
|
2188
|
+
}))) return null;
|
|
2189
|
+
if (!browser && sitesAt.includes('{host}')) return `${direct}${rest}`;
|
|
2058
2190
|
const own = this.host.browserOrigin?.() ?? null;
|
|
2059
|
-
return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
|
|
2191
|
+
return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, ...(browser ? { browser } : {}), land: rest }).toString()}` : null;
|
|
2060
2192
|
};
|
|
2061
2193
|
// inherited history is the parent log: the branch's own ledger starts at its fork, even after a cut
|
|
2062
2194
|
const delta = diffWorld(this.name, { root: this.worldRoot, ...(fork ? { base: 'world-boot' } : {}) });
|
|
@@ -2072,25 +2204,50 @@ export class WorldDoors {
|
|
|
2072
2204
|
} catch { listed = []; }
|
|
2073
2205
|
// a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
|
|
2074
2206
|
// one), whose changes are all of the vendor's
|
|
2075
|
-
|
|
2207
|
+
const fallback = !listed.length;
|
|
2208
|
+
let homeHost: string | undefined;
|
|
2209
|
+
if (fallback) {
|
|
2076
2210
|
const screens = await this.screensOf(vendor);
|
|
2077
2211
|
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
2078
|
-
|
|
2212
|
+
homeHost = home?.host;
|
|
2213
|
+
if (home) listed = [{ id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, covers: [], drawn: true }];
|
|
2079
2214
|
}
|
|
2080
2215
|
for (const f of listed) {
|
|
2216
|
+
const covered = new Set(f.covers.map(c => `${c.type}:${c.id}`));
|
|
2217
|
+
const mine = changes.filter(x => x.service === vendor && (fallback || covered.has(`${x.action.subject.type}:${x.action.subject.id}`)));
|
|
2218
|
+
const add = (at: { site: string } | { screen: string } | null, browser?: string, unseen?: string): void => {
|
|
2219
|
+
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 });
|
|
2220
|
+
};
|
|
2221
|
+
let plain = false;
|
|
2222
|
+
const openedBrowsers: Array<{ browser: string; at: string }> = [];
|
|
2223
|
+
for (const browser of browsers) {
|
|
2224
|
+
if (!browser) { plain = true; continue; }
|
|
2225
|
+
let opened: string | null = null;
|
|
2226
|
+
try {
|
|
2227
|
+
const u = new URL(f.url, homeHost ? `https://${homeHost}` : undefined);
|
|
2228
|
+
if (browserHosts.get(vendor)?.has(u.hostname.toLowerCase())) opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`, browser);
|
|
2229
|
+
} catch { /* this page has no site address */ }
|
|
2230
|
+
if (opened) openedBrowsers.push({ browser, at: opened });
|
|
2231
|
+
else plain = true;
|
|
2232
|
+
}
|
|
2233
|
+
for (const opened of openedBrowsers) add({ site: opened.at }, opened.browser);
|
|
2234
|
+
if (!plain) continue;
|
|
2081
2235
|
let at: { site: string } | { screen: string } | null = null;
|
|
2082
|
-
// why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
|
|
2083
2236
|
let unseen: 'not-drawn' | 'sites-not-routed' | undefined;
|
|
2084
2237
|
try {
|
|
2085
2238
|
const u = new URL(f.url);
|
|
2086
|
-
if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) {
|
|
2239
|
+
if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) {
|
|
2240
|
+
const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`);
|
|
2241
|
+
if (opened) at = { site: opened };
|
|
2242
|
+
}
|
|
2087
2243
|
else if (f.drawn) at = { screen: `${u.pathname}${u.search}` };
|
|
2088
2244
|
if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
2089
|
-
} catch { at =
|
|
2090
|
-
|
|
2091
|
-
|
|
2245
|
+
} catch { if (fallback) at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` }; else unseen = 'not-drawn'; }
|
|
2246
|
+
if (fallback) at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` };
|
|
2247
|
+
add(at, undefined, unseen);
|
|
2092
2248
|
}
|
|
2093
2249
|
}
|
|
2250
|
+
if (browsers[0]) frames.sort((a, b) => String(a.frame.browser ?? '').localeCompare(String(b.frame.browser ?? '')));
|
|
2094
2251
|
return { delta, frames };
|
|
2095
2252
|
}
|
|
2096
2253
|
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|