@volter/world-runtime 3.0.28 → 3.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { serveHttp } from '@volter/world-core/runtime';
1
+ import { BROWSER_SESSION_HEADER, serveHttp } from '@volter/world-core/runtime';
2
2
  import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress } from '@volter/world-core/network-policy';
3
3
  import { websocketRelay } from "./websocket-relay.js";
4
4
  // The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
@@ -350,7 +350,7 @@ export async function startRedirectProxy(options) {
350
350
  }
351
351
  const origin = new URL(twin.origin);
352
352
  const headers = new Headers(req.headers);
353
- for (const h of ['proxy-connection', 'accept-encoding', 'connection'])
353
+ for (const h of [BROWSER_SESSION_HEADER, 'proxy-connection', 'accept-encoding', 'connection'])
354
354
  headers.delete(h);
355
355
  // the application is not a twin: it gets no World key, keeps its Host and hears it was reached over TLS
356
356
  if (twin.vendor === 'app') {
@@ -479,6 +479,7 @@ export async function startRedirectProxy(options) {
479
479
  outHeaders['x-volter-twin-original-host'] = headers.host ?? origin.host;
480
480
  outHeaders.host = origin.host;
481
481
  }
482
+ delete outHeaders[BROWSER_SESSION_HEADER];
482
483
  delete outHeaders['proxy-connection'];
483
484
  delete outHeaders['accept-encoding'];
484
485
  upstream.write(`${method} ${path} ${version}\r\n`);
@@ -1,3 +1,4 @@
1
+ import { BROWSER_SESSION_HEADER } from '@volter/world-core/runtime';
1
2
  import { serveHttp } from '@volter/world-core/runtime';
2
3
  import { websocketRelay } from "./websocket-relay.js";
3
4
  import { sessionTrustEnv } from "./ca-trust.js";
@@ -139,6 +140,7 @@ export async function startReflectFront(options) {
139
140
  return new Response('bad twin origin', { status: 502 });
140
141
  }
141
142
  const headers = new Headers(req.headers);
143
+ headers.delete(BROWSER_SESSION_HEADER);
142
144
  headers.delete('proxy-connection');
143
145
  headers.delete('accept-encoding');
144
146
  // the consumer spoke TLS to the front; a twin that answers with an absolute URL (Slack's Socket Mode link)
@@ -67,10 +67,6 @@ export type MountedWorld = {
67
67
  boot: (url: string) => Promise<void>;
68
68
  stop: () => Promise<void>;
69
69
  };
70
- /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
71
- /** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
72
- * session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
73
- * is this machine's own page (attributed to the machine). */
74
70
  type PagesGrant = {
75
71
  scope: 'read' | 'write';
76
72
  session?: string;
@@ -266,6 +262,13 @@ export declare class WorldDoors {
266
262
  private keepCookiesHere;
267
263
  /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
268
264
  private pagesPlane;
265
+ /** A landing resolved as a browser resolves it, within this vendor's place only. */
266
+ private vendorLanding;
267
+ /** The pack's declared browser session, never inferred from its vendor or a sign-in page. */
268
+ private browserSessionOf;
269
+ private vendorPeople;
270
+ /** The twin receives its own cookie and changes the session in its own process, over a host-only permit. */
271
+ private vendorSession;
269
272
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
270
273
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
271
274
  * keeps them; each lasts 30 days from its opening. */
@@ -13,6 +13,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
13
13
  // POST deploy[/<name>] perform landed entries against each root, by policy
14
14
  // GET/PUT twins/<vendor>/root the vendor's real account behind the twin (PUT null clears)
15
15
  // PUT twins/<vendor>/credential seal the credential; GET answers only that one is sealed
16
+ // GET twins/<vendor>/people, POST twins/<vendor>/session a browser views the vendor as a World's person
16
17
  // POST twins/<vendor>/refresh observe the root now
17
18
  // and what a person looks into it through (docs/contributing/architecture.md, "Viewing a World"):
18
19
  // GET map | timeline | diff every twin and what it holds; the logs merged newest first; the changes since the base
@@ -31,6 +32,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
31
32
  // One writer per world by construction: the doors run in this process, serialized per twin.
32
33
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
33
34
  import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
35
+ import { BROWSER_SESSION_HEADER, BROWSER_SESSION_PATH, stripBrowserSessionPermit, withBrowserSessionPermit } from '@volter/world-core/runtime';
34
36
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
35
37
  import { packFacts } from "./pack-facts.js";
36
38
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
@@ -471,7 +473,9 @@ export class WorldDoors {
471
473
  }
472
474
  /** Whether what gave a content session its pass still stands: the control session that asked (ended, signed out, or
473
475
  * its shared link revoked, the content session ends with it), or this World's token (rotated, every one ends). */
474
- pagesParentLive(grant) { return grant.session === undefined || this.issued().session(grant.session) !== null; }
476
+ pagesParentLive(grant) {
477
+ return grant.session === undefined || this.issued().session(grant.session) !== null;
478
+ }
475
479
  /** The address that opens this World's content plane for a minute's one-time pass and lands on `land` (a path of this
476
480
  * World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
477
481
  pagesPass(scope, land, grant = {}) {
@@ -482,7 +486,7 @@ export class WorldDoors {
482
486
  return pass ? `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}` : null;
483
487
  }
484
488
  /** A one-time pass (a minute long) for the content plane or a hosted site; null when its asker holds fifty unused. */
485
- mintPagesPass(scope, grant) {
489
+ mintPagesPass(scope, grant, viewAs) {
486
490
  const now = Date.now();
487
491
  for (const [k, v] of this.pagePasses)
488
492
  if (v.until <= now)
@@ -492,7 +496,7 @@ export class WorldDoors {
492
496
  if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000)
493
497
  return null;
494
498
  const pass = mintToken('pass_');
495
- this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
499
+ this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000, ...(viewAs ? { viewAs } : {}) });
496
500
  return pass;
497
501
  }
498
502
  /** The hostname a hosted site address names (`{site}` template: `<label>--<world>--<org>.<suffix>`) among those this
@@ -549,13 +553,16 @@ export class WorldDoors {
549
553
  /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
550
554
  async pagesPlane(request, url, /** a hosted site served here, not the content plane */ site) {
551
555
  const doors = `/-/${this.served}/pages/`;
552
- const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
556
+ const viewPath = url.pathname.startsWith(`/${this.served}/`) ? url.pathname.slice(`/${this.served}/`.length).split('/') : [];
557
+ const viewVendor = viewPath.length === 4 && viewPath[1] === '_twin' && viewPath[2] === 'view-as' && Object.hasOwn(this.up().services, viewPath[0]) ? viewPath[0] : null;
558
+ const door = viewVendor ? viewPath[3] : url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
559
+ const opening = viewVendor ? `/${this.served}/${viewVendor}/_twin/view-as/` : doors;
553
560
  const own = this.host.browserOrigin?.() ?? null;
554
561
  if (door === 'open' && request.method === 'GET') {
555
562
  // framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
556
563
  // framed only by the console that asked
557
564
  const consoles = [own, this.parentOrigin()].filter((x) => Boolean(x)).join(' ') || "'none'";
558
- 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' } });
565
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}" data-session="${opening}session"${site ? ' data-site="1"' : ''}><script src="${opening}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' } });
559
566
  }
560
567
  if (door === 'open.js' && request.method === 'GET') {
561
568
  const script = `(() => {
@@ -564,29 +571,47 @@ export class WorldDoors {
564
571
  history.replaceState(null, '', location.pathname);
565
572
  const pass = carried.get('pass') || '';
566
573
  const land = carried.get('land') || '';
574
+ const viewAs = carried.get('viewAs') === '1';
575
+ const tell = (data) => { if (viewAs && parent !== window) parent.postMessage({ type: 'volter-view-as', pass, ...data }, ${JSON.stringify(own)}); };
567
576
  // resolved as the browser will (encoded dots too), on this origin and under this World
568
577
  let to = null;
569
578
  // a hosted site lands anywhere on it; the content plane only under this World
570
579
  const anywhere = document.body.dataset.site === '1';
571
580
  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 {}
572
581
  if (!pass || !to) { document.body.textContent = 'Nothing to open.'; return; }
573
- fetch('/-/' + world + '/pages/session', { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
574
- .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
575
- .catch(() => { document.body.textContent = 'This World did not answer.'; });
582
+ fetch(document.body.dataset.session, { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
583
+ .then(async (r) => {
584
+ const opened = await r.json();
585
+ if (!r.ok) throw new Error(opened.error || 'This World did not open.');
586
+ if (opened.viewAs) { tell({ viewed: opened.viewAs }); location.replace(opened.viewAs.land); }
587
+ else if (viewAs) throw new Error('This pass did not sign in to the vendor.');
588
+ else location.replace(to);
589
+ })
590
+ .catch((e) => { document.body.textContent = e.message || 'This World did not answer.'; tell({ error: document.body.textContent }); });
576
591
  })();
577
592
  `;
578
593
  return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
579
594
  }
580
595
  if (door === 'session' && request.method === 'POST') {
581
596
  // only this plane's own opening page asks (a browser sets the header; a page elsewhere cannot)
582
- if (request.headers.get('sec-fetch-site') !== 'same-origin')
583
- return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
584
597
  const pass = request.headers.get('x-volter-pages-pass') ?? '';
585
598
  const given = this.pagePasses.get(pass);
586
599
  this.pagePasses.delete(pass);
600
+ if (request.headers.get('sec-fetch-site') !== 'same-origin')
601
+ return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
587
602
  if (!given || given.until <= Date.now() || !this.pagesParentLive(given) || (given.site ?? null) !== (site ?? null))
588
603
  return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
589
- const { until: _until, ...grant } = given;
604
+ const { until: _until, viewAs, ...grant } = given;
605
+ if (viewAs) {
606
+ const held = this.pageSession(request, url);
607
+ if (!held)
608
+ return Response.json({ error: 'Open a vendor page from this World first, then choose View as again.' }, { status: 403 });
609
+ if (viewVendor !== viewAs.vendor || !given.session || held.scope !== 'write' || held.session !== given.session)
610
+ return Response.json({ error: 'View as belongs to the browser session that asked for it' }, { status: 403 });
611
+ return this.vendorSession(request, viewAs, given.until);
612
+ }
613
+ if (viewVendor)
614
+ return Response.json({ error: 'this door takes only a View as pass' }, { status: 403 });
590
615
  // one content session per asker at a scope and framer: a board's frames opening together share it
591
616
  const same = (x) => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who && x.site === grant.site;
592
617
  const now = Date.now();
@@ -594,7 +619,9 @@ export class WorldDoors {
594
619
  const id = found ?? mintToken('pgs_');
595
620
  // twelve hours from its last opening while the session that asked stands; an hour where that cannot be known
596
621
  this.keepPagesSessions({ ...this.pagesSessions(), [id]: { ...grant, until: now + (grant.session !== undefined || grant.asker === undefined ? 12 : 1) * 3_600_000 } });
597
- return Response.json({ world: this.served, scope: grant.scope }, { headers: { 'set-cookie': `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned` } });
622
+ const headers = new Headers({ 'cache-control': 'no-store' });
623
+ headers.append('set-cookie', `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned`);
624
+ return Response.json({ world: this.served, scope: grant.scope }, { headers });
598
625
  }
599
626
  // A HOSTED SITE: read with a session its World's own origin gave it a pass for (sites/go), never without
600
627
  if (site) {
@@ -638,6 +665,76 @@ export class WorldDoors {
638
665
  }
639
666
  return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
640
667
  }
668
+ /** A landing resolved as a browser resolves it, within this vendor's place only. */
669
+ vendorLanding(vendor, land) {
670
+ if (typeof land !== 'string' || !land.startsWith('/') || land.startsWith('//') || /[\\\s]/.test(land))
671
+ return null;
672
+ try {
673
+ const url = new URL(land, 'https://world.local');
674
+ const path = decodeURIComponent(url.pathname);
675
+ const base = `/${this.served}/${vendor}/`;
676
+ if (url.origin !== 'https://world.local' || !url.pathname.startsWith(base) || !path.startsWith(base)
677
+ || path.includes('\\') || path.split('/').some((p) => p === '.' || p === '..' || p.includes('%')))
678
+ return null;
679
+ return url.pathname + url.search + url.hash;
680
+ }
681
+ catch {
682
+ return null;
683
+ }
684
+ }
685
+ /** The pack's declared browser session, never inferred from its vendor or a sign-in page. */
686
+ async browserSessionOf(vendor) {
687
+ const twin = this.host.twinFetch(vendor);
688
+ if (!twin || !this.host.pagesOrigin?.())
689
+ return null;
690
+ try {
691
+ const answer = await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } }));
692
+ const manifest = answer.ok ? await answer.json() : null;
693
+ if (!manifest?.drawsScreens)
694
+ return null;
695
+ const session = manifest?.browserSession;
696
+ if (!session || typeof session !== 'object')
697
+ return null;
698
+ const { cookie, scope } = session;
699
+ if (typeof cookie !== 'string' || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(cookie)
700
+ || /^(?:__Host-|(?:__Secure-)?volter_(?:world|pages)$)/i.test(cookie) || (scope !== undefined && typeof scope !== 'string'))
701
+ return null;
702
+ return { cookie, ...(typeof scope === 'string' ? { scope } : {}) };
703
+ }
704
+ catch {
705
+ return null;
706
+ }
707
+ }
708
+ vendorPeople(vendor) {
709
+ return readTree(this.stateOf(vendor), this.controlRoot(vendor))
710
+ .filter((p) => p.type === '_person' && p.deleted !== true && typeof p.email === 'string')
711
+ .map((p) => ({ id: p.id, email: String(p.email), label: String(p.email) }));
712
+ }
713
+ /** The twin receives its own cookie and changes the session in its own process, over a host-only permit. */
714
+ async vendorSession(request, pass, until) {
715
+ const twin = this.host.twinFetch(pass.vendor);
716
+ if (!twin || !await this.browserSessionOf(pass.vendor) || !this.vendorLanding(pass.vendor, pass.land))
717
+ return Response.json({ error: 'that vendor session is unavailable' }, { status: 404 });
718
+ const person = pass.person === null ? null : this.vendorPeople(pass.vendor).find((p) => p.id === pass.person);
719
+ if (person === undefined)
720
+ return Response.json({ error: 'that person is no longer in the World' }, { status: 404 });
721
+ const url = new URL(request.url);
722
+ const headers = new Headers({ [TWIN_PREFIX_HEADER]: `/${this.served}/${pass.vendor}`, 'x-forwarded-host': url.host, 'x-forwarded-proto': url.protocol.slice(0, -1) });
723
+ headers.set(CALLER_HEADER, callerForHeader(pass.asker));
724
+ headers.set('traceparent', traceparentForDelivery(request.headers.get('traceparent')) ?? newTraceparent());
725
+ const cookies = (request.headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
726
+ if (cookies.length)
727
+ headers.set('cookie', cookies.join('; '));
728
+ const answer = await withBrowserSessionPermit(this.stateOf(pass.vendor), this.controlRoot(pass.vendor), { person: pass.person, land: pass.land, until }, async (permit) => {
729
+ headers.set(BROWSER_SESSION_HEADER, permit);
730
+ return twin(new Request(`${url.origin}${BROWSER_SESSION_PATH}`, { method: 'POST', headers, redirect: 'manual' }));
731
+ });
732
+ if (answer.status !== 303)
733
+ return answer;
734
+ const out = new Headers({ 'cache-control': 'no-store' });
735
+ this.keepCookiesHere(answer, out);
736
+ return Response.json({ viewAs: { vendor: pass.vendor, person: pass.person, label: person?.email ?? 'Signed out', land: pass.land } }, { headers: out });
737
+ }
641
738
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
642
739
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
643
740
  * keeps them; each lasts 30 days from its opening. */
@@ -1074,6 +1171,7 @@ export class WorldDoors {
1074
1171
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
1075
1172
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
1076
1173
  async site(request, host) {
1174
+ request = stripBrowserSessionPermit(request);
1077
1175
  if (request.method !== 'GET' && request.method !== 'HEAD')
1078
1176
  return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
1079
1177
  const url = new URL(request.url);
@@ -1111,6 +1209,7 @@ export class WorldDoors {
1111
1209
  return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
1112
1210
  }
1113
1211
  async handle(request) {
1212
+ request = stripBrowserSessionPermit(request);
1114
1213
  try {
1115
1214
  if (!this.host.ready() && !new URL(request.url).pathname.startsWith('/-/ping'))
1116
1215
  return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
@@ -1262,6 +1361,7 @@ export class WorldDoors {
1262
1361
  // the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
1263
1362
  // request's log entries carry, so each entry is joined to its caller by an id no caller chose
1264
1363
  headers.delete(CALLER_HEADER);
1364
+ headers.delete(BROWSER_SESSION_HEADER);
1265
1365
  // the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
1266
1366
  // credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
1267
1367
  if (via === 'session') {
@@ -1337,7 +1437,7 @@ export class WorldDoors {
1337
1437
  // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
1338
1438
  // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
1339
1439
  const caller = new Headers(request.headers);
1340
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
1440
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, BROWSER_SESSION_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
1341
1441
  caller.delete(h);
1342
1442
  // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
1343
1443
  const authorization = caller.get('authorization');
@@ -1949,6 +2049,33 @@ export class WorldDoors {
1949
2049
  return Response.json({ world: this.served, twins });
1950
2050
  }
1951
2051
  if (kind === 'twins' && name) {
2052
+ if (parts.length === 3 && (verb === 'people' || verb === 'session')) {
2053
+ const session = Object.hasOwn(this.up().services, name) ? await this.browserSessionOf(name) : null;
2054
+ if (verb === 'people' && request.method === 'GET')
2055
+ return Response.json({ viewAs: session !== null, people: session ? this.vendorPeople(name).map(({ id, label }) => ({ id, label })) : [] }, { headers: { 'cache-control': 'no-store' } });
2056
+ if (verb === 'session' && request.method === 'POST') {
2057
+ if (!session)
2058
+ return Response.json({ error: `${name} needs a declared browser session, a drawn screen and a content plane for View as` }, { status: 409 });
2059
+ if (via !== 'session' || presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }).length || crossSite || site !== 'same-origin')
2060
+ return Response.json({ error: 'View as is opened from this World’s own page' }, { status: 403 });
2061
+ const body = await request.json().catch(() => null);
2062
+ const land = this.vendorLanding(name, body?.land);
2063
+ if (!body || (body.person !== null && typeof body.person !== 'string') || !land)
2064
+ return Response.json({ error: 'a session names a person (or null) and a path within that vendor' }, { status: 400 });
2065
+ if (body.person !== null && !this.vendorPeople(name).some((p) => p.id === body.person))
2066
+ return Response.json({ error: 'no such person in this World' }, { status: 404 });
2067
+ const at = this.host.pagesOrigin?.();
2068
+ if (!at)
2069
+ return Response.json({ error: 'View as needs this World’s content plane' }, { status: 409 });
2070
+ const asked = this.credential(request);
2071
+ if (!asked.session)
2072
+ return Response.json({ error: 'View as needs this World’s browser session' }, { status: 403 });
2073
+ const pass = this.mintPagesPass('write', { session: asked.session, ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) }, { vendor: name, person: body.person, land, asker: this.callerOf(request) });
2074
+ if (!pass)
2075
+ return Response.json({ error: 'too many pages opened at once: try again in a minute' }, { status: 429 });
2076
+ return Response.json({ url: `${at}/${this.served}/${name}/_twin/view-as/open#${new URLSearchParams({ pass, land, viewAs: '1' }).toString()}` }, { headers: { 'cache-control': 'no-store' } });
2077
+ }
2078
+ }
1952
2079
  if (verb === 'root' && request.method === 'GET')
1953
2080
  return Response.json({ twin: name, root: rootForControlRoot(this.controlRoot(name), name) ?? null, credential: sealedCredentialInfo(this.worldRoot, name) });
1954
2081
  if (verb === 'root' && request.method === 'PUT') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.28",
3
+ "version": "3.0.30",
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.28",
72
- "@volter/world-console": "3.0.23"
71
+ "@volter/world-core": "3.0.30",
72
+ "@volter/world-console": "3.0.25"
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.28",
78
+ "@volter/world-core": "3.0.30",
79
79
  "pg-gateway": "0.3.0-beta.4",
80
80
  "smol-toml": "^1.8.0",
81
81
  "yaml": "2.9.0"
@@ -1,4 +1,4 @@
1
- import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
1
+ import { BROWSER_SESSION_HEADER, serveHttp, type HttpServer } from '@volter/world-core/runtime';
2
2
  import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress } from '@volter/world-core/network-policy';
3
3
  import { websocketRelay } from './websocket-relay.ts';
4
4
  // The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
@@ -376,7 +376,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
376
376
  const origin = new URL(twin.origin);
377
377
 
378
378
  const headers = new Headers(req.headers);
379
- for (const h of ['proxy-connection', 'accept-encoding', 'connection']) headers.delete(h);
379
+ for (const h of [BROWSER_SESSION_HEADER, 'proxy-connection', 'accept-encoding', 'connection']) headers.delete(h);
380
380
  // the application is not a twin: it gets no World key, keeps its Host and hears it was reached over TLS
381
381
  if (twin.vendor === 'app') {
382
382
  headers.delete('x-twins-key');
@@ -509,6 +509,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
509
509
  outHeaders['x-volter-twin-original-host'] = headers.host ?? origin.host;
510
510
  outHeaders.host = origin.host;
511
511
  }
512
+ delete outHeaders[BROWSER_SESSION_HEADER];
512
513
  delete outHeaders['proxy-connection'];
513
514
  delete outHeaders['accept-encoding'];
514
515
  upstream.write(`${method} ${path} ${version}\r\n`);
package/src/reflect.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { BROWSER_SESSION_HEADER } from '@volter/world-core/runtime';
1
2
  import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
2
3
  import { websocketRelay } from './websocket-relay.ts';
3
4
  import { sessionTrustEnv } from './ca-trust.ts';
@@ -160,6 +161,7 @@ export async function startReflectFront(options: ReflectFrontOptions): Promise<R
160
161
  try { origin = new URL(target.origin); } catch { return new Response('bad twin origin', { status: 502 }); }
161
162
 
162
163
  const headers = new Headers(req.headers);
164
+ headers.delete(BROWSER_SESSION_HEADER);
163
165
  headers.delete('proxy-connection');
164
166
  headers.delete('accept-encoding');
165
167
  // the consumer spoke TLS to the front; a twin that answers with an absolute URL (Slack's Socket Mode link)
@@ -13,6 +13,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
13
13
  // POST deploy[/<name>] perform landed entries against each root, by policy
14
14
  // GET/PUT twins/<vendor>/root the vendor's real account behind the twin (PUT null clears)
15
15
  // PUT twins/<vendor>/credential seal the credential; GET answers only that one is sealed
16
+ // GET twins/<vendor>/people, POST twins/<vendor>/session a browser views the vendor as a World's person
16
17
  // POST twins/<vendor>/refresh observe the root now
17
18
  // and what a person looks into it through (docs/contributing/architecture.md, "Viewing a World"):
18
19
  // GET map | timeline | diff every twin and what it holds; the logs merged newest first; the changes since the base
@@ -31,6 +32,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
31
32
  // One writer per world by construction: the doors run in this process, serialized per twin.
32
33
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
33
34
  import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
35
+ import { BROWSER_SESSION_HEADER, BROWSER_SESSION_PATH, stripBrowserSessionPermit, withBrowserSessionPermit, type DerivedManifest } from '@volter/world-core/runtime';
34
36
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
35
37
  import { packFacts } from './pack-facts.ts';
36
38
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
@@ -168,6 +170,7 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
168
170
  /** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
169
171
  * session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
170
172
  * is this machine's own page (attributed to the machine). */
173
+ type VendorSessionPass = { vendor: string; person: string | null; land: string; asker: string };
171
174
  type PagesGrant = { scope: 'read' | 'write'; session?: string; who?: string; framer?: string; local?: true; /** the hosted site it
172
175
  * opens (a hostname this World's twins answer), not the content plane */ site?: string; /** a parent World's session that asked
173
176
  * for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
@@ -450,7 +453,7 @@ export class WorldDoors {
450
453
  * token: it uses the twins at its scope and nothing else. One per session that gave it a pass (every frame of a board
451
454
  * shares it), kept in the World's store, ended with that session; framed only by the console that asked. */
452
455
  private onPages(url: URL): boolean { const at = this.host.pagesOrigin?.() ?? null; return at !== null && url.host === new URL(at).host; }
453
- private readonly pagePasses = new Map<string, PagesGrant & { until: number }>();
456
+ private readonly pagePasses = new Map<string, PagesGrant & { until: number; viewAs?: VendorSessionPass }>();
454
457
  private pagesPath(): string { return join(this.worldRoot, stateDirName(), 'pages-sessions.json'); }
455
458
  private pagesHeld: Record<string, PagesGrant & { until: number }> | null = null;
456
459
  private pagesSessions(): Record<string, PagesGrant & { until: number }> {
@@ -477,7 +480,9 @@ export class WorldDoors {
477
480
  }
478
481
  /** Whether what gave a content session its pass still stands: the control session that asked (ended, signed out, or
479
482
  * its shared link revoked, the content session ends with it), or this World's token (rotated, every one ends). */
480
- private pagesParentLive(grant: PagesGrant): boolean { return grant.session === undefined || this.issued().session(grant.session) !== null; }
483
+ private pagesParentLive(grant: PagesGrant): boolean {
484
+ return grant.session === undefined || this.issued().session(grant.session) !== null;
485
+ }
481
486
  /** The address that opens this World's content plane for a minute's one-time pass and lands on `land` (a path of this
482
487
  * World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
483
488
  pagesPass(scope: 'read' | 'write', land: string, grant: Omit<PagesGrant, 'scope'> = {}): string | null {
@@ -487,14 +492,14 @@ export class WorldDoors {
487
492
  return pass ? `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}` : null;
488
493
  }
489
494
  /** A one-time pass (a minute long) for the content plane or a hosted site; null when its asker holds fifty unused. */
490
- private mintPagesPass(scope: 'read' | 'write', grant: Omit<PagesGrant, 'scope'>): string | null {
495
+ private mintPagesPass(scope: 'read' | 'write', grant: Omit<PagesGrant, 'scope'>, viewAs?: VendorSessionPass): string | null {
491
496
  const now = Date.now();
492
497
  for (const [k, v] of this.pagePasses) if (v.until <= now) this.pagePasses.delete(k);
493
498
  const askerOf = (g: Omit<PagesGrant, 'scope'>): string => g.session ?? g.asker ?? g.framer ?? 'token';
494
499
  const asker = askerOf(grant);
495
500
  if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000) return null;
496
501
  const pass = mintToken('pass_');
497
- this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
502
+ this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000, ...(viewAs ? { viewAs } : {}) });
498
503
  return pass;
499
504
  }
500
505
  /** The hostname a hosted site address names (`{site}` template: `<label>--<world>--<org>.<suffix>`) among those this
@@ -540,13 +545,16 @@ export class WorldDoors {
540
545
  /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
541
546
  private async pagesPlane(request: Request, url: URL, /** a hosted site served here, not the content plane */ site?: string): Promise<Response> {
542
547
  const doors = `/-/${this.served}/pages/`;
543
- const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
548
+ const viewPath = url.pathname.startsWith(`/${this.served}/`) ? url.pathname.slice(`/${this.served}/`.length).split('/') : [];
549
+ const viewVendor = viewPath.length === 4 && viewPath[1] === '_twin' && viewPath[2] === 'view-as' && Object.hasOwn(this.up().services, viewPath[0]!) ? viewPath[0]! : null;
550
+ const door = viewVendor ? viewPath[3] : url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
551
+ const opening = viewVendor ? `/${this.served}/${viewVendor}/_twin/view-as/` : doors;
544
552
  const own = this.host.browserOrigin?.() ?? null;
545
553
  if (door === 'open' && request.method === 'GET') {
546
554
  // framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
547
555
  // framed only by the console that asked
548
556
  const consoles = [own, this.parentOrigin()].filter((x): x is string => Boolean(x)).join(' ') || "'none'";
549
- 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>`,
557
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}" data-session="${opening}session"${site ? ' data-site="1"' : ''}><script src="${opening}open.js"></script></body>`,
550
558
  { 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' } });
551
559
  }
552
560
  if (door === 'open.js' && request.method === 'GET') {
@@ -556,27 +564,42 @@ export class WorldDoors {
556
564
  history.replaceState(null, '', location.pathname);
557
565
  const pass = carried.get('pass') || '';
558
566
  const land = carried.get('land') || '';
567
+ const viewAs = carried.get('viewAs') === '1';
568
+ const tell = (data) => { if (viewAs && parent !== window) parent.postMessage({ type: 'volter-view-as', pass, ...data }, ${JSON.stringify(own)}); };
559
569
  // resolved as the browser will (encoded dots too), on this origin and under this World
560
570
  let to = null;
561
571
  // a hosted site lands anywhere on it; the content plane only under this World
562
572
  const anywhere = document.body.dataset.site === '1';
563
573
  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 {}
564
574
  if (!pass || !to) { document.body.textContent = 'Nothing to open.'; return; }
565
- fetch('/-/' + world + '/pages/session', { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
566
- .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
567
- .catch(() => { document.body.textContent = 'This World did not answer.'; });
575
+ fetch(document.body.dataset.session, { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
576
+ .then(async (r) => {
577
+ const opened = await r.json();
578
+ if (!r.ok) throw new Error(opened.error || 'This World did not open.');
579
+ if (opened.viewAs) { tell({ viewed: opened.viewAs }); location.replace(opened.viewAs.land); }
580
+ else if (viewAs) throw new Error('This pass did not sign in to the vendor.');
581
+ else location.replace(to);
582
+ })
583
+ .catch((e) => { document.body.textContent = e.message || 'This World did not answer.'; tell({ error: document.body.textContent }); });
568
584
  })();
569
585
  `;
570
586
  return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
571
587
  }
572
588
  if (door === 'session' && request.method === 'POST') {
573
589
  // only this plane's own opening page asks (a browser sets the header; a page elsewhere cannot)
574
- if (request.headers.get('sec-fetch-site') !== 'same-origin') return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
575
590
  const pass = request.headers.get('x-volter-pages-pass') ?? '';
576
591
  const given = this.pagePasses.get(pass);
577
592
  this.pagePasses.delete(pass);
593
+ if (request.headers.get('sec-fetch-site') !== 'same-origin') return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
578
594
  if (!given || given.until <= Date.now() || !this.pagesParentLive(given) || (given.site ?? null) !== (site ?? null)) return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
579
- const { until: _until, ...grant } = given;
595
+ const { until: _until, viewAs, ...grant } = given;
596
+ if (viewAs) {
597
+ const held = this.pageSession(request, url);
598
+ if (!held) return Response.json({ error: 'Open a vendor page from this World first, then choose View as again.' }, { status: 403 });
599
+ if (viewVendor !== viewAs.vendor || !given.session || held.scope !== 'write' || held.session !== given.session) return Response.json({ error: 'View as belongs to the browser session that asked for it' }, { status: 403 });
600
+ return this.vendorSession(request, viewAs, given.until);
601
+ }
602
+ if (viewVendor) return Response.json({ error: 'this door takes only a View as pass' }, { status: 403 });
580
603
  // one content session per asker at a scope and framer: a board's frames opening together share it
581
604
  const same = (x: PagesGrant): boolean => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who && x.site === grant.site;
582
605
  const now = Date.now();
@@ -584,7 +607,9 @@ export class WorldDoors {
584
607
  const id = found ?? mintToken('pgs_');
585
608
  // twelve hours from its last opening while the session that asked stands; an hour where that cannot be known
586
609
  this.keepPagesSessions({ ...this.pagesSessions(), [id]: { ...grant, until: now + (grant.session !== undefined || grant.asker === undefined ? 12 : 1) * 3_600_000 } });
587
- return Response.json({ world: this.served, scope: grant.scope }, { headers: { 'set-cookie': `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned` } });
610
+ const headers = new Headers({ 'cache-control': 'no-store' });
611
+ headers.append('set-cookie', `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned`);
612
+ return Response.json({ world: this.served, scope: grant.scope }, { headers });
588
613
  }
589
614
  // A HOSTED SITE: read with a session its World's own origin gave it a pass for (sites/go), never without
590
615
  if (site) {
@@ -627,6 +652,61 @@ export class WorldDoors {
627
652
  return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
628
653
  }
629
654
 
655
+ /** A landing resolved as a browser resolves it, within this vendor's place only. */
656
+ private vendorLanding(vendor: string, land: unknown): string | null {
657
+ if (typeof land !== 'string' || !land.startsWith('/') || land.startsWith('//') || /[\\\s]/.test(land)) return null;
658
+ try {
659
+ const url = new URL(land, 'https://world.local');
660
+ const path = decodeURIComponent(url.pathname);
661
+ const base = `/${this.served}/${vendor}/`;
662
+ if (url.origin !== 'https://world.local' || !url.pathname.startsWith(base) || !path.startsWith(base)
663
+ || path.includes('\\') || path.split('/').some((p) => p === '.' || p === '..' || p.includes('%'))) return null;
664
+ return url.pathname + url.search + url.hash;
665
+ } catch { return null; }
666
+ }
667
+ /** The pack's declared browser session, never inferred from its vendor or a sign-in page. */
668
+ private async browserSessionOf(vendor: string): Promise<NonNullable<DerivedManifest['browserSession']> | null> {
669
+ const twin = this.host.twinFetch(vendor);
670
+ if (!twin || !this.host.pagesOrigin?.()) return null;
671
+ try {
672
+ const answer = await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } }));
673
+ const manifest = answer.ok ? await answer.json() as { browserSession?: unknown; drawsScreens?: boolean } : null;
674
+ if (!manifest?.drawsScreens) return null;
675
+ const session = manifest?.browserSession;
676
+ if (!session || typeof session !== 'object') return null;
677
+ const { cookie, scope } = session as { cookie?: unknown; scope?: unknown };
678
+ if (typeof cookie !== 'string' || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(cookie)
679
+ || /^(?:__Host-|(?:__Secure-)?volter_(?:world|pages)$)/i.test(cookie) || (scope !== undefined && typeof scope !== 'string')) return null;
680
+ return { cookie, ...(typeof scope === 'string' ? { scope } : {}) };
681
+ } catch { return null; }
682
+ }
683
+ private vendorPeople(vendor: string): Array<{ id: string; label: string; email: string }> {
684
+ return readTree(this.stateOf(vendor), this.controlRoot(vendor))
685
+ .filter((p) => p.type === '_person' && p.deleted !== true && typeof p.email === 'string')
686
+ .map((p) => ({ id: p.id, email: String(p.email), label: String(p.email) }));
687
+ }
688
+ /** The twin receives its own cookie and changes the session in its own process, over a host-only permit. */
689
+ private async vendorSession(request: Request, pass: VendorSessionPass, until: number): Promise<Response> {
690
+ const twin = this.host.twinFetch(pass.vendor);
691
+ if (!twin || !await this.browserSessionOf(pass.vendor) || !this.vendorLanding(pass.vendor, pass.land)) return Response.json({ error: 'that vendor session is unavailable' }, { status: 404 });
692
+ const person = pass.person === null ? null : this.vendorPeople(pass.vendor).find((p) => p.id === pass.person);
693
+ if (person === undefined) return Response.json({ error: 'that person is no longer in the World' }, { status: 404 });
694
+ const url = new URL(request.url);
695
+ const headers = new Headers({ [TWIN_PREFIX_HEADER]: `/${this.served}/${pass.vendor}`, 'x-forwarded-host': url.host, 'x-forwarded-proto': url.protocol.slice(0, -1) });
696
+ headers.set(CALLER_HEADER, callerForHeader(pass.asker));
697
+ headers.set('traceparent', traceparentForDelivery(request.headers.get('traceparent')) ?? newTraceparent());
698
+ const cookies = (request.headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
699
+ if (cookies.length) headers.set('cookie', cookies.join('; '));
700
+ const answer = await withBrowserSessionPermit(this.stateOf(pass.vendor), this.controlRoot(pass.vendor), { person: pass.person, land: pass.land, until }, async (permit) => {
701
+ headers.set(BROWSER_SESSION_HEADER, permit);
702
+ return twin(new Request(`${url.origin}${BROWSER_SESSION_PATH}`, { method: 'POST', headers, redirect: 'manual' }));
703
+ });
704
+ if (answer.status !== 303) return answer;
705
+ const out = new Headers({ 'cache-control': 'no-store' });
706
+ this.keepCookiesHere(answer, out);
707
+ return Response.json({ viewAs: { vendor: pass.vendor, person: pass.person, label: person?.email ?? 'Signed out', land: pass.land } }, { headers: out });
708
+ }
709
+
630
710
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
631
711
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
632
712
  * keeps them; each lasts 30 days from its opening. */
@@ -971,6 +1051,7 @@ export class WorldDoors {
971
1051
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
972
1052
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
973
1053
  async site(request: Request, host: string): Promise<Response> {
1054
+ request = stripBrowserSessionPermit(request);
974
1055
  if (request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
975
1056
  const url = new URL(request.url);
976
1057
  for (const { vendor, hosts } of await this.claimedHosts(url.origin)) {
@@ -996,6 +1077,7 @@ export class WorldDoors {
996
1077
  }
997
1078
 
998
1079
  async handle(request: Request): Promise<Response> {
1080
+ request = stripBrowserSessionPermit(request);
999
1081
  try {
1000
1082
  if (!this.host.ready() && !new URL(request.url).pathname.startsWith('/-/ping')) return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
1001
1083
  const url = new URL(request.url);
@@ -1123,6 +1205,7 @@ export class WorldDoors {
1123
1205
  // the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
1124
1206
  // request's log entries carry, so each entry is joined to its caller by an id no caller chose
1125
1207
  headers.delete(CALLER_HEADER);
1208
+ headers.delete(BROWSER_SESSION_HEADER);
1126
1209
  // the address a request was signed for (a CLI's loopback bridge says it, @volter/world-core/http-bridge) is a
1127
1210
  // credential holder's word: a browser's session cannot name another, so a signature is checked on the path it reached
1128
1211
  if (via === 'session') { headers.delete(ORIGINAL_PATH_HEADER); headers.delete('x-volter-twin-original-host'); }
@@ -1182,7 +1265,7 @@ export class WorldDoors {
1182
1265
  // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
1183
1266
  // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
1184
1267
  const caller = new Headers(request.headers);
1185
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
1268
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, BROWSER_SESSION_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
1186
1269
  // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
1187
1270
  const authorization = caller.get('authorization');
1188
1271
  const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
@@ -1657,6 +1740,26 @@ export class WorldDoors {
1657
1740
  return Response.json({ world: this.served, twins });
1658
1741
  }
1659
1742
  if (kind === 'twins' && name) {
1743
+ if (parts.length === 3 && (verb === 'people' || verb === 'session')) {
1744
+ const session = Object.hasOwn(this.up().services, name) ? await this.browserSessionOf(name) : null;
1745
+ if (verb === 'people' && request.method === 'GET') return Response.json({ viewAs: session !== null, people: session ? this.vendorPeople(name).map(({ id, label }) => ({ id, label })) : [] }, { headers: { 'cache-control': 'no-store' } });
1746
+ if (verb === 'session' && request.method === 'POST') {
1747
+ if (!session) return Response.json({ error: `${name} needs a declared browser session, a drawn screen and a content plane for View as` }, { status: 409 });
1748
+ if (via !== 'session' || presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }).length || crossSite || site !== 'same-origin') return Response.json({ error: 'View as is opened from this World’s own page' }, { status: 403 });
1749
+ const body = await request.json().catch(() => null) as { person?: unknown; land?: unknown } | null;
1750
+ const land = this.vendorLanding(name, body?.land);
1751
+ if (!body || (body.person !== null && typeof body.person !== 'string') || !land) return Response.json({ error: 'a session names a person (or null) and a path within that vendor' }, { status: 400 });
1752
+ if (body.person !== null && !this.vendorPeople(name).some((p) => p.id === body.person)) return Response.json({ error: 'no such person in this World' }, { status: 404 });
1753
+ const at = this.host.pagesOrigin?.();
1754
+ if (!at) return Response.json({ error: 'View as needs this World’s content plane' }, { status: 409 });
1755
+ const asked = this.credential(request);
1756
+ if (!asked.session) return Response.json({ error: 'View as needs this World’s browser session' }, { status: 403 });
1757
+ const pass = this.mintPagesPass('write', { session: asked.session, ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin!()! } : {}) }, { vendor: name, person: body.person, land, asker: this.callerOf(request)! });
1758
+ if (!pass) return Response.json({ error: 'too many pages opened at once: try again in a minute' }, { status: 429 });
1759
+ return Response.json({ url: `${at}/${this.served}/${name}/_twin/view-as/open#${new URLSearchParams({ pass, land, viewAs: '1' }).toString()}` }, { headers: { 'cache-control': 'no-store' } });
1760
+ }
1761
+ }
1762
+
1660
1763
  if (verb === 'root' && request.method === 'GET') return Response.json({ twin: name, root: rootForControlRoot(this.controlRoot(name), name) ?? null, credential: sealedCredentialInfo(this.worldRoot, name) });
1661
1764
  if (verb === 'root' && request.method === 'PUT') {
1662
1765
  const body = (await request.json()) as RootConfig | null;