@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.
- package/dist/src/redirect-proxy.js +3 -2
- package/dist/src/reflect.js +2 -0
- package/dist/src/served-world.d.ts +7 -4
- package/dist/src/served-world.js +140 -13
- package/package.json +4 -4
- package/src/redirect-proxy.ts +3 -2
- package/src/reflect.ts +2 -0
- package/src/served-world.ts +116 -13
|
@@ -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`);
|
package/dist/src/reflect.js
CHANGED
|
@@ -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. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -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) {
|
|
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
|
|
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="${
|
|
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(
|
|
574
|
-
.then((r) => {
|
|
575
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
72
|
-
"@volter/world-console": "3.0.
|
|
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.
|
|
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"
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -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)
|
package/src/served-world.ts
CHANGED
|
@@ -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 {
|
|
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'
|
|
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
|
|
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="${
|
|
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(
|
|
566
|
-
.then((r) => {
|
|
567
|
-
|
|
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
|
-
|
|
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;
|