@volter/world-runtime 3.0.13 → 3.0.14

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.
@@ -46,7 +46,7 @@ export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServe
46
46
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.js';
47
47
  export { followParentPins, LocalBranches } from './local-branches.js';
48
48
  export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.js';
49
- export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.js';
49
+ export { LOCAL_ORIGIN_BASE, localWorldOrigin, pagesOfHost, pagesOriginOf, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.js';
50
50
  export type { ViewConsole, WorldView } from './world-view.js';
51
51
  export type { LocalBranchesOptions } from './local-branches.js';
52
52
  export type { ServedWorld } from './served-world.js';
package/dist/src/index.js CHANGED
@@ -33,7 +33,7 @@ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCreden
33
33
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from "./served-world.js";
34
34
  export { followParentPins, LocalBranches } from "./local-branches.js";
35
35
  export { serveWorldView, VIEW_CONSOLE_BASE } from "./world-view.js";
36
- export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
36
+ export { LOCAL_ORIGIN_BASE, localWorldOrigin, pagesOfHost, pagesOriginOf, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
37
37
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor } from "./console-apart.js";
38
38
  export { assertWorldStateRemovable } from "./ancestry.js";
39
39
  export { withoutVaultSignIn } from "./runtime.js";
@@ -227,7 +227,7 @@ export declare function renderEnvFile(plan: InitPlan): string;
227
227
  /** Write the plan's two artifacts. Byte-identical for the same repo + catalog: sorted keys, no
228
228
  * timestamps, no absolute paths inside the files. */
229
229
  /** What `.volter/.gitignore` says: the story is committed, the running state never is. */
230
- export declare const STATE_GITIGNORE = "# Volter: the world's running state and live env \u2014 never committed. world.json, handlers/ and seeds/ are the story.\nworlds/\ncurrent\n*.env\ncredentials/\ntoken\ntoken.read\nsessions.json\nkeys.json\n";
230
+ export declare const STATE_GITIGNORE = "# Volter: the world's running state and live env \u2014 never committed. world.json, handlers/ and seeds/ are the story.\nworlds/\ncurrent\n*.env\ncredentials/\ntoken\ntoken.read\nsessions.json\npages-sessions.json\nkeys.json\n";
231
231
  export declare function writeWorldInit(plan: InitPlan, options?: {
232
232
  force?: boolean;
233
233
  }): void;
package/dist/src/init.js CHANGED
@@ -1087,6 +1087,7 @@ credentials/
1087
1087
  token
1088
1088
  token.read
1089
1089
  sessions.json
1090
+ pages-sessions.json
1090
1091
  keys.json
1091
1092
  `;
1092
1093
  export function writeWorldInit(plan, options = {}) {
@@ -84,15 +84,10 @@ export class LocalBranches {
84
84
  create: (at, ttl, _origin, parentKey, byKey) => this.create(parent, at, ttl, parentKey, byKey),
85
85
  remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent)
86
86
  return false; await this.remove(name); return true; },
87
- // a branch of this parent only, served in this process beside it
88
- show: async (name, request, vendor, rest, prefix) => {
87
+ // a branch of this parent only, served in this process beside it: its own content plane's pass
88
+ pagesPass: async (name, scope, land, grant) => {
89
89
  const w = this.o.worlds.get(name);
90
- const r = w ? this.record(w) : null;
91
- if (!w || r?.from !== parent)
92
- return Response.json({ error: `no branch ${name} of ${parent}` }, { status: 404 });
93
- if (!r.at?.instant || r.at.label || r.at.live)
94
- return Response.json({ error: `${name} is not a copy as of an instant: its pages are shown at its own address` }, { status: 403 });
95
- return await w.shown(request, vendor, rest, prefix);
90
+ return w && this.record(w)?.from === parent ? w.pagesPass(scope, land, grant) : null;
96
91
  },
97
92
  };
98
93
  }
@@ -170,7 +165,7 @@ export class LocalBranches {
170
165
  }
171
166
  const createdAt = new Date().toISOString();
172
167
  const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
173
- // what it was made as (a pull request's preview is labelled and may be live): what may be shown of it rests on that
168
+ // what it was made as (a pull request's preview is labelled and may be live)
174
169
  const made = { ...(at.instant ? { instant: at.instant } : {}), ...(at.label ? { label: at.label } : {}), ...(at.live ? { live: true } : {}) };
175
170
  writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: Object.keys(made).length ? made : null, createdAt, expiresAt }, null, 2)}\n`);
