@volter/world-runtime 3.0.17 → 3.0.18

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.
@@ -75,17 +75,19 @@ type PagesGrant = {
75
75
  session?: string;
76
76
  who?: string;
77
77
  framer?: string;
78
- local?: true; /** a parent World's session that asked
79
- * for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
80
- * cannot tell when it ends, so such a session lasts an hour */
78
+ local?: true; /** the hosted site it
79
+ * opens (a hostname this World's twins answer), not the content plane */
80
+ site?: string; /** a parent World's session that asked
81
+ * for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
82
+ * cannot tell when it ends, so such a session lasts an hour */
81
83
  asker?: string;
82
84
  };
83
85
  export declare function mountWorld(name: string, opts?: {
84
86
  passIssuers?: TrustedIssuer[];
85
87
  root?: string; /** the host's branches of this World, by its served name */
86
88
  branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
87
- browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */
88
- sitesOrigin?: (served: string) => string | null; /** DoorHost.pagesOrigin */
89
+ browserOrigin?: (served: string) => string | null; /** DoorHost.sitesTemplate */
90
+ sitesTemplate?: (served: string) => string | null; /** DoorHost.pagesOrigin */
89
91
  pagesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
90
92
  localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
91
93
  localPerson?: string; /** DoorHost.serveBranch */
@@ -145,9 +147,12 @@ export type DoorHost = {
145
147
  * from the origin of its doors and its session (docs/contributing/architecture.md, "Viewing a World"): a twin's page
146
148
  * runs there with a session that uses the twins and opens no door. Null, or absent, where Worlds share one origin. */
147
149
  pagesOrigin?: () => string | null;
148
- /** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
149
- * (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
150
- sitesOrigin?: () => string | null;
150
+ /** Where this host shows the hostnames the World's twins answer: a sites template (world-core siteUrlOf), `{host}`
151
+ * nested under the World's origin on this machine (siteOfHost), `{site}` as one label beside it on a hosted World
152
+ * (each such site private to the World's people); none where it routes no sites (behind a proxy). */
153
+ sitesTemplate?: () => string | null;
154
+ /** Another World's (a branch of this one) sites template, where this host routes its sites. */
155
+ sitesTemplateOf?: (served: string) => string | null;
151
156
  /** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
152
157
  originOf?: (served: string) => string | null;
153
158
  /** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
@@ -244,8 +249,18 @@ export declare class WorldDoors {
244
249
  /** The address that opens this World's content plane for a minute's one-time pass and lands on `land` (a path of this
245
250
  * World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
246
251
  pagesPass(scope: 'read' | 'write', land: string, grant?: Omit<PagesGrant, 'scope'>): string | null;
252
+ /** A one-time pass (a minute long) for the content plane or a hosted site; null when its asker holds fifty unused. */
253
+ private mintPagesPass;
254
+ /** The hostname a hosted site address names (`{site}` template: `<label>--<world>--<org>.<suffix>`) among those this
255
+ * World's twins claim; null for any other address. */
256
+ /** Whether an address has a hosted site's shape here (`<label>--<world>--<org>.<suffix>`), claimed or not. */
257
+ private siteShaped;
258
+ private hostedSiteOf;
247
259
  private pagesCookie;
248
260
  private pageSession;
261
+ /** A twin's cookies, kept to the origin answering (the content plane, a hosted site): partitioned as its frames need,
262
+ * never set for a parent domain every World shares, and never the World's own session cookies. */
263
+ private keepCookiesHere;
249
264
  /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
250
265
  private pagesPlane;
251
266
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
29
- import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
29
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
30
30
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { packFacts } from "./pack-facts.js";
32
32
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
@@ -188,7 +188,7 @@ export async function mountWorld(name, opts = {}) {
188
188
  clockPath: () => clockFile(worldRoot, name),
189
189
  ...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
190
190
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
191
- ...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin(served) } : {}),
191
+ ...(opts.sitesTemplate ? { sitesTemplate: () => opts.sitesTemplate(served), sitesTemplateOf: (other) => opts.sitesTemplate(other) } : {}),
192
192
  ...(opts.pagesOrigin ? { pagesOrigin: () => opts.pagesOrigin(served) } : {}),
193
193
  ...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
194
194
  ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
@@ -473,6 +473,11 @@ export class WorldDoors {
473
473
  const at = this.host.pagesOrigin?.() ?? null;
474
474
  if (at === null)
475
475
  return null;
476
+ const pass = this.mintPagesPass(scope, grant);
477
+ return pass ? `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}` : null;
478
+ }
479
+ /** A one-time pass (a minute long) for the content plane or a hosted site; null when its asker holds fifty unused. */
480
+ mintPagesPass(scope, grant) {
476
481
  const now = Date.now();
477
482
  for (const [k, v] of this.pagePasses)
478
483
  if (v.until <= now)
@@ -483,19 +488,61 @@ export class WorldDoors {
483
488
  return null;
484
489
  const pass = mintToken('pass_');
485
490
  this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
486
- return `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}`;
491
+ return pass;
492
+ }
493
+ /** The hostname a hosted site address names (`{site}` template: `<label>--<world>--<org>.<suffix>`) among those this
494
+ * World's twins claim; null for any other address. */
495
+ /** Whether an address has a hosted site's shape here (`<label>--<world>--<org>.<suffix>`), claimed or not. */
496
+ siteShaped(url) {
497
+ const template = this.host.sitesTemplate?.() ?? null;
498
+ if (!template || !template.includes('{site}'))
499
+ return false;
500
+ const after = new URL(template.replace('{site}', 'x')).host.slice(1);
501
+ return url.host.endsWith(after) && url.host.length > after.length;
502
+ }
503
+ async hostedSiteOf(url) {
504
+ if (!this.siteShaped(url))
505
+ return null;
506
+ const template = this.host.sitesTemplate?.() ?? null;
507
+ if (!template || !template.includes('{site}'))
508
+ return null;
509
+ const after = new URL(template.replace('{site}', 'x')).host.slice(1);
510
+ if (!url.host.endsWith(after) || url.host.length <= after.length)
511
+ return null;
512
+ const label = url.host.slice(0, -after.length);
513
+ for (const { hosts } of await this.claimedHosts(url.origin))
514
+ for (const h of hosts)
515
+ if (siteLabel(h) === label)
516
+ return h.toLowerCase();
517
+ return null;
487
518
  }
488
519
  pagesCookie(url) { return url.protocol === 'https:' ? '__Host-volter_pages' : 'volter_pages'; }
489
- pageSession(request, url) {
520
+ pageSession(request, url, site) {
490
521
  const id = new RegExp(`(?:^|;\\s*)${this.pagesCookie(url)}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
491
522
  const held = id && Object.hasOwn(this.pagesSessions(), id) ? this.pagesSessions()[id] : undefined;
492
523
  if (!id || !held || held.until <= Date.now() || !this.pagesParentLive(held))
493
524
  return null;
525
+ // a session opens only where its pass was for: the content plane, or that one hosted site
526
+ if ((held.site ?? null) !== (site ?? null))
527
+ return null;
494
528
  const { until: _until, ...grant } = held;
495
529
  return { id, ...grant };
496
530
  }
531
+ /** A twin's cookies, kept to the origin answering (the content plane, a hosted site): partitioned as its frames need,
532
+ * never set for a parent domain every World shares, and never the World's own session cookies. */
533
+ keepCookiesHere(answer, out) {
534
+ const cookies = answer.headers.getSetCookie();
535
+ if (!cookies.length)
536
+ return;
537
+ out.delete('set-cookie');
538
+ for (const c of cookies) {
539
+ if (/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))
540
+ continue;
541
+ out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned|domain=[^;]*)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
542
+ }
543
+ }
497
544
  /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
498
- async pagesPlane(request, url) {
545
+ async pagesPlane(request, url, /** a hosted site served here, not the content plane */ site) {
499
546
  const doors = `/-/${this.served}/pages/`;
500
547
  const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
501
548
  const own = this.host.browserOrigin?.() ?? null;
@@ -503,7 +550,7 @@ export class WorldDoors {
503
550
  // framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
504
551
  // framed only by the console that asked
505
552
  const consoles = [own, this.parentOrigin()].filter((x) => Boolean(x)).join(' ') || "'none'";
506
- return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="${doors}open.js"></script></body>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': `default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors ${consoles}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
553
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"${site ? ' data-site="1"' : ''}><script src="${doors}open.js"></script></body>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': `default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors ${consoles}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
507
554
  }
508
555
  if (door === 'open.js' && request.method === 'GET') {
509
556
  const script = `(() => {
@@ -514,7 +561,9 @@ export class WorldDoors {
514
561
  const land = carried.get('land') || '';
515
562
  // resolved as the browser will (encoded dots too), on this origin and under this World
516
563
  let to = null;
517
- try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
564
+ // a hosted site lands anywhere on it; the content plane only under this World
565
+ const anywhere = document.body.dataset.site === '1';
566
+ try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && (anywhere || u.pathname.startsWith('/' + world + '/'))) to = u.pathname + u.search; } catch {}
518
567
  if (!pass || !to) { document.body.textContent = 'Nothing to open.'; return; }
519
568
  fetch('/-/' + world + '/pages/session', { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
520
569
  .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
@@ -530,11 +579,11 @@ export class WorldDoors {
530
579
  const pass = request.headers.get('x-volter-pages-pass') ?? '';
531
580
  const given = this.pagePasses.get(pass);
532
581
  this.pagePasses.delete(pass);
533
- if (!given || given.until <= Date.now() || !this.pagesParentLive(given))
582
+ if (!given || given.until <= Date.now() || !this.pagesParentLive(given) || (given.site ?? null) !== (site ?? null))
534
583
  return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
535
584
  const { until: _until, ...grant } = given;
536
585
  // one content session per asker at a scope and framer: a board's frames opening together share it
537
- const same = (x) => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who;
586
+ 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;
538
587
  const now = Date.now();
539
588
  const found = Object.entries(this.pagesSessions()).find(([, x]) => x.until > now && same(x))?.[0];
540
589
  const id = found ?? mintToken('pgs_');
@@ -542,6 +591,29 @@ export class WorldDoors {
542
591
  this.keepPagesSessions({ ...this.pagesSessions(), [id]: { ...grant, until: now + (grant.session !== undefined || grant.asker === undefined ? 12 : 1) * 3_600_000 } });
543
592
  return Response.json({ world: this.served, scope: grant.scope }, { headers: { 'set-cookie': `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned` } });
544
593
  }
594
+ // A HOSTED SITE: read with a session its World's own origin gave it a pass for (sites/go), never without
595
+ if (site) {
596
+ const held = this.pageSession(request, url, site);
597
+ const back = own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: site, land: `${url.pathname}${url.search}` }).toString()}` : null;
598
+ if (!held) {
599
+ if (request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html') && back) {
600
+ const shown = site.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
601
+ 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' } });
602
+ }
603
+ return Response.json({ error: `${site} is shown to ${this.served}'s people: open it from the World` }, { status: 401 });
604
+ }
605
+ // read by the site's own pages, or a person's navigation to it: never by another page of the same domain (the
606
+ // session's partition is the domain's, which every World's pages share)
607
+ const fetchSite = request.headers.get('sec-fetch-site');
608
+ const navigation = ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
609
+ if (fetchSite !== 'same-origin' && !(navigation && ['none', 'same-site'].includes(fetchSite ?? '')))
610
+ return Response.json({ error: `${site} is read by its own pages` }, { status: 403 });
611
+ const answer = await this.site(request, site);
612
+ const out = new Headers(answer.headers);
613
+ out.append('content-security-policy', `frame-ancestors 'self'${held.framer ? ` ${held.framer}` : ''}`);
614
+ this.keepCookiesHere(answer, out);
615
+ return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
616
+ }
545
617
  if (url.pathname.startsWith(`/${this.served}/`) && !url.pathname.startsWith(`/${this.served}/.well-known/`)) {
546
618
  const answer = await this.wire(request, url, url.pathname.slice(`/${this.served}/`.length));
547
619
  if (answer.status === 101)
@@ -552,12 +624,7 @@ export class WorldDoors {
552
624
  out.append('content-security-policy', `frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
553
625
  // a twin's own sign-in kept in the frame a console shows it in, which may be another site (each *.localhost is):
554
626
  // its cookies are partitioned by that console, as this plane's own session is
555
- const cookies = answer.headers.getSetCookie();
556
- if (cookies.length) {
557
- out.delete('set-cookie');
558
- for (const c of cookies)
559
- out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
560
- }
627
+ this.keepCookiesHere(answer, out);
561
628
  return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
562
629
  }
563
630
  // a navigation elsewhere (a twin sending the person to its root, `/`) goes to the World's own origin, as it always did
@@ -1021,6 +1088,11 @@ export class WorldDoors {
1021
1088
  return Response.json({ ok: true, world: this.served, worlds: [this.served] });
1022
1089
  if (this.onPages(url))
1023
1090
  return await this.pagesPlane(request, url);
1091
+ const hostedSite = await this.hostedSiteOf(url);
1092
+ if (hostedSite)
1093
+ return await this.pagesPlane(request, url, hostedSite);
1094
+ if (this.siteShaped(url))
1095
+ return Response.json({ error: `no site of ${this.served} at ${url.host}` }, { status: 404 });
1024
1096
  if (url.pathname.startsWith(`/-/${this.served}/`))
1025
1097
  return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
1026
1098
  // the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
@@ -1184,10 +1256,9 @@ export class WorldDoors {
1184
1256
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1185
1257
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
1186
1258
  // origin on this machine (siteOfHost); a World without one shows none
1187
- const at = this.host.sitesOrigin?.() ?? null;
1188
- const sites = at ? new URL(at) : null;
1189
- if (sites)
1190
- headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`);
1259
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
1260
+ if (sitesAt)
1261
+ headers.set(TWIN_SITES_HEADER, sitesAt);
1191
1262
  else
1192
1263
  headers.delete(TWIN_SITES_HEADER);
1193
1264
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
@@ -1391,6 +1462,31 @@ export class WorldDoors {
1391
1462
  // a browser session is opened by either token: the read token's is how a view-only link opens a vendor's screens
1392
1463
  // the session gate comes first: a page that holds only the cookie (a sibling World's, the same site) can neither
1393
1464
  // re-issue nor end the session through this door
1465
+ // A HOSTED SITE OPENED (`GET sites/go?host=<hostname>&land=<path>`): a person's navigation, handed to the site's own
1466
+ // address with a one-time read pass. A navigation from a sibling address (the site's own page, linking back here) is
1467
+ // taken too: it only opens, in this person's own browser, a read of this World's own site
1468
+ if (kind === 'sites' && name === 'go' && request.method === 'GET') {
1469
+ const fetchSite = request.headers.get('sec-fetch-site');
1470
+ const dest = request.headers.get('sec-fetch-dest');
1471
+ // a person's navigation (from here, from the site's own page linking back, or typed in), or a frame of this World's
1472
+ // own console (its board, its comparison)
1473
+ const navigated = dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
1474
+ const framed = dest === 'iframe' && fetchSite === 'same-origin';
1475
+ if (via === 'session' && !navigated && !framed)
1476
+ return Response.json({ error: "a hosted site opens on a person's navigation" }, { status: 403 });
1477
+ const template = this.host.sitesTemplate?.() ?? null;
1478
+ const hostname = (url.searchParams.get('host') ?? '').toLowerCase();
1479
+ const claimed = (await this.claimedHosts(url.origin)).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
1480
+ const at = template && claimed ? siteUrlOf(template, hostname) : null;
1481
+ if (!at)
1482
+ return Response.json({ error: `${hostname || 'that host'} is no site of ${this.served} shown here` }, { status: 404 });
1483
+ const asked = this.credential(request);
1484
+ const land = url.searchParams.get('land') ?? '/';
1485
+ const passed = this.mintPagesPass('read', { site: hostname, ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) });
1486
+ if (!passed)
1487
+ return Response.json({ error: 'too many sites opened at once: try again in a minute' }, { status: 429 });
1488
+ 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' } });
1489
+ }
1394
1490
  const foreign = this.crossOriginSession(request, via);
1395
1491
  if (foreign)
1396
1492
  return foreign;
@@ -1984,7 +2080,7 @@ export class WorldDoors {
1984
2080
  }
1985
2081
  catch { /* a World with no local branches */ }
1986
2082
  // each with the origin a browser reaches it at, where this host gives Worlds one (its sites are under it)
1987
- const rows = (await branches.list()).map((b) => { const origin = this.host.originOf?.(b.name) ?? null; return origin ? { ...b, origin } : b; });
2083
+ const rows = (await branches.list()).map((b) => { const origin = this.host.originOf?.(b.name) ?? null; const sites = this.host.sitesTemplateOf?.(b.name) ?? null; return { ...b, ...(origin ? { origin } : {}), ...(sites ? { sites } : {}) }; });
1988
2084
  return Response.json({ world: this.served, branches: rows, checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1989
2085
  }
1990
2086
  // A BRANCH'S PAGES (`GET branches/<org>/<branch>/pages?land=<path>`): a navigation to the branch's own content plane
@@ -2068,11 +2164,13 @@ export class WorldDoors {
2068
2164
  // session removes it, so no write token ever reaches a page's script (or a frame beside it)
2069
2165
  // `origin`: where the branch's own pages are, when it has an origin of its own (a page moves there to view it)
2070
2166
  const origin = this.host.originOf?.(made.name) ?? null;
2167
+ const sites = this.host.sitesTemplateOf?.(made.name) ?? null;
2168
+ const where = { ...(origin ? { origin } : {}), ...(sites ? { sites } : {}) };
2071
2169
  if (via === 'session') {
2072
2170
  const { token: _write, ...viewed } = made;
2073
- return Response.json({ ...viewed, ...(origin ? { origin } : {}) }, { status: 201 });
2171
+ return Response.json({ ...viewed, ...where }, { status: 201 });
2074
2172
  }
2075
- return Response.json({ ...made, ...(origin ? { origin } : {}) }, { status: 201 });
2173
+ return Response.json({ ...made, ...where }, { status: 201 });
2076
2174
  }
2077
2175
  catch (error) {
2078
2176
  this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
@@ -2136,8 +2234,20 @@ export class WorldDoors {
2136
2234
  /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
2137
2235
  async boardFrames(url) {
2138
2236
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
2139
- const routed = this.host.sitesOrigin?.() ?? null;
2140
- const sites = routed ? new URL(routed) : null;
2237
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
2238
+ // a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
2239
+ // which hands its address a one-time read pass (sites/go); one on this machine opens at its address
2240
+ const siteAt = (hostname, rest) => {
2241
+ if (!sitesAt)
2242
+ return null;
2243
+ const direct = siteUrlOf(sitesAt, hostname);
2244
+ if (!direct)
2245
+ return null;
2246
+ if (sitesAt.includes('{host}'))
2247
+ return `${direct}${rest}`;
2248
+ const own = this.host.browserOrigin?.() ?? null;
2249
+ return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
2250
+ };
2141
2251
  const delta = diffWorld(this.name, { root: this.worldRoot });
2142
2252
  const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
2143
2253
  const changes = delta.actions.filter((a) => !received.has(a.action.id));
@@ -2166,8 +2276,10 @@ export class WorldDoors {
2166
2276
  let unseen;
2167
2277
  try {
2168
2278
  const u = new URL(f.url);
2169
- if (claimed.get(vendor)?.has(u.hostname.toLowerCase()))
2170
- at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
2279
+ if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) {
2280
+ const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`);
2281
+ at = opened ? { site: opened } : null;
2282
+ }
2171
2283
  else if (f.drawn)
2172
2284
  at = { screen: `${u.pathname}${u.search}` };
2173
2285
  if (!at)
@@ -49,10 +49,11 @@ export async function serveWorldView(name, opts = {}) {
49
49
  let base = null;
50
50
  const browserOrigin = origins ? (served) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
51
51
  // sites are routed at each World's own *.localhost origin, never behind the proxy
52
- const sitesOrigin = origins && !proxied ? (served) => (port ? localWorldOrigin(served, port) : null) : undefined;
52
+ // each site nested under its World's origin (`<hostname>.<world>--<org>.localhost`, siteOfHost)
53
+ const sitesTemplate = origins && !proxied ? (served) => { const o = port ? localWorldOrigin(served, port) : null; return o ? `http://{host}.${new URL(o).host}` : null; } : undefined;
53
54
  // the content plane beside each World's origin (`<world>--<org>--pages.localhost`): its twins' pages, apart from its doors
54
55
  const pagesOrigin = origins && !proxied ? (served) => (port ? pagesOriginOf(served, 'http:', 'localhost', port) : null) : undefined;
55
- const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
56
+ const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesTemplate ? { sitesTemplate } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
56
57
  const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
57
58
  // THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
58
59
  // same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
@@ -94,7 +95,7 @@ export async function serveWorldView(name, opts = {}) {
94
95
  switching = false;
95
96
  }
96
97
  };
97
- const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
98
+ const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesTemplate ? { sitesTemplate } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
98
99
  let world = await mountWorld(name, mountOpts);
99
100
  base = world.served;
100
101
  worlds.set(world.served, world);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.17",
3
+ "version": "3.0.18",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -68,14 +68,14 @@
68
68
  "node": ">=22.3"
69
69
  },
70
70
  "peerDependencies": {
71
- "@volter/world-core": "3.0.17",
72
- "@volter/world-console": "3.0.12"
71
+ "@volter/world-core": "3.0.18",
72
+ "@volter/world-console": "3.0.13"
73
73
  },
74
74
  "dependencies": {
75
75
  "@electric-sql/pglite": "0.5.8",
76
76
  "@electric-sql/pglite-pgvector": "0.0.9",
77
77
  "@volter/world-access": "3.0.1",
78
- "@volter/world-core": "3.0.17",
78
+ "@volter/world-core": "3.0.18",
79
79
  "pg-gateway": "0.3.0-beta.4",
80
80
  "smol-toml": "^1.8.0",
81
81
  "yaml": "2.9.0"
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
29
- import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, ORIGINAL_PATH_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
29
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
30
30
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
31
31
  import { packFacts } from './pack-facts.ts';
32
32
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
@@ -164,12 +164,13 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
164
164
  /** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
165
165
  * session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
166
166
  * is this machine's own page (attributed to the machine). */
167
- type PagesGrant = { scope: 'read' | 'write'; session?: string; who?: string; framer?: string; local?: true; /** a parent World's session that asked
167
+ type PagesGrant = { scope: 'read' | 'write'; session?: string; who?: string; framer?: string; local?: true; /** the hosted site it
168
+ * opens (a hostname this World's twins answer), not the content plane */ site?: string; /** a parent World's session that asked
168
169
  * for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
169
170
  * cannot tell when it ends, so such a session lasts an hour */ asker?: string };
170
171
  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 };
171
172
 
172
- export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */ sitesOrigin?: (served: string) => string | null; /** DoorHost.pagesOrigin */ pagesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
173
+ export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** DoorHost.sitesTemplate */ sitesTemplate?: (served: string) => string | null; /** DoorHost.pagesOrigin */ pagesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
173
174
  const worldRoot = resolve(opts.root ?? process.cwd());
174
175
  const configRef = configRefFor(worldRoot, name);
175
176
  if (loadWorldConfig(configRef, worldRoot).config.resources) {
@@ -197,7 +198,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
197
198
  clockPath: () => clockFile(worldRoot, name),
198
199
  ...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
199
200
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
200
- ...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin!(served) } : {}),
201
+ ...(opts.sitesTemplate ? { sitesTemplate: () => opts.sitesTemplate!(served), sitesTemplateOf: (other: string) => opts.sitesTemplate!(other) } : {}),
201
202
  ...(opts.pagesOrigin ? { pagesOrigin: () => opts.pagesOrigin!(served) } : {}),
202
203
  ...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
203
204
  ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
@@ -355,9 +356,12 @@ export type DoorHost = {
355
356
  * from the origin of its doors and its session (docs/contributing/architecture.md, "Viewing a World"): a twin's page
356
357
  * runs there with a session that uses the twins and opens no door. Null, or absent, where Worlds share one origin. */
357
358
  pagesOrigin?: () => string | null;
358
- /** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
359
- * (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
360
- sitesOrigin?: () => string | null;
359
+ /** Where this host shows the hostnames the World's twins answer: a sites template (world-core siteUrlOf), `{host}`
360
+ * nested under the World's origin on this machine (siteOfHost), `{site}` as one label beside it on a hosted World
361
+ * (each such site private to the World's people); none where it routes no sites (behind a proxy). */
362
+ sitesTemplate?: () => string | null;
363
+ /** Another World's (a branch of this one) sites template, where this host routes its sites. */
364
+ sitesTemplateOf?: (served: string) => string | null;
361
365
  /** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
362
366
  originOf?: (served: string) => string | null;
363
367
  /** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
@@ -472,6 +476,11 @@ export class WorldDoors {
472
476
  pagesPass(scope: 'read' | 'write', land: string, grant: Omit<PagesGrant, 'scope'> = {}): string | null {
473
477
  const at = this.host.pagesOrigin?.() ?? null;
474
478
  if (at === null) return null;
479
+ const pass = this.mintPagesPass(scope, grant);
480
+ return pass ? `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}` : null;
481
+ }
482
+ /** A one-time pass (a minute long) for the content plane or a hosted site; null when its asker holds fifty unused. */
483
+ private mintPagesPass(scope: 'read' | 'write', grant: Omit<PagesGrant, 'scope'>): string | null {
475
484
  const now = Date.now();
476
485
  for (const [k, v] of this.pagePasses) if (v.until <= now) this.pagePasses.delete(k);
477
486
  const askerOf = (g: Omit<PagesGrant, 'scope'>): string => g.session ?? g.asker ?? g.framer ?? 'token';
@@ -479,18 +488,50 @@ export class WorldDoors {
479
488
  if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000) return null;
480
489
  const pass = mintToken('pass_');
481
490
  this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
482
- return `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}`;
491
+ return pass;
492
+ }
493
+ /** The hostname a hosted site address names (`{site}` template: `<label>--<world>--<org>.<suffix>`) among those this
494
+ * World's twins claim; null for any other address. */
495
+ /** Whether an address has a hosted site's shape here (`<label>--<world>--<org>.<suffix>`), claimed or not. */
496
+ private siteShaped(url: URL): boolean {
497
+ const template = this.host.sitesTemplate?.() ?? null;
498
+ if (!template || !template.includes('{site}')) return false;
499
+ const after = new URL(template.replace('{site}', 'x')).host.slice(1);
500
+ return url.host.endsWith(after) && url.host.length > after.length;
501
+ }
502
+ private async hostedSiteOf(url: URL): Promise<string | null> {
503
+ if (!this.siteShaped(url)) return null;
504
+ const template = this.host.sitesTemplate?.() ?? null;
505
+ if (!template || !template.includes('{site}')) return null;
506
+ const after = new URL(template.replace('{site}', 'x')).host.slice(1);
507
+ if (!url.host.endsWith(after) || url.host.length <= after.length) return null;
508
+ const label = url.host.slice(0, -after.length);
509
+ for (const { hosts } of await this.claimedHosts(url.origin)) for (const h of hosts) if (siteLabel(h) === label) return h.toLowerCase();
510
+ return null;
483
511
  }
484
512
  private pagesCookie(url: URL): string { return url.protocol === 'https:' ? '__Host-volter_pages' : 'volter_pages'; }
485
- private pageSession(request: Request, url: URL): ({ id: string } & PagesGrant) | null {
513
+ private pageSession(request: Request, url: URL, site?: string): ({ id: string } & PagesGrant) | null {
486
514
  const id = new RegExp(`(?:^|;\\s*)${this.pagesCookie(url)}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
487
515
  const held = id && Object.hasOwn(this.pagesSessions(), id) ? this.pagesSessions()[id]! : undefined;
488
516
  if (!id || !held || held.until <= Date.now() || !this.pagesParentLive(held)) return null;
517
+ // a session opens only where its pass was for: the content plane, or that one hosted site
518
+ if ((held.site ?? null) !== (site ?? null)) return null;
489
519
  const { until: _until, ...grant } = held;
490
520
  return { id, ...grant };
491
521
  }
522
+ /** A twin's cookies, kept to the origin answering (the content plane, a hosted site): partitioned as its frames need,
523
+ * never set for a parent domain every World shares, and never the World's own session cookies. */
524
+ private keepCookiesHere(answer: Response, out: Headers): void {
525
+ const cookies = answer.headers.getSetCookie();
526
+ if (!cookies.length) return;
527
+ out.delete('set-cookie');
528
+ for (const c of cookies) {
529
+ if (/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c)) continue;
530
+ out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned|domain=[^;]*)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
531
+ }
532
+ }
492
533
  /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
493
- private async pagesPlane(request: Request, url: URL): Promise<Response> {
534
+ private async pagesPlane(request: Request, url: URL, /** a hosted site served here, not the content plane */ site?: string): Promise<Response> {
494
535
  const doors = `/-/${this.served}/pages/`;
495
536
  const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
496
537
  const own = this.host.browserOrigin?.() ?? null;
@@ -498,7 +539,7 @@ export class WorldDoors {
498
539
  // framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
499
540
  // framed only by the console that asked
500
541
  const consoles = [own, this.parentOrigin()].filter((x): x is string => Boolean(x)).join(' ') || "'none'";
501
- return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="${doors}open.js"></script></body>`,
542
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"${site ? ' data-site="1"' : ''}><script src="${doors}open.js"></script></body>`,
502
543
  { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': `default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors ${consoles}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
503
544
  }
504
545
  if (door === 'open.js' && request.method === 'GET') {
@@ -510,7 +551,9 @@ export class WorldDoors {
510
551
  const land = carried.get('land') || '';
511
552
  // resolved as the browser will (encoded dots too), on this origin and under this World
512
553
  let to = null;
513
- try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
554
+ // a hosted site lands anywhere on it; the content plane only under this World
555
+ const anywhere = document.body.dataset.site === '1';
556
+ try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && (anywhere || u.pathname.startsWith('/' + world + '/'))) to = u.pathname + u.search; } catch {}
514
557
  if (!pass || !to) { document.body.textContent = 'Nothing to open.'; return; }
515
558
  fetch('/-/' + world + '/pages/session', { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
516
559
  .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
@@ -525,10 +568,10 @@ export class WorldDoors {
525
568
  const pass = request.headers.get('x-volter-pages-pass') ?? '';
526
569
  const given = this.pagePasses.get(pass);
527
570
  this.pagePasses.delete(pass);
528
- if (!given || given.until <= Date.now() || !this.pagesParentLive(given)) return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
571
+ 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 });
529
572
  const { until: _until, ...grant } = given;
530
573
  // one content session per asker at a scope and framer: a board's frames opening together share it
531
- 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;
574
+ 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;
532
575
  const now = Date.now();
533
576
  const found = Object.entries(this.pagesSessions()).find(([, x]) => x.until > now && same(x))?.[0];
534
577
  const id = found ?? mintToken('pgs_');
@@ -536,6 +579,28 @@ export class WorldDoors {
536
579
  this.keepPagesSessions({ ...this.pagesSessions(), [id]: { ...grant, until: now + (grant.session !== undefined || grant.asker === undefined ? 12 : 1) * 3_600_000 } });
537
580
  return Response.json({ world: this.served, scope: grant.scope }, { headers: { 'set-cookie': `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned` } });
538
581
  }
582
+ // A HOSTED SITE: read with a session its World's own origin gave it a pass for (sites/go), never without
583
+ if (site) {
584
+ const held = this.pageSession(request, url, site);
585
+ const back = own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: site, land: `${url.pathname}${url.search}` }).toString()}` : null;
586
+ if (!held) {
587
+ if (request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html') && back) {
588
+ const shown = site.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
589
+ 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' } });
590
+ }
591
+ return Response.json({ error: `${site} is shown to ${this.served}'s people: open it from the World` }, { status: 401 });
592
+ }
593
+ // read by the site's own pages, or a person's navigation to it: never by another page of the same domain (the
594
+ // session's partition is the domain's, which every World's pages share)
595
+ const fetchSite = request.headers.get('sec-fetch-site');
596
+ const navigation = ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
597
+ if (fetchSite !== 'same-origin' && !(navigation && ['none', 'same-site'].includes(fetchSite ?? ''))) return Response.json({ error: `${site} is read by its own pages` }, { status: 403 });
598
+ const answer = await this.site(request, site);
599
+ const out = new Headers(answer.headers);
600
+ out.append('content-security-policy', `frame-ancestors 'self'${held.framer ? ` ${held.framer}` : ''}`);
601
+ this.keepCookiesHere(answer, out);
602
+ return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
603
+ }
539
604
  if (url.pathname.startsWith(`/${this.served}/`) && !url.pathname.startsWith(`/${this.served}/.well-known/`)) {
540
605
  const answer = await this.wire(request, url, url.pathname.slice(`/${this.served}/`.length));
541
606
  if (answer.status === 101) return answer;
@@ -545,11 +610,7 @@ export class WorldDoors {
545
610
  out.append('content-security-policy', `frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
546
611
  // a twin's own sign-in kept in the frame a console shows it in, which may be another site (each *.localhost is):
547
612
  // its cookies are partitioned by that console, as this plane's own session is
548
- const cookies = answer.headers.getSetCookie();
549
- if (cookies.length) {
550
- out.delete('set-cookie');
551
- for (const c of cookies) out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
552
- }
613
+ this.keepCookiesHere(answer, out);
553
614
  return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
554
615
  }
555
616
  // a navigation elsewhere (a twin sending the person to its root, `/`) goes to the World's own origin, as it always did
@@ -914,6 +975,9 @@ export class WorldDoors {
914
975
  const prefix = `/${this.served}/`;
915
976
  if (url.pathname === '/-/ping') return Response.json({ ok: true, world: this.served, worlds: [this.served] });
916
977
  if (this.onPages(url)) return await this.pagesPlane(request, url);
978
+ const hostedSite = await this.hostedSiteOf(url);
979
+ if (hostedSite) return await this.pagesPlane(request, url, hostedSite);
980
+ if (this.siteShaped(url)) return Response.json({ error: `no site of ${this.served} at ${url.host}` }, { status: 404 });
917
981
  if (url.pathname.startsWith(`/-/${this.served}/`)) return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
918
982
  // the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
919
983
  // place under this World, and the token the injector carries to them — the presented one
@@ -1048,9 +1112,8 @@ export class WorldDoors {
1048
1112
  headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1049
1113
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
1050
1114
  // origin on this machine (siteOfHost); a World without one shows none
1051
- const at = this.host.sitesOrigin?.() ?? null;
1052
- const sites = at ? new URL(at) : null;
1053
- if (sites) headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`); else headers.delete(TWIN_SITES_HEADER);
1115
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
1116
+ if (sitesAt) headers.set(TWIN_SITES_HEADER, sitesAt); else headers.delete(TWIN_SITES_HEADER);
1054
1117
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
1055
1118
  const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
1056
1119
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
@@ -1214,6 +1277,28 @@ export class WorldDoors {
1214
1277
  // a browser session is opened by either token: the read token's is how a view-only link opens a vendor's screens
1215
1278
  // the session gate comes first: a page that holds only the cookie (a sibling World's, the same site) can neither
1216
1279
  // re-issue nor end the session through this door
1280
+ // A HOSTED SITE OPENED (`GET sites/go?host=<hostname>&land=<path>`): a person's navigation, handed to the site's own
1281
+ // address with a one-time read pass. A navigation from a sibling address (the site's own page, linking back here) is
1282
+ // taken too: it only opens, in this person's own browser, a read of this World's own site
1283
+ if (kind === 'sites' && name === 'go' && request.method === 'GET') {
1284
+ const fetchSite = request.headers.get('sec-fetch-site');
1285
+ const dest = request.headers.get('sec-fetch-dest');
1286
+ // a person's navigation (from here, from the site's own page linking back, or typed in), or a frame of this World's
1287
+ // own console (its board, its comparison)
1288
+ const navigated = dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
1289
+ const framed = dest === 'iframe' && fetchSite === 'same-origin';
1290
+ if (via === 'session' && !navigated && !framed) return Response.json({ error: "a hosted site opens on a person's navigation" }, { status: 403 });
1291
+ const template = this.host.sitesTemplate?.() ?? null;
1292
+ const hostname = (url.searchParams.get('host') ?? '').toLowerCase();
1293
+ const claimed = (await this.claimedHosts(url.origin)).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
1294
+ const at = template && claimed ? siteUrlOf(template, hostname) : null;
1295
+ if (!at) return Response.json({ error: `${hostname || 'that host'} is no site of ${this.served} shown here` }, { status: 404 });
1296
+ const asked = this.credential(request);
1297
+ const land = url.searchParams.get('land') ?? '/';
1298
+ const passed = this.mintPagesPass('read', { site: hostname, ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin!()! } : {}) });
1299
+ if (!passed) return Response.json({ error: 'too many sites opened at once: try again in a minute' }, { status: 429 });
1300
+ 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' } });
1301
+ }
1217
1302
  const foreign = this.crossOriginSession(request, via);
1218
1303
  if (foreign) return foreign;
1219
1304
  // a named key is an app's: it opens no browser session (which would outlive the key and could make keys of its own),
@@ -1673,7 +1758,7 @@ export class WorldDoors {
1673
1758
  let named: Array<{ name: string; running: boolean }> = [];
1674
1759
  try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
1675
1760
  // each with the origin a browser reaches it at, where this host gives Worlds one (its sites are under it)
1676
- const rows = (await branches.list()).map((b) => { const origin = this.host.originOf?.(b.name) ?? null; return origin ? { ...b, origin } : b; });
1761
+ const rows = (await branches.list()).map((b) => { const origin = this.host.originOf?.(b.name) ?? null; const sites = this.host.sitesTemplateOf?.(b.name) ?? null; return { ...b, ...(origin ? { origin } : {}), ...(sites ? { sites } : {}) }; });
1677
1762
  return Response.json({ world: this.served, branches: rows, checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1678
1763
  }
1679
1764
  // A BRANCH'S PAGES (`GET branches/<org>/<branch>/pages?land=<path>`): a navigation to the branch's own content plane
@@ -1737,8 +1822,10 @@ export class WorldDoors {
1737
1822
  // session removes it, so no write token ever reaches a page's script (or a frame beside it)
1738
1823
  // `origin`: where the branch's own pages are, when it has an origin of its own (a page moves there to view it)
1739
1824
  const origin = this.host.originOf?.(made.name) ?? null;
1740
- if (via === 'session') { const { token: _write, ...viewed } = made; return Response.json({ ...viewed, ...(origin ? { origin } : {}) }, { status: 201 }); }
1741
- return Response.json({ ...made, ...(origin ? { origin } : {}) }, { status: 201 });
1825
+ const sites = this.host.sitesTemplateOf?.(made.name) ?? null;
1826
+ const where = { ...(origin ? { origin } : {}), ...(sites ? { sites } : {}) };
1827
+ if (via === 'session') { const { token: _write, ...viewed } = made; return Response.json({ ...viewed, ...where }, { status: 201 }); }
1828
+ return Response.json({ ...made, ...where }, { status: 201 });
1742
1829
  } catch (error) {
1743
1830
  this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
1744
1831
  return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
@@ -1797,8 +1884,17 @@ export class WorldDoors {
1797
1884
  /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1798
1885
  private async boardFrames(url: URL): Promise<{ delta: ReturnType<typeof diffWorld>; frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: Array<ReturnType<typeof diffWorld>['actions'][number]> }> }> {
1799
1886
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1800
- const routed = this.host.sitesOrigin?.() ?? null;
1801
- const sites = routed ? new URL(routed) : null;
1887
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
1888
+ // a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
1889
+ // which hands its address a one-time read pass (sites/go); one on this machine opens at its address
1890
+ const siteAt = (hostname: string, rest: string): string | null => {
1891
+ if (!sitesAt) return null;
1892
+ const direct = siteUrlOf(sitesAt, hostname);
1893
+ if (!direct) return null;
1894
+ if (sitesAt.includes('{host}')) return `${direct}${rest}`;
1895
+ const own = this.host.browserOrigin?.() ?? null;
1896
+ return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
1897
+ };
1802
1898
  const delta = diffWorld(this.name, { root: this.worldRoot });
1803
1899
  const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
1804
1900
  const changes = delta.actions.filter((a) => !received.has(a.action.id));
@@ -1823,7 +1919,7 @@ export class WorldDoors {
1823
1919
  let unseen: 'not-drawn' | 'sites-not-routed' | undefined;
1824
1920
  try {
1825
1921
  const u = new URL(f.url);
1826
- if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
1922
+ if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) { const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`); at = opened ? { site: opened } : null; }
1827
1923
  else if (f.drawn) at = { screen: `${u.pathname}${u.search}` };
1828
1924
  if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
1829
1925
  } catch { at = null; unseen = 'not-drawn'; }
package/src/world-view.ts CHANGED
@@ -50,10 +50,11 @@ export async function serveWorldView(name: string, opts: { /** the person signed
50
50
  let base: string | null = null;
51
51
  const browserOrigin = origins ? (served: string) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
52
52
  // sites are routed at each World's own *.localhost origin, never behind the proxy
53
- const sitesOrigin = origins && !proxied ? (served: string) => (port ? localWorldOrigin(served, port) : null) : undefined;
53
+ // each site nested under its World's origin (`<hostname>.<world>--<org>.localhost`, siteOfHost)
54
+ const sitesTemplate = origins && !proxied ? (served: string) => { const o = port ? localWorldOrigin(served, port) : null; return o ? `http://{host}.${new URL(o).host}` : null; } : undefined;
54
55
  // the content plane beside each World's origin (`<world>--<org>--pages.localhost`): its twins' pages, apart from its doors
55
56
  const pagesOrigin = origins && !proxied ? (served: string) => (port ? pagesOriginOf(served, 'http:', 'localhost', port) : null) : undefined;
56
- const mountAt = (at: string): Promise<MountedWorld> => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
57
+ const mountAt = (at: string): Promise<MountedWorld> => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesTemplate ? { sitesTemplate } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
57
58
  const branches: LocalBranches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
58
59
  // THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
59
60
  // same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
@@ -84,7 +85,7 @@ export async function serveWorldView(name: string, opts: { /** the person signed
84
85
  if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id) rmSync(current, { force: true }); else writeFileSync(current, `${branch}\n`);
85
86
  } finally { switching = false; }
86
87
  };
87
- const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
88
+ const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesTemplate ? { sitesTemplate } : {}), ...(pagesOrigin ? { pagesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
88
89
  let world = await mountWorld(name, mountOpts);
89
90
  base = world.served;
90
91
  worlds.set(world.served, world);