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