@volter/world-runtime 3.0.17 → 3.0.19

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
@@ -360,6 +375,17 @@ export declare class WorldDoors {
360
375
  vendor: string;
361
376
  hosts: string[];
362
377
  }>>;
378
+ /** What a twin asks an application (`ctx.ask`) that this World can answer truthfully, where no process routes it (a
379
+ * hosted World's isolate): a GET or HEAD of a URL whose hostname one of this World's twins answers (its hostsClaimed
380
+ * door: a Cloudflare twin's Worker route or custom domain) is that twin's answer, read as a browser reads the site
381
+ * here (`site`). Any other question (another method, a hostname no twin claims) is undefined: it goes on as it would
382
+ * without this router (the application route, the World's egress rule, the network), never a World-made answer
383
+ * dressed as the application's (architecture, "What an author writes, and how"). Given to each twin's fetch as its
384
+ * `application` by apps/cloud. */
385
+ askInWorld(target: string, init: RequestInit): Promise<{
386
+ status: number;
387
+ body: string;
388
+ } | undefined>;
363
389
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
364
390
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
365
391
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -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
@@ -971,6 +1038,33 @@ export class WorldDoors {
971
1038
  }
972
1039
  return out;
973
1040
  }
1041
+ /** What a twin asks an application (`ctx.ask`) that this World can answer truthfully, where no process routes it (a
1042
+ * hosted World's isolate): a GET or HEAD of a URL whose hostname one of this World's twins answers (its hostsClaimed
1043
+ * door: a Cloudflare twin's Worker route or custom domain) is that twin's answer, read as a browser reads the site
1044
+ * here (`site`). Any other question (another method, a hostname no twin claims) is undefined: it goes on as it would
1045
+ * without this router (the application route, the World's egress rule, the network), never a World-made answer
1046
+ * dressed as the application's (architecture, "What an author writes, and how"). Given to each twin's fetch as its
1047
+ * `application` by apps/cloud. */
1048
+ async askInWorld(target, init) {
1049
+ const method = (init.method ?? 'GET').toUpperCase();
1050
+ if (method !== 'GET' && method !== 'HEAD')
1051
+ return undefined;
1052
+ let url;
1053
+ try {
1054
+ url = new URL(target);
1055
+ }
1056
+ catch {
1057
+ return undefined;
1058
+ }
1059
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
1060
+ return undefined;
1061
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
1062
+ const claimed = await this.claimedHosts(url.origin);
1063
+ if (!claimed.some(({ hosts }) => hosts.includes(host)))
1064
+ return undefined;
1065
+ const answer = await this.site(new Request(url, { method, headers: init.headers ?? {} }), host);
1066
+ return { status: answer.status, body: await answer.text() };
1067
+ }
974
1068
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
975
1069
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
976
1070
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -1021,6 +1115,11 @@ export class WorldDoors {
1021
1115
  return Response.json({ ok: true, world: this.served, worlds: [this.served] });
1022
1116
  if (this.onPages(url))
1023
1117
  return await this.pagesPlane(request, url);
1118
+ const hostedSite = await this.hostedSiteOf(url);
1119
+ if (hostedSite)
1120
+ return await this.pagesPlane(request, url, hostedSite);
1121
+ if (this.siteShaped(url))
1122
+ return Response.json({ error: `no site of ${this.served} at ${url.host}` }, { status: 404 });
1024
1123
  if (url.pathname.startsWith(`/-/${this.served}/`))
1025
1124
  return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
1026
1125
  // the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
@@ -1184,10 +1283,9 @@ export class WorldDoors {
1184
1283
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1185
1284
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
1186
1285
  // 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}`);
1286
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
1287
+ if (sitesAt)
1288
+ headers.set(TWIN_SITES_HEADER, sitesAt);
1191
1289
  else
1192
1290
  headers.delete(TWIN_SITES_HEADER);
1193
1291
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
@@ -1391,6 +1489,31 @@ export class WorldDoors {
1391
1489
  // a browser session is opened by either token: the read token's is how a view-only link opens a vendor's screens
1392
1490
  // the session gate comes first: a page that holds only the cookie (a sibling World's, the same site) can neither
1393
1491
  // re-issue nor end the session through this door
1492
+ // A HOSTED SITE OPENED (`GET sites/go?host=<hostname>&land=<path>`): a person's navigation, handed to the site's own
1493
+ // address with a one-time read pass. A navigation from a sibling address (the site's own page, linking back here) is
1494
+ // taken too: it only opens, in this person's own browser, a read of this World's own site
1495
+ if (kind === 'sites' && name === 'go' && request.method === 'GET') {
1496
+ const fetchSite = request.headers.get('sec-fetch-site');
1497
+ const dest = request.headers.get('sec-fetch-dest');
1498
+ // a person's navigation (from here, from the site's own page linking back, or typed in), or a frame of this World's
1499
+ // own console (its board, its comparison)
1500
+ const navigated = dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
1501
+ const framed = dest === 'iframe' && fetchSite === 'same-origin';
1502
+ if (via === 'session' && !navigated && !framed)
1503
+ return Response.json({ error: "a hosted site opens on a person's navigation" }, { status: 403 });
1504
+ const template = this.host.sitesTemplate?.() ?? null;
1505
+ const hostname = (url.searchParams.get('host') ?? '').toLowerCase();
1506
+ const claimed = (await this.claimedHosts(url.origin)).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
1507
+ const at = template && claimed ? siteUrlOf(template, hostname) : null;
1508
+ if (!at)
1509
+ return Response.json({ error: `${hostname || 'that host'} is no site of ${this.served} shown here` }, { status: 404 });
1510
+ const asked = this.credential(request);
1511
+ const land = url.searchParams.get('land') ?? '/';
1512
+ const passed = this.mintPagesPass('read', { site: hostname, ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) });
1513
+ if (!passed)
1514
+ return Response.json({ error: 'too many sites opened at once: try again in a minute' }, { status: 429 });
1515
+ 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' } });
1516
+ }
1394
1517
  const foreign = this.crossOriginSession(request, via);
1395
1518
  if (foreign)
1396
1519
  return foreign;
@@ -1984,7 +2107,7 @@ export class WorldDoors {
1984
2107
  }
1985
2108
  catch { /* a World with no local branches */ }
1986
2109
  // 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; });
2110
+ 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
2111
  return Response.json({ world: this.served, branches: rows, checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1989
2112
  }
1990
2113
  // A BRANCH'S PAGES (`GET branches/<org>/<branch>/pages?land=<path>`): a navigation to the branch's own content plane
@@ -2068,11 +2191,13 @@ export class WorldDoors {
2068
2191
  // session removes it, so no write token ever reaches a page's script (or a frame beside it)
2069
2192
  // `origin`: where the branch's own pages are, when it has an origin of its own (a page moves there to view it)
2070
2193
  const origin = this.host.originOf?.(made.name) ?? null;
2194
+ const sites = this.host.sitesTemplateOf?.(made.name) ?? null;
2195
+ const where = { ...(origin ? { origin } : {}), ...(sites ? { sites } : {}) };
2071
2196
  if (via === 'session') {
2072
2197
  const { token: _write, ...viewed } = made;
2073
- return Response.json({ ...viewed, ...(origin ? { origin } : {}) }, { status: 201 });
2198
+ return Response.json({ ...viewed, ...where }, { status: 201 });
2074
2199
  }
2075
- return Response.json({ ...made, ...(origin ? { origin } : {}) }, { status: 201 });
2200
+ return Response.json({ ...made, ...where }, { status: 201 });
2076
2201
  }
2077
2202
  catch (error) {
2078
2203
  this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
@@ -2136,8 +2261,20 @@ export class WorldDoors {
2136
2261
  /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
2137
2262
  async boardFrames(url) {
2138
2263
  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;
2264
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
2265
+ // a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
2266
+ // which hands its address a one-time read pass (sites/go); one on this machine opens at its address
2267
+ const siteAt = (hostname, rest) => {
2268
+ if (!sitesAt)
2269
+ return null;
2270
+ const direct = siteUrlOf(sitesAt, hostname);
2271
+ if (!direct)
2272
+ return null;
2273
+ if (sitesAt.includes('{host}'))
2274
+ return `${direct}${rest}`;
2275
+ const own = this.host.browserOrigin?.() ?? null;
2276
+ return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
2277
+ };
2141
2278
  const delta = diffWorld(this.name, { root: this.worldRoot });
2142
2279
  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
2280
  const changes = delta.actions.filter((a) => !received.has(a.action.id));
@@ -2166,8 +2303,10 @@ export class WorldDoors {
2166
2303
  let unseen;
2167
2304
  try {
2168
2305
  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;
2306
+ if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) {
2307
+ const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`);
2308
+ at = opened ? { site: opened } : null;
2309
+ }
2171
2310
  else if (f.drawn)
2172
2311
  at = { screen: `${u.pathname}${u.search}` };
2173
2312
  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.19",
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.19",
72
+ "@volter/world-console": "3.0.14"
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.19",
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
@@ -879,6 +940,26 @@ export class WorldDoors {
879
940
  return out;
880
941
  }
881
942
 
943
+ /** What a twin asks an application (`ctx.ask`) that this World can answer truthfully, where no process routes it (a
944
+ * hosted World's isolate): a GET or HEAD of a URL whose hostname one of this World's twins answers (its hostsClaimed
945
+ * door: a Cloudflare twin's Worker route or custom domain) is that twin's answer, read as a browser reads the site
946
+ * here (`site`). Any other question (another method, a hostname no twin claims) is undefined: it goes on as it would
947
+ * without this router (the application route, the World's egress rule, the network), never a World-made answer
948
+ * dressed as the application's (architecture, "What an author writes, and how"). Given to each twin's fetch as its
949
+ * `application` by apps/cloud. */
950
+ async askInWorld(target: string, init: RequestInit): Promise<{ status: number; body: string } | undefined> {
951
+ const method = (init.method ?? 'GET').toUpperCase();
952
+ if (method !== 'GET' && method !== 'HEAD') return undefined;
953
+ let url: URL;
954
+ try { url = new URL(target); } catch { return undefined; }
955
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
956
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
957
+ const claimed = await this.claimedHosts(url.origin);
958
+ if (!claimed.some(({ hosts }) => hosts.includes(host))) return undefined;
959
+ const answer = await this.site(new Request(url, { method, headers: init.headers ?? {} }), host);
960
+ return { status: answer.status, body: await answer.text() };
961
+ }
962
+
882
963
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
883
964
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
884
965
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -914,6 +995,9 @@ export class WorldDoors {
914
995
  const prefix = `/${this.served}/`;
915
996
  if (url.pathname === '/-/ping') return Response.json({ ok: true, world: this.served, worlds: [this.served] });
916
997
  if (this.onPages(url)) return await this.pagesPlane(request, url);
998
+ const hostedSite = await this.hostedSiteOf(url);
999
+ if (hostedSite) return await this.pagesPlane(request, url, hostedSite);
1000
+ if (this.siteShaped(url)) return Response.json({ error: `no site of ${this.served} at ${url.host}` }, { status: 404 });
917
1001
  if (url.pathname.startsWith(`/-/${this.served}/`)) return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
918
1002
  // the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
919
1003
  // place under this World, and the token the injector carries to them — the presented one
@@ -1048,9 +1132,8 @@ export class WorldDoors {
1048
1132
  headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1049
1133
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
1050
1134
  // 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);
1135
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
1136
+ if (sitesAt) headers.set(TWIN_SITES_HEADER, sitesAt); else headers.delete(TWIN_SITES_HEADER);
1054
1137
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
1055
1138
  const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
1056
1139
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
@@ -1214,6 +1297,28 @@ export class WorldDoors {
1214
1297
  // a browser session is opened by either token: the read token's is how a view-only link opens a vendor's screens
1215
1298
  // the session gate comes first: a page that holds only the cookie (a sibling World's, the same site) can neither
1216
1299
  // re-issue nor end the session through this door
1300
+ // A HOSTED SITE OPENED (`GET sites/go?host=<hostname>&land=<path>`): a person's navigation, handed to the site's own
1301
+ // address with a one-time read pass. A navigation from a sibling address (the site's own page, linking back here) is
1302
+ // taken too: it only opens, in this person's own browser, a read of this World's own site
1303
+ if (kind === 'sites' && name === 'go' && request.method === 'GET') {
1304
+ const fetchSite = request.headers.get('sec-fetch-site');
1305
+ const dest = request.headers.get('sec-fetch-dest');
1306
+ // a person's navigation (from here, from the site's own page linking back, or typed in), or a frame of this World's
1307
+ // own console (its board, its comparison)
1308
+ const navigated = dest === 'document' && ['same-origin', 'same-site', 'none'].includes(fetchSite ?? '');
1309
+ const framed = dest === 'iframe' && fetchSite === 'same-origin';
1310
+ if (via === 'session' && !navigated && !framed) return Response.json({ error: "a hosted site opens on a person's navigation" }, { status: 403 });
1311
+ const template = this.host.sitesTemplate?.() ?? null;
1312
+ const hostname = (url.searchParams.get('host') ?? '').toLowerCase();
1313
+ const claimed = (await this.claimedHosts(url.origin)).some(({ hosts }) => hosts.some((h) => h.toLowerCase() === hostname));
1314
+ const at = template && claimed ? siteUrlOf(template, hostname) : null;
1315
+ if (!at) return Response.json({ error: `${hostname || 'that host'} is no site of ${this.served} shown here` }, { status: 404 });
1316
+ const asked = this.credential(request);
1317
+ const land = url.searchParams.get('land') ?? '/';
1318
+ const passed = this.mintPagesPass('read', { site: hostname, ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin!()! } : {}) });
1319
+ if (!passed) return Response.json({ error: 'too many sites opened at once: try again in a minute' }, { status: 429 });
1320
+ 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' } });
1321
+ }
1217
1322
  const foreign = this.crossOriginSession(request, via);
1218
1323
  if (foreign) return foreign;
1219
1324
  // 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 +1778,7 @@ export class WorldDoors {
1673
1778
  let named: Array<{ name: string; running: boolean }> = [];
1674
1779
  try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
1675
1780
  // 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; });
1781
+ 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
1782
  return Response.json({ world: this.served, branches: rows, checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1678
1783
  }
1679
1784
  // A BRANCH'S PAGES (`GET branches/<org>/<branch>/pages?land=<path>`): a navigation to the branch's own content plane
@@ -1737,8 +1842,10 @@ export class WorldDoors {
1737
1842
  // session removes it, so no write token ever reaches a page's script (or a frame beside it)
1738
1843
  // `origin`: where the branch's own pages are, when it has an origin of its own (a page moves there to view it)
1739
1844
  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 });
1845
+ const sites = this.host.sitesTemplateOf?.(made.name) ?? null;
1846
+ const where = { ...(origin ? { origin } : {}), ...(sites ? { sites } : {}) };
1847
+ if (via === 'session') { const { token: _write, ...viewed } = made; return Response.json({ ...viewed, ...where }, { status: 201 }); }
1848
+ return Response.json({ ...made, ...where }, { status: 201 });
1742
1849
  } catch (error) {
1743
1850
  this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
1744
1851
  return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
@@ -1797,8 +1904,17 @@ export class WorldDoors {
1797
1904
  /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1798
1905
  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
1906
  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;
1907
+ const sitesAt = this.host.sitesTemplate?.() ?? null;
1908
+ // a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
1909
+ // which hands its address a one-time read pass (sites/go); one on this machine opens at its address
1910
+ const siteAt = (hostname: string, rest: string): string | null => {
1911
+ if (!sitesAt) return null;
1912
+ const direct = siteUrlOf(sitesAt, hostname);
1913
+ if (!direct) return null;
1914
+ if (sitesAt.includes('{host}')) return `${direct}${rest}`;
1915
+ const own = this.host.browserOrigin?.() ?? null;
1916
+ return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
1917
+ };
1802
1918
  const delta = diffWorld(this.name, { root: this.worldRoot });
1803
1919
  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
1920
  const changes = delta.actions.filter((a) => !received.has(a.action.id));
@@ -1823,7 +1939,7 @@ export class WorldDoors {
1823
1939
  let unseen: 'not-drawn' | 'sites-not-routed' | undefined;
1824
1940
  try {
1825
1941
  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;
1942
+ if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) { const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`); at = opened ? { site: opened } : null; }
1827
1943
  else if (f.drawn) at = { screen: `${u.pathname}${u.search}` };
1828
1944
  if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
1829
1945
  } 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);