@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.
@@ -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, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
35
- import { BROWSER_SESSION_HEADER, BROWSER_SESSION_PATH, stripBrowserSessionPermit, withBrowserSessionPermit } from '@volter/world-core/runtime';
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, viewAs) {
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, ...(viewAs ? { viewAs } : {}) });
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 || !template.includes('{site}'))
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 after = new URL(template.replace('{site}', 'x')).host.slice(1);
510
- return url.host.endsWith(after) && url.host.length > after.length;
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?.() ?? null;
516
- if (!template || !template.includes('{site}'))
517
- return null;
518
- const after = new URL(template.replace('{site}', 'x')).host.slice(1);
519
- if (!url.host.endsWith(after) || url.host.length <= after.length)
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 label = url.host.slice(0, -after.length);
522
- for (const { hosts } of await this.claimedHosts(url.origin))
523
- for (const h of hosts)
524
- if (siteLabel(h) === label)
525
- return h.toLowerCase();
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 viewPath = url.pathname.startsWith(`/${this.served}/`) ? url.pathname.slice(`/${this.served}/`.length).split('/') : [];
557
- const viewVendor = viewPath.length === 4 && viewPath[1] === '_twin' && viewPath[2] === 'view-as' && Object.hasOwn(this.up().services, viewPath[0]) ? viewPath[0] : null;
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
- if (opened.viewAs) { tell({ viewed: opened.viewAs }); location.replace(opened.viewAs.land); }
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.'; tell({ error: document.body.textContent }); });
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, viewAs, ...grant } = given;
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
- 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><a href="${back.replace(/&/g, '&amp;')}">Open it from the World</a></p></body>`, { status: 401, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
644
+ const opening = browser ? "Open this site from the World's board." : `<a href="${back.replace(/&/g, '&amp;')}">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
- const answer = await this.site(request, site);
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 = stripBrowserSessionPermit(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
- for (const { vendor, hosts } of await this.claimedHosts(url.origin)) {
1179
- const twin = this.host.twinFetch(vendor);
1180
- if (!twin || !hosts.includes(host))
1181
- continue;
1182
- const headers = new Headers();
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
- headers.set(READ_ONLY_HEADER, '1');
1190
- const answer = await twin(new Request(`${url.origin}${url.pathname}${url.search}`, { method: request.method, headers, redirect: 'manual' }));
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 = stripBrowserSessionPermit(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, BROWSER_SESSION_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
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 = dest === 'iframe' && fetchSite === 'same-origin';
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
- const claimed = (await this.claimedHosts(url.origin)).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
1612
- const at = template && claimed ? siteUrlOf(template, hostname) : null;
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 found = (await this.boardFrames(url, fork)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
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 claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
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 (sitesAt.includes('{host}'))
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
- if (!listed.length) {
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
- frames.push({ frame: { vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` } }, mine: changes.filter((c) => c.service === vendor) });
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
- at = opened ? { site: opened } : null;
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
- at = null;
2492
- unseen = 'not-drawn';
2703
+ if (fallback)
2704
+ at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` };
2705
+ else
2706
+ unseen = 'not-drawn';
2493
2707
  }
2494
- const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
2495
- frames.push({ frame: { vendor, 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: changes.filter((x) => x.service === vendor && covered.has(`${x.action.subject.type}:${x.action.subject.id}`)) });
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. */