176
171
  this.o.announce?.(`branch ${name} of ${parent}${at.instant ? ` as of ${at.instant}` : ''}${expiresAt ? `, until ${expiresAt}` : ''}`);
@@ -57,17 +57,36 @@ export type MountedWorld = {
57
57
  readToken: string;
58
58
  };
59
59
  handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */
60
- site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.shown */
61
- shown: (request: Request, vendor: string, rest: string, prefix: string) => Promise<Response>;
60
+ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */
61
+ pagesPass: (scope: 'read' | 'write', land: string, grant?: {
62
+ who?: string;
63
+ framer?: string;
64
+ asker?: string;
65
+ }) => string | null;
62
66
  boot: (url: string) => Promise<void>;
63
67
  stop: () => Promise<void>;
64
68
  };
69
+ /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
70
+ /** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
71
+ * session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
72
+ * is this machine's own page (attributed to the machine). */
73
+ type PagesGrant = {
74
+ scope: 'read' | 'write';
75
+ session?: string;
76
+ who?: string;
77
+ framer?: string;
78
+ local?: true; /** a parent World's session that asked
79
+ * for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
80
+ * cannot tell when it ends, so such a session lasts an hour */
81
+ asker?: string;
82
+ };
65
83
  export declare function mountWorld(name: string, opts?: {
66
84
  passIssuers?: TrustedIssuer[];
67
85
  root?: string; /** the host's branches of this World, by its served name */
68
86
  branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
69
87
  browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */
70
- sitesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
88
+ sitesOrigin?: (served: string) => string | null; /** DoorHost.pagesOrigin */
89
+ pagesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
71
90
  localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
72
91
  localPerson?: string; /** DoorHost.serveBranch */
73
92
  serveBranch?: (branch: string) => Promise<void>;
@@ -122,6 +141,10 @@ export type DoorHost = {
122
141
  * host gives each World one: its session lives only there (docs/contributing/architecture.md,
123
142
  * "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
124
143
  browserOrigin?: () => string | null;
144
+ /** THE CONTENT PLANE: the origin this World's twins' pages are shown at for a browser (`<world>--<org>--pages`), apart
145
+ * from the origin of its doors and its session (docs/contributing/architecture.md, "Viewing a World"): a twin's page
146
+ * runs there with a session that uses the twins and opens no door. Null, or absent, where Worlds share one origin. */
147
+ pagesOrigin?: () => string | null;
125
148
  /** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
126
149
  * (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
127
150
  sitesOrigin?: () => string | null;
@@ -173,9 +196,13 @@ export type BranchDoors = {
173
196
  expiresAt: string | null;
174
197
  }>;
175
198
  remove: (name: string) => Promise<boolean>;
176
- /** a page of a branch, read as its parent shows it (a vendor's screen at `prefix`, the parent's own path for it): where
177
- * the host holds the branch beside its parent; absent, a branch's pages are seen only at the branch's own address */
178
- show?: (name: string, request: Request, vendor: string, rest: string, prefix: string) => Promise<Response>;
199
+ /** the address that opens a branch's content plane with a one-time pass at `scope`, landing on `land` (a path of the
200
+ * branch): how its parent's console frames the branch's pages beside its own (the comparison) */
201
+ pagesPass?: (name: string, scope: 'read' | 'write', land: string, grant?: {
202
+ who?: string;
203
+ framer?: string;
204
+ asker?: string;
205
+ }) => Promise<string | null>;
179
206
  };
180
207
  /** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
181
208
  * label: lowercase letters, digits and single hyphens, 63 at most). A host maps a label back by the
@@ -196,6 +223,31 @@ export declare class WorldDoors {
196
223
  constructor(name: string, worldRoot: string, configRef: string, served: string, host: DoorHost, token: string, readToken: string);
197
224
  /** New tokens; a request presenting the old ones is refused from the next call on. */
198
225
  retoken(token: string, readToken: string): void;
226
+ /** THE CONTENT PLANE (DoorHost.pagesOrigin): a browser sees this World's twins' pages at an origin apart from its doors
227
+ * and its session, so a twin's page (another app's content, a pack's markup) runs with nothing that opens a door. A
228
+ * page there holds a session of its own, opened from a one-time pass the World's own origin gives its session or
229
+ * token: it uses the twins at its scope and nothing else. One per session that gave it a pass (every frame of a board
230
+ * shares it), kept in the World's store, ended with that session; framed only by the console that asked. */
231
+ private onPages;
232
+ private readonly pagePasses;
233
+ private pagesPath;
234
+ private pagesHeld;
235
+ private pagesSessions;
236
+ private keepPagesSessions;
237
+ private pagesIgnored;
238
+ /** The origin of the World this one is a branch of, whose console frames its pages beside its own: none for a World
239
+ * that is no branch (its record, written by the host that made it). */
240
+ private parentOrigin;
241
+ /** Whether what gave a content session its pass still stands: the control session that asked (ended, signed out, or
242
+ * its shared link revoked, the content session ends with it), or this World's token (rotated, every one ends). */
243
+ private pagesParentLive;
244
+ /** 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
+ * World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
246
+ pagesPass(scope: 'read' | 'write', land: string, grant?: Omit<PagesGrant, 'scope'>): string | null;
247
+ private pagesCookie;
248
+ private pageSession;
249
+ /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
250
+ private pagesPlane;
199
251
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
200
252
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
201
253
  * keeps them; each lasts 30 days from its opening. */
@@ -308,11 +360,6 @@ export declare class WorldDoors {
308
360
  vendor: string;
309
361
  hosts: string[];
310
362
  }>>;
311
- /** A vendor's page of this World as another World shows it at `prefix` (a parent showing its branch, the comparison's
312
- * "before"): read with this World's read token (a twin that enforces the read scope refuses any write), its links
313
- * minted under `prefix`.
314
- * The host calls it for the parent, which has checked its own caller; nothing of this World's session is involved. */
315
- shown(request: Request, vendor: string, rest: string, prefix: string): Promise<Response>;
316
363
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
317
364
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
318
365
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -436,3 +483,4 @@ export declare function localWorld(target: string): {
436
483
  served: string;
437
484
  instance: WorldInstance;
438
485
  };
486
+ export {};
@@ -189,6 +189,7 @@ export async function mountWorld(name, opts = {}) {
189
189
  ...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
190
190
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
191
191
  ...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin(served) } : {}),
192
+ ...(opts.pagesOrigin ? { pagesOrigin: () => opts.pagesOrigin(served) } : {}),
192
193
  ...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
193
194
  ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
194
195
  ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
@@ -213,7 +214,7 @@ export async function mountWorld(name, opts = {}) {
213
214
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
214
215
  handle: (request) => world.handle(request),
215
216
  site: (request, host) => world.site(request, host),
216
- shown: (request, vendor, rest, prefix) => world.shown(request, vendor, rest, prefix),
217
+ pagesPass: (scope, land, grant) => world.pagesPass(scope, land, grant),
217
218
  async boot(url) {
218
219
  keep();
219
220
  const port = Number(new URL(url).port) || 0;
@@ -417,7 +418,154 @@ export class WorldDoors {
417
418
  }
418
419
  /** New tokens; a request presenting the old ones is refused from the next call on. */
419
420
  // new tokens end everything the old ones reached: every session and every named key
420
- retoken(token, readToken) { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); }
421
+ retoken(token, readToken) { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); this.keepPagesSessions({}); this.pagePasses.clear(); }
422
+ /** THE CONTENT PLANE (DoorHost.pagesOrigin): a browser sees this World's twins' pages at an origin apart from its doors
423
+ * and its session, so a twin's page (another app's content, a pack's markup) runs with nothing that opens a door. A
424
+ * page there holds a session of its own, opened from a one-time pass the World's own origin gives its session or
425
+ * token: it uses the twins at its scope and nothing else. One per session that gave it a pass (every frame of a board
426
+ * shares it), kept in the World's store, ended with that session; framed only by the console that asked. */
427
+ onPages(url) { const at = this.host.pagesOrigin?.() ?? null; return at !== null && url.host === new URL(at).host; }
428
+ pagePasses = new Map();
429
+ pagesPath() { return join(this.worldRoot, stateDirName(), 'pages-sessions.json'); }
430
+ pagesHeld = null;
431
+ pagesSessions() {
432
+ if (this.pagesHeld === null) {
433
+ try {
434
+ this.pagesHeld = JSON.parse(getActiveWorldStore().read(this.pagesPath()) ?? '{}');
435
+ }
436
+ catch {
437
+ this.pagesHeld = {};
438
+ }
439
+ }
440
+ return this.pagesHeld;
441
+ }
442
+ keepPagesSessions(all) {
443
+ const now = Date.now();
444
+ this.pagesHeld = Object.fromEntries(Object.entries(all).filter(([, x]) => x.until > now).sort(([, a], [, b]) => b.until - a.until).slice(0, 2000));
445
+ getActiveWorldStore().write(this.pagesPath(), `${JSON.stringify(this.pagesHeld)}\n`, { secret: true });
446
+ // never committed, as a World's other sessions are not: a `.volter/.gitignore` written before this file existed names it
447
+ if (!this.pagesIgnored) {
448
+ this.pagesIgnored = true;
449
+ const ignore = join(this.worldRoot, stateDirName(), '.gitignore');
450
+ const held = getActiveWorldStore().read(ignore);
451
+ if (held !== null && !held.split('\n').includes('pages-sessions.json'))
452
+ getActiveWorldStore().write(ignore, `${held.replace(/\n?$/, '\n')}pages-sessions.json\n`);
453
+ }
454
+ }
455
+ pagesIgnored = false;
456
+ /** The origin of the World this one is a branch of, whose console frames its pages beside its own: none for a World
457
+ * that is no branch (its record, written by the host that made it). */
458
+ parentOrigin() {
459
+ try {
460
+ const from = JSON.parse(getActiveWorldStore().read(join(this.worldRoot, stateDirName(), 'branch.json')) ?? 'null')?.from;
461
+ return from ? this.host.originOf?.(from) ?? null : null;
462
+ }
463
+ catch {
464
+ return null;
465
+ }
466
+ }
467
+ /** Whether what gave a content session its pass still stands: the control session that asked (ended, signed out, or
468
+ * its shared link revoked, the content session ends with it), or this World's token (rotated, every one ends). */
469
+ pagesParentLive(grant) { return grant.session === undefined || this.issued().session(grant.session) !== null; }
470
+ /** The address that opens this World's content plane for a minute's one-time pass and lands on `land` (a path of this
471
+ * World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
472
+ pagesPass(scope, land, grant = {}) {
473
+ const at = this.host.pagesOrigin?.() ?? null;
474
+ if (at === null)
475
+ return null;
476
+ const now = Date.now();
477
+ for (const [k, v] of this.pagePasses)
478
+ if (v.until <= now)
479
+ this.pagePasses.delete(k);
480
+ const askerOf = (g) => g.session ?? g.asker ?? g.framer ?? 'token';
481
+ const asker = askerOf(grant);
482
+ if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000)
483
+ return null;
484
+ const pass = mintToken('pass_');
485
+ this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
486
+ return `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}`;
487
+ }
488
+ pagesCookie(url) { return url.protocol === 'https:' ? '__Host-volter_pages' : 'volter_pages'; }
489
+ pageSession(request, url) {
490
+ const id = new RegExp(`(?:^|;\\s*)${this.pagesCookie(url)}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
491
+ const held = id && Object.hasOwn(this.pagesSessions(), id) ? this.pagesSessions()[id] : undefined;
492
+ if (!id || !held || held.until <= Date.now() || !this.pagesParentLive(held))
493
+ return null;
494
+ const { until: _until, ...grant } = held;
495
+ return { id, ...grant };
496
+ }
497
+ /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
498
+ async pagesPlane(request, url) {
499
+ const doors = `/-/${this.served}/pages/`;
500
+ const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
501
+ const own = this.host.browserOrigin?.() ?? null;
502
+ if (door === 'open' && request.method === 'GET') {
503
+ // framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
504
+ // framed only by the console that asked
505
+ 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' } });
507
+ }
508
+ if (door === 'open.js' && request.method === 'GET') {
509
+ const script = `(() => {
510
+ const world = document.body.dataset.world;
511
+ const carried = new URLSearchParams(location.hash.slice(1));
512
+ history.replaceState(null, '', location.pathname);
513
+ const pass = carried.get('pass') || '';
514
+ const land = carried.get('land') || '';
515
+ // resolved as the browser will (encoded dots too), on this origin and under this World
516
+ let to = null;
517
+ try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
518
+ if (!pass || !to) { document.body.textContent = 'Nothing to open.'; return; }
519
+ fetch('/-/' + world + '/pages/session', { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
520
+ .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
521
+ .catch(() => { document.body.textContent = 'This World did not answer.'; });
522
+ })();
523
+ `;
524
+ return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
525
+ }
526
+ if (door === 'session' && request.method === 'POST') {
527
+ // only this plane's own opening page asks (a browser sets the header; a page elsewhere cannot)
528
+ if (request.headers.get('sec-fetch-site') !== 'same-origin')
529
+ return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
530
+ const pass = request.headers.get('x-volter-pages-pass') ?? '';
531
+ const given = this.pagePasses.get(pass);
532
+ this.pagePasses.delete(pass);
533
+ if (!given || given.until <= Date.now() || !this.pagesParentLive(given))
534
+ return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
535
+ const { until: _until, ...grant } = given;
536
+ // 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;
538
+ const now = Date.now();
539
+ const found = Object.entries(this.pagesSessions()).find(([, x]) => x.until > now && same(x))?.[0];
540
+ const id = found ?? mintToken('pgs_');
541
+ // twelve hours from its last opening while the session that asked stands; an hour where that cannot be known
542
+ this.keepPagesSessions({ ...this.pagesSessions(), [id]: { ...grant, until: now + (grant.session !== undefined || grant.asker === undefined ? 12 : 1) * 3_600_000 } });
543
+ return Response.json({ world: this.served, scope: grant.scope }, { headers: { 'set-cookie': `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned` } });
544
+ }
545
+ if (url.pathname.startsWith(`/${this.served}/`) && !url.pathname.startsWith(`/${this.served}/.well-known/`)) {
546
+ const answer = await this.wire(request, url, url.pathname.slice(`/${this.served}/`.length));
547
+ if (answer.status === 101)
548
+ return answer;
549
+ const out = new Headers(answer.headers);
550
+ // framed by the console whose session asked, and by the page itself
551
+ const framer = this.pageSession(request, url)?.framer ?? own;
552
+ out.append('content-security-policy', `frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
553
+ // a twin's own sign-in kept in the frame a console shows it in, which may be another site (each *.localhost is):
554
+ // its cookies are partitioned by that console, as this plane's own session is
555
+ const cookies = answer.headers.getSetCookie();
556
+ if (cookies.length) {
557
+ out.delete('set-cookie');
558
+ for (const c of cookies)
559
+ out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
560
+ }
561
+ return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
562
+ }
563
+ // a navigation elsewhere (a twin sending the person to its root, `/`) goes to the World's own origin, as it always did
564
+ if (own && request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '') && !url.pathname.startsWith('/-/')) {
565
+ return new Response(null, { status: 303, headers: { location: `${own}${url.pathname}${url.search}`, 'cache-control': 'no-store' } });
566
+ }
567
+ return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
568
+ }
421
569
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
422
570
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
423
571
  * keeps them; each lasts 30 days from its opening. */
@@ -670,6 +818,12 @@ export class WorldDoors {
670
818
  };
671
819
  }
672
820
  credential(request) {
821
+ // on the content plane only its own session counts: a page there presents nothing that opens a door
822
+ const at = new URL(request.url);
823
+ if (this.onPages(at)) {
824
+ const held = this.pageSession(request, at);
825
+ return held ? { scope: held.scope, via: 'session', token: null, session: `pages:${held.id}`, ...(held.who ? { who: held.who } : {}) } : { scope: null, via: null, token: null };
826
+ }
673
827
  const presented = presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER });
