@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, 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 { BROWSER_SESSION_HEADER, BROWSER_SESSION_PATH, stripBrowserSessionPermit, withBrowserSessionPermit, type DerivedManifest } from '@volter/world-core/runtime';
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; viewAs?: VendorSessionPass }>();
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'>, viewAs?: VendorSessionPass): string | null {
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, ...(viewAs ? { viewAs } : {}) });
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 || !template.includes('{site}')) return false;
511
- const after = new URL(template.replace('{site}', 'x')).host.slice(1);
512
- return url.host.endsWith(after) && url.host.length > after.length;
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?.() ?? null;
517
- if (!template || !template.includes('{site}')) 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) return null;
520
- const label = url.host.slice(0, -after.length);
521
- for (const { hosts } of await this.claimedHosts(url.origin)) for (const h of hosts) if (siteLabel(h) === label) return h.toLowerCase();
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 viewPath = url.pathname.startsWith(`/${this.served}/`) ? url.pathname.slice(`/${this.served}/`.length).split('/') : [];
549
- const viewVendor = viewPath.length === 4 && viewPath[1] === '_twin' && viewPath[2] === 'view-as' && Object.hasOwn(this.up().services, viewPath[0]!) ? viewPath[0]! : null;
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
- if (opened.viewAs) { tell({ viewed: opened.viewAs }); location.replace(opened.viewAs.land); }
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.'; tell({ error: document.body.textContent }); });
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, viewAs, ...grant } = given;
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
- 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' } });
632
+ const opening = browser ? "Open this site from the World's board." : `<a href="${back!.replace(/&/g, '&amp;')}">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
- const answer = await this.site(request, site);
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 = stripBrowserSessionPermit(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
- for (const { vendor, hosts } of await this.claimedHosts(url.origin)) {
1058
- const twin = this.host.twinFetch(vendor);
1059
- if (!twin || !hosts.includes(host)) continue;
1060
- const headers = new Headers();
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
- const answer = await twin(new Request(`${url.origin}${url.pathname}${url.search}`, { method: request.method, headers, redirect: 'manual' }));
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 = stripBrowserSessionPermit(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, BROWSER_SESSION_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
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 = dest === 'iframe' && fetchSite === 'same-origin';
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
- const claimed = (await this.claimedHosts(url.origin)).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
1404
- const at = template && claimed ? siteUrlOf(template, hostname) : null;
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 found = (await this.boardFrames(url, fork)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
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 claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
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 (sitesAt.includes('{host}')) return `${direct}${rest}`;
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
- if (!listed.length) {
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
- if (home) frames.push({ frame: { vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` } }, mine: changes.filter((c) => c.service === vendor) });
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())) { const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`); at = opened ? { site: opened } : null; }
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 = null; unseen = 'not-drawn'; }
2090
- const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
2091
- 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}`)) });
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. */