674
828
  const name = this.sessionCookieName(new URL(request.url));
675
829
  // the name is one of two fixed literals (sessionCookieName); the separator browsers use is "; "
@@ -720,7 +874,7 @@ export class WorldDoors {
720
874
  return null;
721
875
  // this machine's own page with no one signed in here is this machine; another session with no name is just that
722
876
  if (cred.via === 'session')
723
- return cred.who ? `person:${cred.who}` : cred.session && this.sessions()[cred.session]?.local ? 'machine' : 'person';
877
+ return cred.who ? `person:${cred.who}` : cred.session && (cred.session.startsWith('pages:') ? this.pagesSessions()[cred.session.slice(6)]?.local : this.sessions()[cred.session]?.local) ? 'machine' : 'person';
724
878
  // a key acts as itself, for its person (docs/contributing/architecture.md, "Who acted is recorded")
725
879
  if (cred.via === 'key') {
726
880
  const held = this.keyOf(cred.token);
@@ -817,31 +971,6 @@ export class WorldDoors {
817
971
  }
818
972
  return out;
819
973
  }
820
- /** A vendor's page of this World as another World shows it at `prefix` (a parent showing its branch, the comparison's
821
- * "before"): read with this World's read token (a twin that enforces the read scope refuses any write), its links
822
- * minted under `prefix`.
823
- * The host calls it for the parent, which has checked its own caller; nothing of this World's session is involved. */
824
- async shown(request, vendor, rest, prefix) {
825
- if (request.method !== 'GET' && request.method !== 'HEAD')
826
- return Response.json({ error: "a copy's pages are read: GET and HEAD" }, { status: 405 });
827
- if (!this.host.ready())
828
- return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
829
- // a twin's pages only: a link forwards to the vendor itself, never something shown on another World's origin
830
- if (this.link(vendor) || !this.host.twinFetch(vendor))
831
- return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
832
- const url = new URL(request.url);
833
- // only what a browser's read of a page says, as a site's read (site()): never a caller's credential, cookie, or
834
- // the signed-address headers a session may not name
835
- const headers = new Headers();
836
- for (const name of ['accept', 'accept-language', 'if-none-match', 'if-modified-since', 'range', 'user-agent', 'sec-fetch-dest']) {
837
- const v = request.headers.get(name);
838
- if (v !== null)
839
- headers.set(name, v);
840
- }
841
- headers.set(TOKEN_HEADER, this.readToken);
842
- const at = new URL(`${url.origin}/${this.served}/${vendor}/${rest}${url.search}`);
843
- return await this.wire(new Request(at, { method: request.method, headers, redirect: 'manual' }), at, `${vendor}/${rest}`, prefix);
844
- }
845
974
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
846
975
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
847
976
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
@@ -890,6 +1019,8 @@ export class WorldDoors {
890
1019
  const prefix = `/${this.served}/`;
891
1020
  if (url.pathname === '/-/ping')
892
1021
  return Response.json({ ok: true, world: this.served, worlds: [this.served] });
1022
+ if (this.onPages(url))
1023
+ return await this.pagesPlane(request, url);
893
1024
  if (url.pathname.startsWith(`/-/${this.served}/`))
894
1025
  return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
895
1026
  // the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
@@ -972,21 +1103,40 @@ export class WorldDoors {
972
1103
  return enforced;
973
1104
  }
974
1105
  /** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
975
- async wire(request, url, rest, /** the path its twin's pages are shown at, when not this World's own */ shownAt) {
1106
+ async wire(request, url, rest) {
976
1107
  const [vendor, ...tail] = rest.split('/');
977
1108
  const { scope, via } = this.credential(request);
978
1109
  const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
1110
+ const browserAt = this.host.browserOrigin?.() ?? null;
1111
+ if (scope === null && this.onPages(url) && browserAt && request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '')) {
1112
+ return new Response(null, { status: 303, headers: { location: `${browserAt}${url.pathname}${url.search}`, 'cache-control': 'no-store' } });
1113
+ }
979
1114
  if (scope === null && !manifestAsk)
980
1115
  return Response.json({ error: 'token required' }, { status: 401 });
981
1116
  const foreign = this.crossOriginSession(request, via);
982
1117
  if (foreign)
983
1118
  return foreign;
984
1119
  const reads = request.method === 'GET' || request.method === 'HEAD';
1120
+ // THE TWO PLANES: a browser's session never sees a twin's page on the World's own origin. Its navigation there goes to
1121
+ // the content plane with a pass (a frame or a tab alike); anything else its pages would send is refused. A page on the
1122
+ // content plane that has no session yet is sent back here to get one
1123
+ const navigation = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
1124
+ const pagesAt = this.host.pagesOrigin?.() ?? null;
1125
+ if (pagesAt !== null && !this.onPages(url) && via === 'session') {
1126
+ const asked = this.credential(request);
1127
+ const to = navigation ? this.pagesPass(scope, `${url.pathname}${url.search}`, { ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}), ...(asked.session && this.sessions()[asked.session]?.local ? { local: true } : {}) }) : null;
1128
+ if (to)
1129
+ return new Response(null, { status: 303, headers: { location: to, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
1130
+ return Response.json({ error: `${this.served}'s twins' pages are shown at ${pagesAt}` }, { status: 403 });
1131
+ }
985
1132
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
986
1133
  // the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
987
1134
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
988
1135
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
989
1136
  const link = this.link(vendor);
1137
+ // a link spends the World's sealed vendor credential: never from the content plane, where any twin's markup runs
1138
+ if (link && this.onPages(url))
1139
+ return Response.json({ error: `${vendor} is linked to the vendor itself: it is used through the World's own origin` }, { status: 404 });
990
1140
  if (link) {
991
1141
  if (scope === 'read' && !reads)
992
1142
  return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
@@ -1023,13 +1173,13 @@ export class WorldDoors {
1023
1173
  if (enforced)
1024
1174
  headers.set(READ_ONLY_HEADER, '1');
1025
1175
  // the browser's World session is the World's credential, never the twin's (a tunnel would hand it on)
1026
- const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_world=/.test(c));
1176
+ const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
1027
1177
  if (cookies.length)
1028
1178
  headers.set('cookie', cookies.join('; '));
1029
1179
  else
1030
1180
  headers.delete('cookie');
1031
1181
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
1032
- headers.set(TWIN_PREFIX_HEADER, shownAt ?? `/${this.served}/${vendor}`);
1182
+ headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
1033
1183
  headers.set('x-forwarded-host', url.host);
1034
1184
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
1035
1185
  // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
@@ -1062,10 +1212,10 @@ export class WorldDoors {
1062
1212
  // above): caches keep them apart
1063
1213
  out.append('vary', 'Sec-Fetch-Dest');
1064
1214
  const setCookies = answer.headers.getSetCookie();
1065
- if (setCookies.some((c) => /^\s*(?:__Host-)?volter_world=/i.test(c))) {
1215
+ if (setCookies.some((c) => /^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))) {
1066
1216
  out.delete('set-cookie');
1067
1217
  for (const c of setCookies)
1068
- if (!/^\s*(?:__Host-)?volter_world=/i.test(c))
1218
+ if (!/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))
1069
1219
  out.append('set-cookie', c);
1070
1220
  }
1071
1221
  return new Response(answer.body, { status: answer.status, headers: out });
@@ -1176,35 +1326,6 @@ export class WorldDoors {
1176
1326
  // ending a browser's session needs no token: it only clears the caller's own cookie, which never
1177
1327
  // reaches these /-/ paths, so a page holding just the session could not otherwise sign out
1178
1328
  const sessionPath = path.replace(/\/+$/, '');
1179
- // THE HAND-OFF: a page of no UI that opens this World's session from the token in its fragment, then goes to the page
1180
- // of this World it names (`land`): how a page elsewhere links to this World's own pages (the console's comparison
1181
- // opening a vendor's page in a copy of the World, read only). The token never reaches the server in a URL; its script
1182
- // is a file of this origin, so the page keeps a strict policy. It is never framed: a session opened in another
1183
- // site's frame is not this World's own page's
1184
- if (sessionPath === 'session/hand-off' && request.method === 'GET') {
1185
- return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="/-/${this.served}/session/hand-off.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 'none'", 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
1186
- }
1187
- if (sessionPath === 'session/hand-off.js' && request.method === 'GET') {
1188
- const script = `(() => {
1189
- const world = document.body.dataset.world;
1190
- const carried = new URLSearchParams(location.hash.slice(1));
1191
- history.replaceState(null, '', location.pathname);
1192
- const token = carried.get('token') || '';
1193
- const land = carried.get('land') || '';
1194
- // resolved as the browser will (encoded dots too), on this origin and under this World
1195
- let to = null;
1196
- try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
1197
- const open = () => fetch('/-/' + world + '/session', { method: 'POST', headers: { 'x-volter-token': token } });
1198
- if (!token || !to) { document.body.textContent = 'Nothing to open.'; return; }
1199
- // a session this browser already holds here is kept: a page elsewhere cannot replace, downgrade or fix it
1200
- fetch('/-/' + world + '/session')
1201
- .then((held) => (held.ok ? held : open()))
1202
- .then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
1203
- .catch(() => { document.body.textContent = 'This World did not answer.'; });
1204
- })();
1205
- `;
1206
- return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
1207
- }
1208
1329
  // a person signing in from a page (the console's form, posted to this origin): the token in the
1209
1330
  // form body, the session set here, then on to a page of this World
1210
1331
  // a form or beacon from another site never opens or ends a session here (the console, on this host's
@@ -1282,34 +1403,6 @@ export class WorldDoors {
1282
1403
  return this.session(request, url, scope, via === 'key' ? this.presentedKey(request) : undefined);
1283
1404
  if (kind === 'keys')
1284
1405
  return this.keysDoor(request, name, scope, via, this.credential(request).session);
1285
- // A COPY'S PAGES SHOWN HERE: `copies/<branch>/<vendor>/…`, a vendor's page in a branch of this World, read on this
1286
- // World's origin under its caller's credential (the comparison frames its "before" beside "now" this way: the
1287
- // branch's own session opens only on the branch's own pages). Only a copy as of an instant, which only a writer of
1288
- // this World makes, so its pages run here with nothing a stranger wrote (a labelled or live branch, a pull request's
1289
- // preview written by the app it tests, is shown at its own address). Read only; no cookie the copy sets is kept
1290
- if (kind === 'copies' && name && verb) {
1291
- const show = this.host.branches?.show;
1292
- if (!show)
1293
- return Response.json({ error: "this host shows a copy's pages only at the copy's own address" }, { status: 501 });
1294
- const [org] = this.served.split('/');
1295
- // the path after the vendor as it came, empty segments and a trailing slash kept (an object key may hold `//`)
1296
- const full = `${org}/${name}`;
1297
- // made by the World's token or a person's session: a key's copy is written by whoever holds the key, and its pages
1298
- // would run here under the viewer's session
1299
- const madeBy = this.keys().find((k) => k.branch === full && (!k.expiresAt || Date.parse(k.expiresAt) > Date.now()))?.createdBy?.via;
1300
- if (madeBy !== 'token' && madeBy !== 'session')
1301
- return Response.json({ error: `${full} was not made by this World's token or a person here: its pages are shown at its own address` }, { status: 403 });
1302
- // the path after the vendor as it came (empty segments and a trailing slash kept: an object key may hold `//`)
1303
- const exact = /^copies\/[^/]+\/[^/]+(?:\/(.*))?$/.exec(path);
1304
- if (!exact)
1305
- return Response.json({ error: 'copies/<branch>/<vendor>/<path>' }, { status: 404 });
1306
- const answer = await show(full, request, verb, exact[1] ?? '', `/-/${this.served}/copies/${name}/${verb}`);
1307
- if (answer.status < 200)
1308
- return Response.json({ error: "a copy's pages are read, never upgraded" }, { status: 400 });
1309
- const out = new Headers(answer.headers);
1310
- out.delete('set-cookie');
1311
- return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
1312
- }
1313
1406
  if (scope === 'read' && request.method !== 'GET')
1314
1407
  return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1315
1408
  if (kind === 'clock')
@@ -1892,6 +1985,23 @@ export class WorldDoors {
1892
1985
  catch { /* a World with no local branches */ }
1893
1986
  return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1894
1987
  }
1988
+ // A BRANCH'S PAGES (`GET branches/<org>/<branch>/pages?land=<path>`): a navigation to the branch's own content plane
1989
+ // with a one-time read pass, for a person or the World's token: how the console frames a branch's page beside this
1990
+ // World's (its comparison); the branch's pages never run on this World's origin
1991
+ if (rest.length === 3 && rest[2] === 'pages' && request.method === 'GET') {
1992
+ if (via !== 'session' && via !== 'token')
1993
+ return Response.json({ error: "a branch's pages open for a person or the World's token" }, { status: 403 });
1994
+ const name = `${decodeURIComponent(rest[0])}/${decodeURIComponent(rest[1])}`;
1995
+ if (!(await branches.list()).some((b) => b.name === name))
1996
+ return Response.json({ error: `no branch ${name} of ${this.served}` }, { status: 404 });
1997
+ const land = url.searchParams.get('land') ?? `/${name}/`;
1998
+ const asked = this.credential(request);
1999
+ const mark = asked.session ? createHash('sha256').update(`${this.served}\0${asked.session}`).digest('base64url').slice(0, 22) : 'token';
2000
+ const to = branches.pagesPass ? await branches.pagesPass(name, 'read', land, { asker: mark, ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) }) : null;
2001
+ if (!to)
2002
+ return Response.json({ error: `this host shows ${name}'s pages only at its own address` }, { status: 501 });
2003
+ return new Response(null, { status: 303, headers: { location: to, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
2004
+ }
1895
2005
  // SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
1896
2006
  if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
1897
2007
  if (!this.host.serveBranch)
@@ -2,6 +2,15 @@ export declare const LOCAL_ORIGIN_BASE = "localhost";
2
2
  /** The origin a browser reaches `served` at on a local host listening on `port`, or null when its
3
3
  * names make no label. */
4
4
  export declare function localWorldOrigin(served: string, port: number, base?: string): string | null;
5
+ /** THE CONTENT PLANE's label beside a World's own: `<world>--<org>--pages`, where a browser sees the World's twins'
6
+ * pages apart from its doors and its session. A World of an org named `pages` (`<world>--pages`) is no content plane:
7
+ * its label names no World once the suffix is taken off, so it falls through to the World it is. */
8
+ export declare const PAGES_LABEL_SUFFIX = "--pages";
9
+ /** The content plane's origin for `served` on a host whose World origins are `<label>.<base>`, or null when its names
10
+ * make no label. */
11
+ export declare function pagesOriginOf(served: string, protocol: string, base: string, port?: number | string): string | null;
12
+ /** Which World's content plane a request's host names, or undefined for any other host. */
13
+ export declare function pagesOfHost(hostname: string, served: Iterable<string>, base?: string): string | undefined;
5
14
  /** Which World a request's host names: undefined for a host that is not a World's origin (the host's
6
15
  * own, path-addressed), null for a World-origin-shaped host naming no World served here. */
7
16
  export declare function worldOfHost(hostname: string, served: Iterable<string>, base?: string): string | null | undefined;