@volter/world-runtime 2.0.1 → 2.0.2

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.
Files changed (63) hide show
  1. package/dist/src/app-url.js +1 -1
  2. package/dist/src/catalog.js +1 -1
  3. package/dist/src/cli.js +3 -3
  4. package/dist/src/console-apart.d.ts +1 -0
  5. package/dist/src/console-apart.js +7 -0
  6. package/dist/src/covers.js +4 -2
  7. package/dist/src/host-worker.js +2 -9
  8. package/dist/src/host.js +2 -9
  9. package/dist/src/import-module.d.ts +1 -0
  10. package/dist/src/import-module.js +16 -0
  11. package/dist/src/index.d.ts +7 -2
  12. package/dist/src/index.js +4 -1
  13. package/dist/src/infra-cli.js +2 -2
  14. package/dist/src/init.d.ts +1 -1
  15. package/dist/src/init.js +3 -0
  16. package/dist/src/local-branches.d.ts +37 -0
  17. package/dist/src/local-branches.js +193 -0
  18. package/dist/src/pglite-backing.js +8 -5
  19. package/dist/src/prerequisites.js +1 -1
  20. package/dist/src/process-groups.js +1 -1
  21. package/dist/src/redirect-proxy.js +2 -2
  22. package/dist/src/root.d.ts +23 -0
  23. package/dist/src/root.js +22 -10
  24. package/dist/src/run-task.js +1 -1
  25. package/dist/src/runtime.js +15 -15
  26. package/dist/src/schema.d.ts +5 -0
  27. package/dist/src/schema.js +10 -1
  28. package/dist/src/served-world.d.ts +196 -9
  29. package/dist/src/served-world.js +847 -103
  30. package/dist/src/service-recorder.js +1 -1
  31. package/dist/src/storage-capacity.js +2 -2
  32. package/dist/src/up-task.js +1 -1
  33. package/dist/src/world-origins.d.ts +13 -0
  34. package/dist/src/world-origins.js +37 -0
  35. package/dist/src/world-view.d.ts +25 -0
  36. package/dist/src/world-view.js +110 -0
  37. package/package.json +6 -5
  38. package/src/app-url.ts +1 -1
  39. package/src/catalog.ts +1 -1
  40. package/src/cli.ts +3 -3
  41. package/src/console-apart.ts +7 -1
  42. package/src/covers.ts +4 -2
  43. package/src/host-worker.ts +2 -1
  44. package/src/host.ts +2 -1
  45. package/src/import-module.ts +9 -0
  46. package/src/index.ts +7 -2
  47. package/src/infra-cli.ts +2 -2
  48. package/src/init.ts +3 -0
  49. package/src/local-branches.ts +173 -0
  50. package/src/pglite-backing.ts +8 -5
  51. package/src/prerequisites.ts +1 -1
  52. package/src/process-groups.ts +1 -1
  53. package/src/redirect-proxy.ts +2 -2
  54. package/src/root.ts +27 -2
  55. package/src/run-task.ts +1 -1
  56. package/src/runtime.ts +15 -15
  57. package/src/schema.ts +11 -1
  58. package/src/served-world.ts +762 -85
  59. package/src/service-recorder.ts +1 -1
  60. package/src/storage-capacity.ts +2 -2
  61. package/src/up-task.ts +1 -1
  62. package/src/world-origins.ts +38 -0
  63. package/src/world-view.ts +100 -0
@@ -1,4 +1,4 @@
1
- import { getActiveWorldStore, withAncestryLock, captureHistory, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } from '@volter/world-core';
1
+ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } from '@volter/world-core';
2
2
  // A SERVED WORLD (docs/reference/http-api.md#a-served-world): `volter world serve` puts a world at
3
3
  // `http://<host>:<port>/<org>/<world>/`. Each twin's vendor API is under its vendor id — the
4
4
  // addressed grammar RH2 depends on — and the world's own doors are under `/-/<org>/<world>/`:
@@ -14,23 +14,33 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyDigest, h
14
14
  // GET/PUT twins/<vendor>/root the vendor's real account behind the twin (PUT null clears)
15
15
  // PUT twins/<vendor>/credential seal the credential; GET answers only that one is sealed
16
16
  // POST twins/<vendor>/refresh observe the root now
17
- // Tokens ride `x-volter-token`: the token opens everything, the read token only GET. `serve` mints
18
- // both, prints them once and writes them to `.volter/token` and `.volter/token.read` (0600).
17
+ // and what a person looks into it through (docs/contributing/architecture.md, "Viewing a World"):
18
+ // GET map | timeline | diff every twin and what it holds; the logs merged newest first; the changes since the base
19
+ // GET/PUT clock, POST clock/advance the frozen instant every twin stamps from, forward only
20
+ // GET/POST branches, DELETE branches/<org>/<world> the host's branches of this World ("as of" views)
21
+ // GET history?at=<instant> each twin's history cut at an instant (where such a branch starts)
22
+ // GET /<org>/<world>/<vendor>/mirror/ the twin's mirror, keyless: the vendor's own UI over this World's wire
23
+ // Tokens ride `x-volter-token`: the token opens everything, the read token only reads — on the vendor
24
+ // wire, a twin that enforces it (`requestScopes: ['read']`) is handed the read token's other requests
25
+ // marked read-only and refuses the writes itself. `serve` mints both, prints them once and writes them
26
+ // to `.volter/token` and `.volter/token.read` (0600).
19
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
20
28
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
21
29
  import { blobDigest, getActiveBlobStore, loadCheck, mirrorShellUnder, openSealedCredential, readTwinRequestJournal, sealingKey, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
22
30
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
31
+ import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
32
+ import { createHash } from 'node:crypto';
23
33
  import { dirname, join, resolve } from 'node:path';
24
34
  import {
25
- appendActionIfAbsent, branchEntries, confirmAction, readTree, stateDirName, wholeLog, worldNow,
35
+ appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
26
36
  parentEntries, rebaseBranch, type Changeset, type Entry, type Receipt, type RootConfig,
27
37
  } from '@volter/world-core';
28
- import { approveWorldChangeset, createWorldChangeset, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
38
+ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
29
39
  import { fetchFromOrigin } from './origin.ts';
30
40
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
31
41
  import { loadWorldConfig } from './configs.ts';
32
- import { adaptersFor, packMirrorExports, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from './root.ts';
33
- import { downWorld, statusWorld, upWorld } from './runtime.ts';
42
+ import { adaptersFor, credentialPath, packMirror, type PackMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from './root.ts';
43
+ import { clockFile, downWorld, statusWorld, upWorld } from './runtime.ts';
34
44
  import type { WorldInstance } from './schema.ts';
35
45
 
36
46
  export const TOKEN_HEADER = 'x-volter-token';
@@ -64,6 +74,24 @@ export function readServeRecord(worldRoot: string): ServeRecord | null {
64
74
 
65
75
  export type ServedWorld = { url: string; base: string; name: string; port: number; token: string; readToken: string; console: string | null; stop: () => Promise<void> };
66
76
 
77
+ /** Where a World keeps its browser sessions (WorldDoors): a host that rotates the tokens without the doors at hand
78
+ * clears it itself. */
79
+ export function sessionsFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'sessions.json'); }
80
+ /** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
81
+ export function keysFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'keys.json'); }
82
+ /** A named key as the World keeps it: the SHA-256 of the key, what it may do, and when it was made and last used. */
83
+ type HeldKey = { id: string; name: string; scope: 'read' | 'write'; hash: string; createdAt: string; lastUsedAt?: string;
84
+ /** who made it: the World's token, or a person's session (the subject a pass named, and its platform) */
85
+ createdBy?: { via: 'token' } | { via: 'session'; sub?: string; who?: string; issuer?: string } | { via: 'key'; keyId: string };
86
+ /** the person it was made for, when the World's token made it on their behalf (a platform, for a command) */
87
+ for?: string;
88
+ /** after this it opens nothing */
89
+ expiresAt?: string;
90
+ /** the branch this key is the parent's key of: the branch reaches its parent with it, and goes when it goes */
91
+ branch?: string;
92
+ /** the key that made that branch: revoking it removes the branch too */
93
+ madeWith?: string };
94
+ const keyHash = (key: string): string => createHash('sha256').update(key).digest('hex');
67
95
  function mintToken(prefix: string): string { return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`; }
68
96
  function writeSecret(path: string, value: string): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${value}\n`, { mode: 0o600 }); chmodSync(path, 0o600); }
69
97
 
@@ -85,7 +113,10 @@ function configRefFor(worldRoot: string, name: string): string {
85
113
  * `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
86
114
  * with the call — and keeps them beside the world like `boot` does. */
87
115
  export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
88
- export async function mountWorld(name: string, opts: { root?: string } = {}): Promise<MountedWorld> {
116
+ /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
117
+ type HeldSession = { scope: 'read' | 'write'; until: number; 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 };
118
+
119
+ 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; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean } = {}): Promise<MountedWorld> {
89
120
  const worldRoot = resolve(opts.root ?? process.cwd());
90
121
  const configRef = configRefFor(worldRoot, name);
91
122
  if (loadWorldConfig(configRef, worldRoot).config.resources) {
@@ -110,6 +141,15 @@ export async function mountWorld(name: string, opts: { root?: string } = {}): Pr
110
141
  const declared = loadWorldConfig(configRef, worldRoot).config.services.find((s) => s.id === vendor)?.colocate?.scenarioPath;
111
142
  return declared ? resolve(worldRoot, declared) : null;
112
143
  },
144
+ // a local mirror is the pack's own, built by its builder (at serve under Bun, prebuilt under Node)
145
+ mirror: (vendor, file) => localMirrorFile(worldRoot, vendor, file),
146
+ hasMirror: async (vendor) => (await localMirror(worldRoot, vendor)) !== null,
147
+ mirrorSignIn: async (vendor, as, ctx) => { const mirror = await localMirror(worldRoot, vendor); try { return (await mirror?.signIn?.(as, ctx)) ?? null; } catch { return null; } },
148
+ clockPath: () => clockFile(worldRoot, name),
149
+ ...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
150
+ ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
151
+ ...(opts.localTrust ? { localTrust: () => true } : {}),
152
+ ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
113
153
  // a local twin is its own process: the wire forwards to the port it listens on
114
154
  twinFetch: (vendor) => {
115
155
  const target = instance?.services[vendor]?.url;
@@ -145,6 +185,29 @@ export async function mountWorld(name: string, opts: { root?: string } = {}): Pr
145
185
  };
146
186
  }
147
187
 
188
+ /** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
189
+ const localMirrors = new Map<string, Promise<PackMirror | null>>();
190
+ const builtMirrorFiles = new Map<string, Promise<string | null>>();
191
+ function localMirror(worldRoot: string, vendor: string): Promise<PackMirror | null> {
192
+ const key = `${worldRoot}\0${vendor}`;
193
+ let held = localMirrors.get(key);
194
+ if (!held) { held = packMirror(vendor, worldRoot).catch(() => null); localMirrors.set(key, held); }
195
+ return held;
196
+ }
197
+ async function localMirrorFile(worldRoot: string, vendor: string, file: string): Promise<Response | null> {
198
+ const mirror = await localMirror(worldRoot, vendor);
199
+ if (!mirror) return null;
200
+ if (file === 'index.html') return new Response(mirror.html(), { headers: { 'content-type': 'text/html; charset=utf-8' } });
201
+ const build = file === 'assets/app.js' ? mirror.client : file === 'assets/styles.css' ? mirror.styles : undefined;
202
+ if (!build) return null;
203
+ const key = `${worldRoot}\0${vendor}\0${file}`;
204
+ let held = builtMirrorFiles.get(key);
205
+ // a failed build is not kept: the next request builds again
206
+ if (!held) { held = build().catch(() => { builtMirrorFiles.delete(key); return null; }); builtMirrorFiles.set(key, held); }
207
+ const body = await held;
208
+ return body === null ? null : new Response(body, { headers: { 'content-type': file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8' } });
209
+ }
210
+
148
211
  /** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
149
212
  export async function serveWorld(name: string, opts: { root?: string; port?: number; host?: string; consolePort?: number; announce?: (info: { name: string; base: string; token: string; readToken: string; console: string | null }) => void } = {}): Promise<ServedWorld> {
150
213
  const mounted = await mountWorld(name, opts);
@@ -213,14 +276,224 @@ export type DoorHost = {
213
276
  /** Where this host reads a twin's scenario (the handlers document a generative twin serves from,
214
277
  * re-read on every request), or null when the twin takes none here. */
215
278
  scenarioPath?: (vendor: string) => string | null;
279
+ /** A twin's mirror as its pack builds it: `index.html` (the shell) or `assets/<file>`, or null when
280
+ * the twin has none here. Every host answers the same mount (`/<served>/<vendor>/mirror/`); only
281
+ * where the files come from differs (built at serve under Bun, prebuilt under Node, static assets
282
+ * on Cloudflare). */
283
+ mirror?: (vendor: string, file: string) => Promise<Response | null>;
284
+ /** Whether the twin has a mirror on this host, without building it. */
285
+ hasMirror?: (vendor: string) => boolean | Promise<boolean>;
286
+ /** The account and token a twin's screens open signed in as, resolved by its pack from the twin's state at `root`
287
+ * for the account the World's config names (`signIn.as`); null when the pack cannot, or has no such account. */
288
+ mirrorSignIn?: (vendor: string, as: string, ctx: { root: string; twin: (path: string, init?: RequestInit) => Promise<Response> }) => Promise<{ account: string; token: string } | null>;
289
+ /** Where this World's clock lives in its store (the file every twin reads `worldNow()` from). */
290
+ clockPath?: () => string;
291
+ /** The origin a browser reaches this World at (`http://<world>--<org>.localhost:<port>`), when the
292
+ * host gives each World one: its session lives only there (docs/contributing/architecture.md,
293
+ * "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
294
+ browserOrigin?: () => string | null;
295
+ /** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
296
+ originOf?: (served: string) => string | null;
297
+ /** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
298
+ * loopback name, is given the World's session without a token (`localSession`). A remote host leaves it absent,
299
+ * and its pages sign in with a token. */
300
+ localTrust?: () => boolean;
301
+ /** The platforms whose passes open this World (docs/contributing/architecture.md, "The hosted product"): a person a
302
+ * trusted platform signed a pass for is given a session of the pass's scope. Absent, or empty, no pass opens it. */
303
+ passIssuers?: () => TrustedIssuer[];
304
+ /** This World's branches, where the host can make them (a branch is another World, so making one
305
+ * is the host's act); absent, the branches doors answer 404. */
306
+ branches?: BranchDoors;
307
+ };
308
+
309
+ /** A branch as its parent lists it. */
310
+ export type BranchRow = { name: string; from: string; at: { instant?: string; live?: boolean; label?: string } | null; createdAt: string; expiresAt: string | null };
311
+ /** What a host does for a World's branches doors. `create` makes a World cloned from this one at
312
+ * `at` (its history cut at that instant), removed when `ttlSeconds` runs out; `origin` is where the
313
+ * request reached this World (a host whose branches clone over its public URL uses it). */
314
+ export type BranchDoors = {
315
+ list: () => Promise<BranchRow[]>;
316
+ /** `parentKey`: the key the branch reaches this World with (never this World's own token), made for whoever asked */
317
+ create: (at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, origin: string, parentKey?: string) => Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }>;
318
+ remove: (name: string) => Promise<boolean>;
216
319
  };
217
320
 
321
+ /** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
322
+ * label: lowercase letters, digits and single hyphens, 63 at most). A host maps a label back by the
323
+ * Worlds it serves, never by parsing it. */
324
+ export function worldOriginLabel(served: string): string | null {
325
+ const [org, world] = served.split('/');
326
+ const part = /^[a-z0-9](?:[a-z0-9]|-(?!-))*[a-z0-9]$|^[a-z0-9]$/;
327
+ if (!org || !world || !part.test(org) || !part.test(world)) return null;
328
+ const label = `${world}--${org}`;
329
+ return label.length <= 63 ? label : null;
330
+ }
331
+
332
+ /** The header the doors set on a read-scope request to a twin: the twin refuses the caller's writes
333
+ * and still makes the vendor's own moves. It only restricts, so a twin trusts it without a token. */
334
+ export const READ_ONLY_HEADER = 'x-volter-read-only';
335
+ const MIRROR_ASSET = /^assets\/[A-Za-z0-9._-]+$/;
336
+ const SPAN = /^(\d+(?:\.\d+)?)(s|m|h|d)$/;
337
+ const SPAN_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const;
338
+ /** A W3C traceparent's trace id, or null when the value is not one. */
339
+ function traceIdOf(traceparent: unknown): string | null {
340
+ const m = typeof traceparent === 'string' ? /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/.exec(traceparent) : null;
341
+ return m && !/^0+$/.test(m[1]!) ? m[1]! : null;
342
+ }
343
+ type TimelineRow = { twin: string; position: number; entry: Entry };
344
+ /** Timeline order, newest first: by the entry's instant, then twin, then position. */
345
+ function newerFirst(a: { at: string; twin: string; position: number }, b: { at: string; twin: string; position: number }): number {
346
+ return b.at.localeCompare(a.at) || b.twin.localeCompare(a.twin) || b.position - a.position;
347
+ }
348
+
218
349
  export class WorldDoors {
219
350
  private readonly queues = new Map<string, Promise<unknown>>();
220
351
  token: string; readToken: string;
221
352
  constructor(readonly name: string, readonly worldRoot: string, readonly configRef: string, readonly served: string, private readonly host: DoorHost, token: string, readToken: string) { this.token = token; this.readToken = readToken; }
222
353
  /** New tokens; a request presenting the old ones is refused from the next call on. */
223
- retoken(token: string, readToken: string): void { this.token = token; this.readToken = readToken; }
354
+ // new tokens end everything the old ones reached: every session and every named key
355
+ retoken(token: string, readToken: string): void { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); this.signIns.clear(); }
356
+
357
+ /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
358
+ * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
359
+ * keeps them; each lasts 30 days from its opening. */
360
+ private sessionsPath(): string { return sessionsFile(this.worldRoot); }
361
+ private held: Record<string, HeldSession> | null = null;
362
+ private sessions(): Record<string, HeldSession> {
363
+ if (this.held === null) { try { this.held = JSON.parse(getActiveWorldStore().read(this.sessionsPath()) ?? '{}') as Record<string, HeldSession>; } catch { this.held = {}; } }
364
+ return this.held;
365
+ }
366
+ private keepSessions(all: Record<string, HeldSession>): void {
367
+ const now = Date.now();
368
+ // the expired go, and at most the 100 newest are kept (a browser that keeps losing its cookie cannot grow it)
369
+ this.held = Object.fromEntries(Object.entries(all).filter(([, s]) => s.until > now).sort(([, a], [, b]) => b.until - a.until).slice(0, 100));
370
+ getActiveWorldStore().write(this.sessionsPath(), `${JSON.stringify(this.held)}\n`, { secret: true });
371
+ }
372
+ /** A new session of `scope`: its id, the cookie's value. A token's lasts 30 days; a person's from a pass, `who`,
373
+ * lasts as long as the pass's platform says (twelve hours), so a person removed there loses it within the day. */
374
+ private openSession(scope: 'read' | 'write', person?: { who: string; ttlMs: number; issuer: string; sub?: string }, key?: { id: string; until: number }): string {
375
+ const id = mintToken('ses_');
376
+ let held = this.sessions();
377
+ // a person holds at most five sessions here: passes (anyone's with one) never crowd out everyone else's
378
+ if (person) { const theirs = Object.entries(held).filter(([, x]) => x.who === person.who).sort(([, a], [, b]) => b.until - a.until); if (theirs.length >= 5) { const drop = new Set(theirs.slice(4).map(([k]) => k)); held = Object.fromEntries(Object.entries(held).filter(([k]) => !drop.has(k))); } }
379
+ const until = Math.min(Date.now() + (person?.ttlMs ?? 30 * 86_400_000), key?.until ?? Infinity);
380
+ this.keepSessions({ ...held, [id]: { scope, until, ...(person ? { who: person.who, issuer: person.issuer, ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
381
+ return id;
382
+ }
383
+ /** NAMED KEYS: what an app or a script holds (docs/contributing/architecture.md, "The hosted product"), each named
384
+ * when made, shown once, kept only as its hash, and revoked alone; the World's two tokens stay its first keys. */
385
+ private heldKeys: HeldKey[] | null = null;
386
+ private keys(): HeldKey[] {
387
+ if (this.heldKeys === null) { try { this.heldKeys = JSON.parse(getActiveWorldStore().read(keysFile(this.worldRoot)) ?? '[]') as HeldKey[]; } catch { this.heldKeys = []; } }
388
+ return this.heldKeys;
389
+ }
390
+ private keepKeys(all: HeldKey[]): void { this.heldKeys = all; getActiveWorldStore().write(keysFile(this.worldRoot), `${JSON.stringify(all)}\n`, { secret: true }); }
391
+ /** When a key was last used, to the minute (a key in constant use is not a write per request). */
392
+ private touchKey(key: HeldKey): void {
393
+ const now = new Date(); if (key.lastUsedAt && now.getTime() - Date.parse(key.lastUsedAt) < 60_000) return;
394
+ this.keepKeys(this.keys().map((k) => (k.id === key.id ? { ...k, lastUsedAt: now.toISOString() } : k)));
395
+ }
396
+ /** `GET|POST /-/<world>/keys`, `DELETE /-/<world>/keys/<id>`, `DELETE /-/<world>/keys?person=<sub>`: named keys are
397
+ * managed with write access by the World's token or a person's session, never by a key (a key cannot make a key
398
+ * that outlives it). Each records who made it and whom it is for; the token may give one an end and a person, and
399
+ * revoke every key made by or for a person (their leaving the org). */
400
+ /** The person a key is for: whom it was made for, else the person whose session made it. */
401
+ private keyPerson(k: HeldKey): string | undefined { return k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.sub : undefined); }
402
+ /** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
403
+ * keys) a revoked key made. Answers the revoked keys' ids. */
404
+ private async revokeKeys(pick: (k: HeldKey) => boolean): Promise<string[]> {
405
+ const gone = this.keys().filter(pick);
406
+ const ids = new Set(gone.map((k) => k.id));
407
+ const also = this.keys().filter((k) => !ids.has(k.id) && k.madeWith !== undefined && ids.has(k.madeWith)); // branches a revoked key made
408
+ for (const k of also) ids.add(k.id);
409
+ this.keepKeys(this.keys().filter((k) => !ids.has(k.id)));
410
+ // the keys are gone whatever a removal answers; a branch that stays is said, not hidden (its parent key no longer opens this World)
411
+ for (const k of [...gone, ...also]) if (k.branch) await this.host.branches?.remove(k.branch).catch((error: unknown) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${error instanceof Error ? error.message : String(error)}`); return false; });
412
+ return [...ids];
413
+ }
414
+ private async keysDoor(request: Request, id: string | undefined, scope: 'read' | 'write', via: 'token' | 'key' | 'session' | null, session?: string): Promise<Response> {
415
+ if (scope !== 'write') return Response.json({ error: 'keys are managed with write access' }, { status: 403 });
416
+ if (via === 'key') return Response.json({ error: 'a key manages no keys: use the World\'s token, or its page' }, { status: 403 });
417
+ const now = Date.now();
418
+ const live = (): HeldKey[] => this.keys().filter((k) => !k.expiresAt || Date.parse(k.expiresAt) > now);
419
+ const view = (k: HeldKey) => ({ id: k.id, name: k.name, scope: k.scope, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt ?? null, expiresAt: k.expiresAt ?? null, for: k.for ?? null, person: this.keyPerson(k) ?? null, branch: k.branch ?? null, createdBy: k.createdBy?.via === 'session' ? (k.createdBy.who ?? k.createdBy.sub ?? 'a person') : k.createdBy?.via === 'key' ? `the key ${k.createdBy.keyId}` : k.createdBy ? 'the World\'s token' : null });
420
+ const person = (k: HeldKey): string | undefined => this.keyPerson(k);
421
+ if (!id && request.method === 'GET') return Response.json({ keys: live().map(view) });
422
+ if (!id && request.method === 'POST') {
423
+ const body = (await request.json().catch(() => ({}))) as { name?: unknown; scope?: unknown; for?: unknown; expiresAt?: unknown; replace?: unknown };
424
+ const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
425
+ if (!name) return Response.json({ error: 'a key needs a name: what holds it (an app, a CI job, a laptop)' }, { status: 400 });
426
+ const keyScope = body.scope === 'read' ? 'read' : 'write';
427
+ // a session a platform's pass opened makes no keys: a key outlives the session, and a script on this origin (a
428
+ // stored page) could make one in the person's name. Keys for a platform's Worlds are made on the platform.
429
+ const opener = via === 'session' && session ? this.sessions()[session] : undefined;
430
+ if (opener?.issuer) return Response.json({ error: 'a key for this World is made on the platform it was opened from, not from a browser session here', platform: opener.issuer }, { status: 403 });
431
+ // `for`, `replace` and an end are the token's to give (a platform making a command's key for a person)
432
+ if ((body.for !== undefined || body.replace !== undefined) && via !== 'token') return Response.json({ error: 'for and replace are the World token\'s' }, { status: 403 });
433
+ const forWhom = typeof body.for === 'string' && /^[A-Za-z0-9:._@-]{1,160}$/.test(body.for) ? body.for : undefined;
434
+ let expiresAt: string | undefined;
435
+ if (body.expiresAt !== undefined) { const t = typeof body.expiresAt === 'string' ? Date.parse(body.expiresAt) : NaN; if (!(t > now)) return Response.json({ error: 'expiresAt: an ISO instant in the future' }, { status: 400 }); expiresAt = new Date(t).toISOString(); }
436
+ // replace: the key of the same name for the same person goes (one command, one key: asking again does not pile them up)
437
+ let kept = live(); if (body.replace === true) kept = kept.filter((k) => !(k.name === name && person(k) === forWhom));
438
+ if (kept.length >= 200) return Response.json({ error: 'this World holds 200 keys: revoke some first' }, { status: 409 });
439
+ const opened = via === 'session' && session ? this.sessions()[session] : undefined;
440
+ const createdBy: HeldKey['createdBy'] = via === 'session' ? { via: 'session', ...(opened?.sub ? { sub: opened.sub } : {}), ...(opened?.who ? { who: opened.who } : {}), ...(opened?.issuer ? { issuer: opened.issuer } : {}) } : { via: 'token' };
441
+ const key = mintToken('tok_k_');
442
+ const held: HeldKey = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(expiresAt ? { expiresAt } : {}) };
443
+ this.keepKeys([...kept, held]);
444
+ return Response.json({ ...view(held), key }, { status: 201, headers: { 'cache-control': 'no-store' } });
445
+ }
446
+ if (!id && request.method === 'DELETE') {
447
+ // every key made by or for a person: their leaving the org ends what they hold here (the token's act)
448
+ const who = new URL(request.url).searchParams.get('person');
449
+ if (via !== 'token' || !who) return Response.json({ error: 'DELETE /keys?person=<subject>, with the World\'s token' }, { status: via !== 'token' ? 403 : 400 });
450
+ // their keys, the parent keys of the branches they made, and those branches
451
+ return Response.json({ revoked: await this.revokeKeys((k) => person(k) === who) });
452
+ }
453
+ if (id && request.method === 'DELETE') {
454
+ const found = this.keys().find((k) => k.id === id);
455
+ if (!found) return Response.json({ error: `no key ${id}` }, { status: 404 });
456
+ // a branch's parent key takes its branch with it, and a key takes the branches it made
457
+ await this.revokeKeys((k) => k.id === id);
458
+ return Response.json({ revoked: id, name: found.name });
459
+ }
460
+ return Response.json({ error: 'keys: GET or POST /keys, DELETE /keys/<id> or /keys?person=<subject>' }, { status: 405 });
461
+ }
462
+ /** Passes this World has already spent: each opens one session. */
463
+ private readonly spent = spentPasses();
464
+ /** A PASS opens a session: a person a trusted platform signed a pass for, into this World, at its own origin, asked
465
+ * from its own page (the console reads the pass from its address's fragment and posts it here). */
466
+ /** The org its host records as this World's owner (`<state>/owner`, written at its making or its claim), or null. */
467
+ private recordedOwner(): string | null { try { const o = getActiveWorldStore().read(join(this.worldRoot, stateDirName(), 'owner'))?.trim() ?? ''; return /^[A-Za-z0-9_-]{1,80}$/.test(o) ? o : null; } catch { return null; } }
468
+ private async passSession(request: Request, url: URL, pass: string): Promise<Response> {
469
+ const issuers = this.host.passIssuers?.() ?? [];
470
+ if (issuers.length === 0) return Response.json({ error: `${this.served} trusts no platform's passes` }, { status: 401 });
471
+ const own = this.host.browserOrigin?.() ?? null;
472
+ // a pass opens only a World with an origin of its own: on a shared origin every other World's pages would reach the
473
+ // session it opened
474
+ if (own === null) return Response.json({ error: `${this.served} has no origin of its own, where a pass could open it` }, { status: 409 });
475
+ if (this.sessionCookieName(url) === null) return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
476
+ const checked = await verifyPass(pass, { issuers, world: this.served, audience: own, seen: this.spent });
477
+ if (!checked.ok) return Response.json({ error: `that pass does not open ${this.served}: ${checked.reason}` }, { status: 401 });
478
+ // a World its host records an owner for opens only for that org: a platform that holds the World under another org
479
+ // (a slug taken again, a claim gone wrong) cannot open it
480
+ const owner = this.recordedOwner();
481
+ if (owner !== null && checked.claims.org !== owner) return Response.json({ error: `that pass does not open ${this.served}: it names another org than the one that owns it` }, { status: 401 });
482
+ const who = checked.claims.email ?? checked.claims.name ?? checked.claims.sub;
483
+ const cookie = this.sessionCookie(url, this.openSession(checked.claims.scope, { who, ttlMs: 12 * 3_600_000, issuer: checked.claims.iss, sub: checked.claims.sub }))!;
484
+ // `issuer`: the platform the pass came from, verified, which the page offers as the way back (never an address from
485
+ // its own URL, which anyone can write)
486
+ return Response.json({ world: this.served, scope: checked.claims.scope, who, issuer: checked.claims.iss, origin: own }, { headers: { 'set-cookie': cookie } });
487
+ }
488
+ /** A named key that still opens this World: not revoked, not expired. */
489
+ private liveKey(id: string): boolean { const k = this.keys().find((x) => x.id === id); return !!k && (!k.expiresAt || Date.parse(k.expiresAt) > Date.now()); }
490
+ /** The named key a request presents, with the end of its life. */
491
+ private presentedKey(request: Request): { id: string; until: number } | undefined {
492
+ const token = this.credential(request).token; const k = token ? this.keys().find((x) => sameSecret(keyHash(token), x.hash)) : undefined;
493
+ return k ? { id: k.id, until: k.expiresAt ? Date.parse(k.expiresAt) : Infinity } : undefined;
494
+ }
495
+ private endSession(id: string | null): void { if (id && id in this.sessions()) { const { [id]: _ended, ...rest } = this.sessions(); this.keepSessions(rest); } }
496
+ private endSessions(): void { if (Object.keys(this.sessions()).length) this.keepSessions({}); }
224
497
  /** The world's twins, by vendor, once booted. */
225
498
  twins(): string[] { return Object.keys(this.up().services); }
226
499
  private up(): WorldLayout { return this.host.layout(); }
@@ -228,29 +501,74 @@ export class WorldDoors {
228
501
  /** The world's token, presented the way the app's SDK presents a credential: the world's own header,
229
502
  * the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
230
503
  * GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
231
- private scope(request: Request): 'write' | 'read' | null { return this.match(request)?.scope ?? null; }
232
- /** The World token the request presented, in whichever form, and the scope it opens. */
233
- private match(request: Request): { scope: 'write' | 'read'; token: string; fromSession: boolean } | null {
234
- const authorization = request.headers.get('authorization') ?? '';
235
- const bearer = /^(?:Bearer|token)\s+(\S+)$/i.exec(authorization)?.[1];
236
- const basic = /^Basic\s+(\S+)$/i.exec(authorization)?.[1];
237
- const basicSecret = basic ? (() => { try { const text = Buffer.from(basic, 'base64').toString('utf8'); const i = text.indexOf(':'); return i >= 0 ? text.slice(i + 1) : text; } catch { return undefined; } })() : undefined;
238
- // a browser holding a session for this World (POST …/session): what a mirror page reads the wire with
239
- const cookie = /(?:^|;\s*)volter_world=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
240
- // every form presented is a candidate: a mirror page sends the VENDOR's bearer (the X token its
241
- // sign-in holds) beside the World's session cookie, and the cookie is what opens the World
242
- const headed = [request.headers.get(TOKEN_HEADER), request.headers.get(TWINS_KEY_HEADER), bearer, basicSecret];
243
- const session = cookie ? decodeURIComponent(cookie) : null;
244
- for (const [scope, token] of [['write', this.token], ['read', this.readToken]] as const) {
245
- if (headed.includes(token)) return { scope, token, fromSession: false };
246
- if (session === token) return { scope, token, fromSession: true };
247
- }
248
- return null;
504
+ private scope(request: Request): 'write' | 'read' | null { return this.credential(request).scope; }
505
+ /** The request's scope, whether it came by a token the caller presented or by a browser's session
506
+ * (the cookie `POST …/session` set), and the token that opened it. Every form presented is a
507
+ * candidate: a mirror page sends the VENDOR's bearer (the token its sign-in holds) beside the World's
508
+ * session cookie, and the cookie is what opens the World. With a browser origin of its own
509
+ * (`browserOrigin`), a World honours its session only on that origin: a session cookie on any other
510
+ * (a host's shared origin, path-addressed) opens nothing. */
511
+ /** What this World issued, for world-access to decide a grant against: its two tokens, its named keys, its sessions. */
512
+ private issued(): WorldCredentials {
513
+ return {
514
+ world: this.served, token: this.token, readToken: this.readToken,
515
+ // a NAMED KEY (an app's or a script's, revoked alone): known by its hash; it acts as the token of its scope
516
+ key: (p) => { if (!p.startsWith('tok_k_')) return null; const held = this.keys().find((k) => sameSecret(keyHash(p), k.hash)); if (!held || (held.expiresAt && Date.parse(held.expiresAt) <= Date.now())) return null; this.touchKey(held); return held.scope; },
517
+ // a session a shared link's read key opened ends with that key: revoked or expired, it opens nothing
518
+ session: (id) => { const opened = Object.hasOwn(this.sessions(), id) ? this.sessions()[id]! : null; return opened && opened.until > Date.now() && (!opened.key || this.liveKey(opened.key)) ? opened : null; },
519
+ };
520
+ }
521
+ private credential(request: Request): { scope: 'write' | 'read' | null; via: 'token' | 'key' | 'session' | null; token: string | null; /** the session's id, when the session opened it */ session?: string; /** who the session is */ who?: string } {
522
+ const presented = presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER });
523
+ const name = this.sessionCookieName(new URL(request.url));
524
+ // the name is one of two fixed literals (sessionCookieName); the separator browsers use is "; "
525
+ const cookie = name === null ? undefined : new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
526
+ // the decision is world-access's (the architecture's "access is decided in one place"); this World says what it issued
527
+ const grant = grantOf(presented, cookie ? decodeURIComponent(cookie) : null, this.issued());
528
+ if (!grant) return { scope: null, via: presented.length ? 'token' : null, token: null };
529
+ // `token` is only ever the secret the caller presented (a named key is answered as itself): the World's own tokens
530
+ // never reach a key's or a session's holder
531
+ if (grant.via === 'session') return { scope: grant.scope, via: 'session', token: null, session: grant.session!, ...(grant.who ? { who: grant.who } : {}) };
532
+ return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
533
+ }
534
+ /** The session cookie's name where `url` reached this World, or null where a session opens nothing:
535
+ * on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
536
+ * the path-scoped cookie a shared origin has always carried. */
537
+ private sessionCookieName(url: URL): string | null {
538
+ const own = this.host.browserOrigin?.() ?? null;
539
+ if (own === null) return 'volter_world';
540
+ if (url.host !== new URL(own).host) return null;
541
+ return url.protocol === 'https:' ? '__Host-volter_world' : 'volter_world';
542
+ }
543
+ /** The session cookie for `url`, set to `value`: host-only over the whole origin on the World's own
544
+ * origin (a `__Host-` cookie must be), scoped to the World's paths on a shared one. Null where a
545
+ * session opens nothing (another World's origin). */
546
+ private sessionCookie(url: URL, value: string, extra = ''): string | null {
547
+ const name = this.sessionCookieName(url);
548
+ if (name === null) return null;
549
+ const own = this.host.browserOrigin?.() ?? null;
550
+ return `${name}=${value}; Path=${own ? '/' : `/${this.served}/`}; HttpOnly; SameSite=Strict${url.protocol === 'https:' ? '; Secure' : ''}${extra}`;
551
+ }
552
+ /** A browser's session opens the World only to the World's own pages: a request the session cookie carries must
553
+ * say `Sec-Fetch-Site: same-origin` (a browser sets it; a page cannot), or be a read the person navigated to
554
+ * (`none`: a typed or bookmarked address). Another page of the same site (a sibling World's origin shares its
555
+ * suffix) is refused its reads too, so a twin that echoes the caller's Origin with credentials cannot hand one
556
+ * World's data to another World's page; a request with no header fails closed. Null when it may pass. */
557
+ private crossOriginSession(request: Request, via: 'token' | 'key' | 'session' | null): Response | null {
558
+ if (via !== 'session') return null;
559
+ const site = request.headers.get('sec-fetch-site');
560
+ if (site === 'same-origin') return null;
561
+ const read = request.method === 'GET' || request.method === 'HEAD';
562
+ if (read && site === 'none') return null;
563
+ return Response.json({ error: read
564
+ ? "a World's browser session reads only for the World's own pages or an address typed in (Sec-Fetch-Site: same-origin or none)"
565
+ : "a World's browser session writes only from the World's own pages (Sec-Fetch-Site: same-origin)" }, { status: 403 });
249
566
  }
250
567
  /** Open one connection on a byte-stream door for the holder of the World's (write) token; null when
251
568
  * the token is not the World's or the World has no such stream. */
252
569
  openStream(token: string, id: string, sink: TwinStreamSink, peer: string): TwinStreamConnection | null {
253
- if (token !== this.token || !this.host.openStream || !(id in (this.host.streams?.() ?? {}))) return null;
570
+ // the World's write token or a write key opens a stream (world-access decides, as for every door)
571
+ if (grantOf([token], null, this.issued())?.scope !== 'write' || !this.host.openStream || !(id in (this.host.streams?.() ?? {}))) return null;
254
572
  return this.host.openStream(id, sink, peer);
255
573
  }
256
574
  private serialized<T>(key: string, fn: () => Promise<T>): Promise<T> {
@@ -276,8 +594,8 @@ export class WorldDoors {
276
594
  // place under this World, and the token the injector carries to them — the presented one
277
595
  if (url.pathname === `${prefix}.well-known/volter-world` && request.method === 'GET') {
278
596
  // the manifest hands its caller the token it presented; a session's token is HttpOnly, never echoed to script
279
- const matched = this.match(request);
280
- if (matched === null || matched.fromSession) return Response.json({ error: 'token required' }, { status: 401 });
597
+ const matched = this.credential(request);
598
+ if (matched.token === null || matched.via === 'session') return Response.json({ error: 'token required' }, { status: 401 });
281
599
  const presented = matched.token;
282
600
  const vendors = Object.fromEntries(Object.keys(this.up().services).map((v) => [v, `${url.origin}/${this.served}/${v}`]));
283
601
  // a stream is reached through the attacher's loopback bridge (@volter/world-core/stream-bridge):
@@ -288,35 +606,127 @@ export class WorldDoors {
288
606
  const endpoints = Object.fromEntries(Object.keys(vendors).flatMap((v) => { const name = APP_READ_ENDPOINT_ENV[v]?.injectEnv; return name ? [[name, vendors[v]!]] : []; }));
289
607
  return Response.json({ name: this.served, vendors, ca: null, proxy: null, env: { ...endpoints, VOLTER_TWINS_KEY: presented }, ...(Object.keys(streams).length ? { streams } : {}) });
290
608
  }
291
- if (url.pathname.startsWith(prefix)) return await this.wire(request, url, url.pathname.slice(prefix.length));
609
+ if (url.pathname.startsWith(prefix)) {
610
+ const rest = url.pathname.slice(prefix.length);
611
+ const mirror = /^([a-z0-9_-]+)\/mirror(?:\/(.*))?$/.exec(rest);
612
+ if (mirror) return await this.mirror(request, url, mirror[1]!, mirror[2] ?? '');
613
+ return await this.wire(request, url, rest);
614
+ }
292
615
  return Response.json({ error: `not this world: serving /${this.served}/` }, { status: 404 });
293
616
  } catch (error) {
294
617
  return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
295
618
  }
296
619
  }
297
620
 
621
+ /** THE MIRROR MOUNT: `/<served>/<vendor>/mirror/` and `…/mirror/assets/<file>`, keyless and GET only —
622
+ * the pack's own shell and client. The shell's <base> is the twin's place in this World, so the
623
+ * mirror reads and writes the World's wire with the browser's session (`POST …/session`); any
624
+ * other path under mirror/ is one of the mirror's own routes (a reloaded deep link) and gets the
625
+ * shell. A link is not a twin and has no mirror. */
626
+ private async mirror(request: Request, url: URL, vendor: string, rest: string, /** answered at the twin's place (the vendor's site) */ atSite = false): Promise<Response> {
627
+ if (request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'the mirror mount serves the shell and its assets only' }, { status: 405, headers: { allow: 'GET, HEAD' } });
628
+ const isAsset = MIRROR_ASSET.test(rest);
629
+ if (rest.startsWith('assets/') && !isAsset) return Response.json({ error: 'no such asset' }, { status: 404 });
630
+ if (!this.up().services[vendor] || !this.host.mirror) return Response.json({ error: `no mirror for ${vendor} in ${this.served}` }, { status: 404 });
631
+ const file = await this.host.mirror(vendor, isAsset ? rest : 'index.html');
632
+ if (!file || file.status !== 200) return Response.json({ error: isAsset ? 'no such asset' : `no mirror for ${vendor} in ${this.served}` }, { status: 404 });
633
+ if (isAsset) return file;
634
+ // the shell's own address is the vendor's site at the twin's place (wire, above): a page opened here goes there, so
635
+ // a mirror that routes by its path sees its home, not `mirror/` (the browser keeps any `#/…` route)
636
+ if (rest === '' && !atSite && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '')) return new Response(null, { status: 302, headers: { location: `/${this.served}/${vendor}/${url.search}` } });
637
+ // the shell's <base> is the twin's place under this World (its reads go to the World's wire), its assets stay under
638
+ // mirror/, and its `#/…` links keep working under that base (world-core mirror-shell.ts)
639
+ let html = mirrorShellUnder(await file.text(), `/${this.served}/${vendor}/`);
640
+ // SIGNED IN AS the config says (`signIn.as`): the screens are handed the account and a credential the World minted
641
+ // for it, before they start. Only to a request that may write this World (a read link signs no one in, and minting
642
+ // is a write), on the vendor's site (never the keyless mount), at the World's own origin: a shared origin's other
643
+ // pages, and a rebound name reaching this port, are not this World's pages. Checked here, whoever called.
644
+ const own = this.host.browserOrigin?.() ?? null;
645
+ const signs = atSite && own !== null && url.host === new URL(own).host && this.credential(request).scope === 'write';
646
+ const as = signs ? loadWorldConfig(this.configRef, this.worldRoot).config.services.find((s) => s.id === vendor)?.signIn?.as : undefined;
647
+ const given = as ? await this.signedIn(vendor, as, url.origin) : null;
648
+ if (given) {
649
+ // data, never markup: every character that could end the script or the line is escaped, and the replacement is a
650
+ // function, so no `$&` or `$'` in a value is expanded into the page
651
+ const data = JSON.stringify({ account: given.account, token: given.token }).replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
652
+ html = html.replace(/<script/i, (tag) => `<script>window.__VOLTER_SIGN_IN__=${data};</script>${tag}`);
653
+ }
654
+ // where the console lives on the World's own origin, only that origin frames the World's pages (no clickjacking);
655
+ // a page that carries a credential is never stored, and differs by who asked
656
+ return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': given ? 'no-store, private' : 'no-cache', vary: given ? 'Sec-Fetch-Dest, Cookie, Authorization' : 'Sec-Fetch-Dest', ...(own ? { 'content-security-policy': "frame-ancestors 'self'" } : {}) } });
657
+ }
658
+
659
+ /** A vendor's screens' sign-in (`signIn.as`), once per account every twelve hours while this World is served (rotating the
660
+ * tokens forgets them): the pack finds the account and mints its credential through the twin's own doors; a failure
661
+ * is not kept, so the next page asks again. An account or credential that is not plain printable text is refused. */
662
+ private readonly signIns = new Map<string, Promise<{ account: string; token: string } | null>>();
663
+ private readonly signInAt = new Map<string, number>();
664
+ private signedIn(vendor: string, as: string, origin: string): Promise<{ account: string; token: string } | null> {
665
+ const key = `${vendor}\0${as}`;
666
+ if (Date.now() - (this.signInAt.get(key) ?? 0) > 43_200_000) this.signIns.delete(key);
667
+ let held = this.signIns.get(key);
668
+ if (!held) {
669
+ this.signInAt.set(key, Date.now());
670
+ const twin = this.host.twinFetch(vendor);
671
+ held = !twin || !this.host.mirrorSignIn ? Promise.resolve(null) : this.host.mirrorSignIn(vendor, as, {
672
+ root: this.controlRoot(vendor),
673
+ twin: (path, init) => twin(new Request(`${origin}${path.startsWith('/') ? path : `/${path}`}`, init)),
674
+ }).then((given) => (given && /^[\x21-\x7e]{1,512}$/.test(given.token) && /^[^\x00-\x1f\x7f]{1,256}$/.test(given.account) ? given : null)).catch(() => null);
675
+ this.signIns.set(key, held);
676
+ void held.then((given) => { if (!given) this.signIns.delete(key); });
677
+ }
678
+ return held;
679
+ }
680
+
681
+ /** Whether a twin enforces a read-scope request itself (its manifest names `requestScopes: ['read']`):
682
+ * only then may the read token reach it with anything but GET or HEAD. Asked once per twin. */
683
+ private readonly readEnforced = new Map<string, { enforced: boolean; at: number }>();
684
+ private async enforcesRead(vendor: string, twin: (request: Request) => Promise<Response>, origin: string): Promise<boolean> {
685
+ // asked again after a minute: a twin that was down, or a pack swapped under a running World, is seen
686
+ const known = this.readEnforced.get(vendor);
687
+ if (known !== undefined && Date.now() - known.at < 60_000) return known.enforced;
688
+ let enforced = false;
689
+ try {
690
+ const answer = await twin(new Request(`${origin}/twin`, { headers: { [READ_ONLY_HEADER]: '1' } }));
691
+ if (answer.ok) {
692
+ const scopes = ((await answer.json()) as { requestScopes?: unknown }).requestScopes;
693
+ enforced = Array.isArray(scopes) && scopes.includes('read');
694
+ }
695
+ } catch { enforced = false; }
696
+ this.readEnforced.set(vendor, { enforced, at: Date.now() });
697
+ return enforced;
698
+ }
699
+
298
700
  /** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
299
701
  private async wire(request: Request, url: URL, rest: string): Promise<Response> {
300
702
  const [vendor, ...tail] = rest.split('/');
301
- if (tail[0] === 'mirror') return await this.mirror(request, vendor!, tail.slice(1).join('/'));
302
- const scope = this.scope(request);
703
+ const { scope, via } = this.credential(request);
303
704
  const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
304
705
  if (scope === null && !manifestAsk) return Response.json({ error: 'token required' }, { status: 401 });
305
- if (scope === 'read' && request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
706
+ const foreign = this.crossOriginSession(request, via);
707
+ if (foreign) return foreign;
708
+ const reads = request.method === 'GET' || request.method === 'HEAD';
306
709
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
307
710
  // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
308
- // a person's browser landing on a twin's root (a mirror that moved to a `#/…` route by assigning
309
- // location, which resolves against the twin's <base>) is sent to the mirror; the browser keeps the hash
310
- if (request.method === 'GET' && tail.join('/') === '' && request.headers.get('sec-fetch-dest') === 'document' && this.up().services[vendor!] && (await this.hasMirror(vendor!))) {
311
- return new Response(null, { status: 302, headers: { location: `/${this.served}/${vendor}/mirror/` } });
312
- }
711
+ // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
712
+ const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
313
713
  const link = this.link(vendor!);
314
- if (link) return await this.forward(request, url, tail.join('/'), vendor!, link.origin);
714
+ if (link) {
715
+ if (scope === 'read' && !reads) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
716
+ return await this.forward(request, url, tail.join('/'), vendor!, link.origin);
717
+ }
315
718
  const twin = this.host.twinFetch(vendor!);
316
719
  if (!twin) return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
720
+ // THE READ SCOPE: the twin is told the request is read-only and refuses the caller's writes itself
721
+ // (a Slack read over POST passes, a post is refused); a twin that cannot enforce that gets reads only
722
+ const enforced = scope === 'read' && (await this.enforcesRead(vendor!, twin, url.origin));
723
+ if (scope === 'read' && !reads && !enforced) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
317
724
  const headers = new Headers(request.headers); headers.delete(TOKEN_HEADER); headers.delete(TWINS_KEY_HEADER); headers.delete('host');
725
+ // at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
726
+ // that would write for its caller is refused too); at any other, its GET passes as it always has
727
+ if (enforced) headers.set(READ_ONLY_HEADER, '1');
318
728
  // the browser's World session is the World's credential, never the twin's (a tunnel would hand it on)
319
- const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !c.startsWith('volter_world='));
729
+ const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_world=/.test(c));
320
730
  if (cookies.length) headers.set('cookie', cookies.join('; ')); else headers.delete('cookie');
321
731
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
322
732
  headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
@@ -325,38 +735,40 @@ export class WorldDoors {
325
735
  const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
326
736
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
327
737
  if (answer.status === 101) return answer;
738
+ // THE VENDOR'S SITE: a page the twin does not serve as a page itself (it serves an OAuth screen, a redirect) is the
739
+ // vendor's own screens, the mirror, answered at that very address: the twin's place is the vendor's site, and a
740
+ // mirror's routes are real URLs, reloadable and linkable, as a dev server falls back to its app for unknown pages
741
+ const servedAsPage = (answer.status >= 300 && answer.status < 400) || (answer.status < 300 && (answer.headers.get('content-type') ?? '').includes('text/html'));
742
+ // a credential reached it: the manifest's keyless GET never becomes a page (and never signs anyone in). A resource
743
+ // the twin answers (a JSON link, a stored file, a PDF: 2xx) opened in a tab is that resource, as the vendor's own
744
+ // address would be; an address it refuses or has nothing at (a browser brings no vendor token), and the site's
745
+ // home, are the screens
746
+ const unanswered = answer.status >= 400 || tail.join('/') === '';
747
+ if (page && scope !== null && !servedAsPage && unanswered && (await this.host.hasMirror?.(vendor!))) {
748
+ await answer.body?.cancel().catch(() => undefined);
749
+ return await this.mirror(request, url, vendor!, '', true);
750
+ }
328
751
  // staleness is a fact on the wire: a twin with a root says when the vendor was last observed
329
752
  const out = new Headers(answer.headers);
330
753
  const observedAt = this.observedAt(vendor!);
331
754
  if (observedAt) out.set('x-volter-observed-at', observedAt);
755
+ // served on the World's origin beside its session: a twin's bytes are never sniffed into a script or a page,
756
+ // and a twin cannot set (fix, shadow or clear) the World's own session cookie
757
+ out.set('x-content-type-options', 'nosniff');
758
+ // a stored file opened as a page (an uploaded SVG, an XML document) runs nothing on the World's origin: sandboxed, it is
759
+ // an origin of its own with no scripts, so it can neither read the session's World nor make it a key
760
+ const type = (answer.headers.get('content-type') ?? '').toLowerCase();
761
+ if (page && answer.status < 300 && /^(image\/svg\+xml|application\/xhtml\+xml|text\/xml|application\/xml)\b/.test(type)) out.set('content-security-policy', 'sandbox');
762
+ // a page and an API call to one address are answered differently (the vendor's site): caches keep them apart
763
+ out.append('vary', 'Sec-Fetch-Dest');
764
+ const setCookies = answer.headers.getSetCookie();
765
+ if (setCookies.some((c) => /^\s*(?:__Host-)?volter_world=/i.test(c))) {
766
+ out.delete('set-cookie');
767
+ for (const c of setCookies) if (!/^\s*(?:__Host-)?volter_world=/i.test(c)) out.append('set-cookie', c);
768
+ }
332
769
  return new Response(answer.body, { status: answer.status, headers: out });
333
770
  }
334
771
 
335
- /** Whether the twin's pack ships a mirror (its vendor's UI over this World's state). */
336
- private async hasMirror(vendor: string): Promise<boolean> {
337
- const mod = await packMirrorExports(vendor, this.worldRoot).catch(() => null);
338
- return !!mod && Object.keys(mod).some((n) => /^\w+MirrorHtml$/.test(n)) && Object.keys(mod).some((n) => /^build\w+MirrorClient$/.test(n));
339
- }
340
-
341
- /** THE MIRROR MOUNT, as the hosted World serves it (apps/cloud supervisor.ts): `/<org>/<world>/<vendor>/mirror/`
342
- * and `…/mirror/assets/*`, keyless, GET only — the pack's own shell and bundle. The shell's <base> is the
343
- * twin's place under this World, so its reads go to the keyed wire with the browser's World session. */
344
- private async mirror(request: Request, vendor: string, rest: string): Promise<Response> {
345
- if (request.method !== 'GET') return Response.json({ error: 'the mirror mount serves the shell and its assets only' }, { status: 404 });
346
- const isAsset = /^assets\/[A-Za-z0-9._-]+$/.test(rest);
347
- if (rest.startsWith('assets/') && !isAsset) return Response.json({ error: 'no such asset' }, { status: 404 });
348
- if (!this.up().services[vendor]) return Response.json({ error: 'no mirror for this twin' }, { status: 404 });
349
- const mod = await packMirrorExports(vendor, this.worldRoot);
350
- const pick = (re: RegExp) => { const k = mod && Object.keys(mod).find((n) => re.test(n) && typeof mod[n] === 'function'); return k ? (mod![k] as () => string | Promise<string>) : undefined; };
351
- const html = pick(/^\w+MirrorHtml$/); const client = pick(/^build\w+MirrorClient$/); const styles = pick(/^\w+MirrorStyles$/);
352
- if (!html || !client) return Response.json({ error: 'no mirror for this twin' }, { status: 404 });
353
- if (rest === 'assets/app.js') return new Response(await client(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
354
- if (rest === 'assets/styles.css' && styles) return new Response(await styles(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
355
- if (isAsset) return Response.json({ error: 'no such asset' }, { status: 404 });
356
- const shell = mirrorShellUnder(await html(), `/${this.served}/${vendor}/`);
357
- return new Response(shell, { headers: { 'content-type': 'text/html; charset=utf-8' } });
358
- }
359
-
360
772
  private linksPath(): string { return join(this.worldRoot, stateDirName(), 'links.json'); }
361
773
  /** The World's links: name → the vendor origin it forwards to. */
362
774
  links(): Record<string, { origin: string }> {
@@ -419,28 +831,79 @@ export class WorldDoors {
419
831
  // form body, the session set here, then on to a page of this World
420
832
  // a form or beacon from another site never opens or ends a session here (the console, on this host's
421
833
  // next port, is same-site)
422
- const crossSite = request.headers.get('sec-fetch-site') === 'cross-site';
834
+ // With an origin of its own, a World's session is opened and ended only from its own pages (or an address typed
835
+ // in): a sibling World is the same site and must not end or re-issue it (a logout CSRF). A request that says
836
+ // nothing (not a browser) is left to the token the form carries.
837
+ const site = request.headers.get('sec-fetch-site');
838
+ const crossSite = this.host.browserOrigin?.() ? site !== null && site !== 'same-origin' && site !== 'none' : site === 'cross-site';
423
839
  if (sessionPath === 'session' && request.method === 'POST' && (request.headers.get('content-type') ?? '').startsWith('application/x-www-form-urlencoded')) {
424
840
  if (crossSite) return new Response('A session is opened from this host\'s own pages.', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } });
425
841
  const form = new URLSearchParams(await request.text());
426
842
  const token = form.get('token') ?? '';
427
- if (token !== this.token && token !== this.readToken) return new Response('That token does not open this World.', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8' } });
843
+ // the token or a named key typed into the form, decided by world-access as every door's is
844
+ const found = token ? grantOf([token], null, this.issued()) : null;
845
+ // the World's token opens a session here; a named key is an app's and opens none, except a READ key (a shared
846
+ // read-only link), whose session is bound to it as the header door's is: read only, ended with the key
847
+ const granted = found?.via === 'token' || (found?.via === 'key' && found.scope === 'read') ? found : null;
848
+ if (!granted) return new Response('That token does not open this World.', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8' } });
849
+ if (this.sessionCookieName(url) === null) return new Response(`${this.served}'s browser session lives at its own origin: ${this.host.browserOrigin?.() ?? ''}`, { status: 409, headers: { 'content-type': 'text/plain; charset=utf-8' } });
850
+ const bound = granted.via === 'key' ? ((k) => (k ? { id: k.id, until: k.expiresAt ? Date.parse(k.expiresAt) : Infinity } : undefined))(this.keys().find((x) => sameSecret(keyHash(token), x.hash))) : undefined;
851
+ if (granted.via === 'key' && !bound) return new Response('That token does not open this World.', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8' } });
852
+ const cookie = this.sessionCookie(url, this.openSession(granted.scope, undefined, bound));
853
+ // a World with an origin of its own keeps its session there: asked elsewhere, it says where
854
+ if (cookie === null) return new Response(`${this.served}'s browser session lives at its own origin: ${this.host.browserOrigin?.() ?? ''}`, { status: 409, headers: { 'content-type': 'text/plain; charset=utf-8' } });
428
855
  const next = form.get('next') ?? '';
429
856
  // the path the browser would land on, normalized (%2e%2e resolves), and only inside this World
430
857
  const landing = (() => { try { const at = new URL(next, url.origin); return at.origin === url.origin ? at.pathname : ''; } catch { return ''; } })();
431
858
  const to = next.startsWith('/') && !next.startsWith('//') && !/[\\\s]/.test(next) && landing.startsWith(`/${this.served}/`) ? landing : `/${this.served}/`;
432
- const secure = url.protocol === 'https:' ? '; Secure' : '';
433
- return new Response(null, { status: 303, headers: { location: to, 'set-cookie': `volter_world=${encodeURIComponent(token)}; Path=/${this.served}/; HttpOnly; SameSite=Strict${secure}` } });
859
+ return new Response(null, { status: 303, headers: { location: to, 'set-cookie': cookie } });
434
860
  }
435
861
  if (((sessionPath === 'session' && request.method === 'DELETE') || (sessionPath === 'session/end' && request.method === 'POST')) && !crossSite) {
436
- const secure = url.protocol === 'https:' ? '; Secure' : '';
437
- return new Response(JSON.stringify({ world: this.served, ended: true }), { status: 200, headers: { 'content-type': 'application/json', 'set-cookie': `volter_world=; Path=/${this.served}/; HttpOnly; SameSite=Strict; Max-Age=0${secure}` } });
862
+ this.endSession(this.credential(request).session ?? null);
863
+ const cookie = this.sessionCookie(url, '', '; Max-Age=0');
864
+ return new Response(JSON.stringify({ world: this.served, ended: true }), { status: 200, headers: { 'content-type': 'application/json', ...(cookie ? { 'set-cookie': cookie } : {}) } });
865
+ }
866
+ // a person arriving from a platform with a pass: from this World's own page only (the pass is in its fragment)
867
+ const pass = request.headers.get('x-volter-pass');
868
+ if (pass !== null && sessionPath === 'session' && request.method === 'POST') {
869
+ if (crossSite || (this.host.browserOrigin?.() && site !== 'same-origin')) return Response.json({ error: "a pass opens a session from this World's own page" }, { status: 403 });
870
+ return await this.passSession(request, url, pass);
438
871
  }
439
- const scope = this.scope(request);
872
+ const { scope, via } = this.credential(request);
873
+ // a local World's own page asking for its session with nothing to show: it is the person at this machine
874
+ if (scope === null && sessionPath === 'session' && request.method === 'POST' && this.localPage(request, url)) return this.localSession(url);
440
875
  if (scope === null) return Response.json({ error: 'token required' }, { status: 401 });
441
- if (scope === 'read' && request.method !== 'GET') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
442
876
  const parts = path.split('/').filter(Boolean);
443
877
  const [kind, name, verb] = parts;
878
+ // a browser session is opened by either token: the read token's is how a view-only link opens a mirror
879
+ // the session gate comes first: a page that holds only the cookie (a sibling World's, the same site) can neither
880
+ // re-issue nor end the session through this door
881
+ const foreign = this.crossOriginSession(request, via);
882
+ if (foreign) return foreign;
883
+ // a named key is an app's: it opens no browser session (which would outlive the key and could make keys of its own),
884
+ // except a READ key, which is how a shared read-only link opens the World's pages: its session reads only, makes no
885
+ // keys, and ends when the key is revoked or expires
886
+ if (kind === 'session' && !name && request.method === 'POST' && via === 'key' && scope !== 'read') return Response.json({ error: 'a key opens no browser session: open the World with its token, or from the platform' }, { status: 403 });
887
+ if (kind === 'session' && !name && (request.method === 'POST' || request.method === 'DELETE')) return this.session(request, url, scope, via === 'key' ? this.presentedKey(request) : undefined);
888
+ if (kind === 'keys') return this.keysDoor(request, name, scope, via, this.credential(request).session);
889
+ if (scope === 'read' && request.method !== 'GET') return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
890
+
891
+ if (kind === 'clock') return await this.clockDoor(request, name);
892
+ if (kind === 'branches') return await this.branchesDoor(request, url, parts.slice(1), via);
893
+ // each twin's history cut at an instant: where a branch "as of" that instant starts
894
+ if (kind === 'history' && !name && request.method === 'GET') {
895
+ const instant = url.searchParams.get('at') ?? '';
896
+ if (Number.isNaN(Date.parse(instant))) return Response.json({ error: 'history?at=<ISO-8601 instant>' }, { status: 400 });
897
+ const views: Record<string, HistoryReference> = {};
898
+ withAncestryLock(() => { for (const vendor of Object.keys(this.up().services)) views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); });
899
+ return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
900
+ }
901
+ if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
902
+ if (kind === 'timeline' && !name && request.method === 'GET') return this.timeline(url);
903
+ if (kind === 'diff' && !name && request.method === 'GET') {
904
+ const delta = diffWorld(this.name, { root: this.worldRoot });
905
+ return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.map((a) => ({ twin: a.service, entry: a.action })) });
906
+ }
444
907
 
445
908
  if (kind === 'log' && name && request.method === 'GET') {
446
909
  const state = this.stateOf(name);
@@ -480,13 +943,6 @@ export class WorldDoors {
480
943
  if (!body.as) return Response.json({ error: 'approve: { as, note? }' }, { status: 400 });
481
944
  return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal: body.as, ...(body.note ? { note: body.note } : {}) }));
482
945
  }
483
- // a browser session for this World: the presented token as an HttpOnly cookie scoped to the World's
484
- // paths, so a page served under it (a twin's mirror) reads the wire as the token's holder
485
- if (kind === 'session' && !name && request.method === 'POST') {
486
- const presented = this.match(request)!.token;
487
- const secure = url.protocol === 'https:' ? '; Secure' : '';
488
- return new Response(JSON.stringify({ world: this.served, scope }), { status: 200, headers: { 'content-type': 'application/json', 'set-cookie': `volter_world=${encodeURIComponent(presented)}; Path=/${this.served}/; HttpOnly; SameSite=Strict${secure}` } });
489
- }
490
946
  if (kind === 'links' && !name && request.method === 'GET') return Response.json({ links: this.links() });
491
947
  if (kind === 'links' && name && request.method === 'PUT') {
492
948
  if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name) || this.up().services[name]) return Response.json({ error: 'a link is named [a-z0-9_-] and is not a twin of this World' }, { status: 400 });
@@ -525,11 +981,12 @@ export class WorldDoors {
525
981
  const key = (): string => { const t = store.read(tokenPath)?.trim(); if (!t) throw new Error(`${this.served} has no origin — PUT /-/${this.served}/origin { url, token }`); return t; };
526
982
  if (!name && request.method === 'GET') return Response.json({ origin: statusWorld(this.name, this.worldRoot).origin ?? null });
527
983
  if (!name && request.method === 'PUT') {
528
- const body = (await request.json()) as { url?: string; token?: string };
984
+ const body = (await request.json()) as { url?: string; token?: string; views?: Record<string, HistoryReference> };
529
985
  const at = /^(https?:\/\/[^/]+)\/([a-z0-9][a-z0-9_-]*\/[a-z0-9][a-z0-9_-]*)\/?$/i.exec(body.url ?? '');
530
- if (!at || !body.token) return Response.json({ error: 'PUT origin: { url: "https://<host>/<org>/<world>", token }' }, { status: 400 });
986
+ if (!at || !body.token) return Response.json({ error: 'PUT origin: { url: "https://<host>/<org>/<world>", token, views? }' }, { status: 400 });
531
987
  store.write(tokenPath, `${body.token}\n`, { secret: true });
532
- const fetched = await this.serialized('push', () => fetchFromOrigin(this.name, { root: this.worldRoot, url: at[1]!, namespace: at[2]!, key: body.token!, full: true }));
988
+ // `views` (the origin's history door answers them) clones each twin's history as of a cut
989
+ const fetched = await this.serialized('push', () => fetchFromOrigin(this.name, { root: this.worldRoot, url: at[1]!, namespace: at[2]!, key: body.token!, full: true, ...(body.views ? { views: body.views } : {}) }));
533
990
  return Response.json({ origin: fetched.origin, appended: fetched.appended });
534
991
  }
535
992
  if (name === 'pull' && request.method === 'POST') {
@@ -584,6 +1041,13 @@ export class WorldDoors {
584
1041
  }
585
1042
  if (kind === 'push' && request.method === 'POST') return await this.serialized('push', () => this.push(request));
586
1043
  if (kind === 'deploy' && request.method === 'POST') {
1044
+ // a deploy acts on the real vendors: a browser's session deploys only what the request itself names, so no link,
1045
+ // page or request built from a path (a console route, a crafted URL) deploys by being opened. A caller presenting
1046
+ // the token is the operator's own tool, as before.
1047
+ if (via === 'session') {
1048
+ const asked = (await request.json().catch(() => null)) as { confirm?: unknown } | null;
1049
+ if (asked?.confirm !== (name ?? this.served)) return Response.json({ error: `a browser deploys only what it names: POST { "confirm": "${name ?? this.served}" }` }, { status: 400 });
1050
+ }
587
1051
  const outcomes = await this.serialized('push', () => deployWorld(this.name, { root: this.worldRoot, instance: this.up(), ...(name ? { changeset: name } : {}) }));
588
1052
  return Response.json({ deployed: outcomes.map((o) => ({ twin: o.service, pushed: o.report.pushed, ...(o.report.refused ? { refused: o.report.refused } : {}), ...(o.report.failed ? { failed: o.report.failed } : {}) })) });
589
1053
  }
@@ -655,7 +1119,7 @@ export class WorldDoors {
655
1119
  const marker = getActiveWorldStore().read(join(this.controlRoot(name), stateDirName(), 'world', name, 'refresh.json'));
656
1120
  let refresh: unknown = null; try { if (marker !== null) refresh = JSON.parse(marker); } catch { refresh = null; }
657
1121
  const last = [...parentEntries(state, this.controlRoot(name))].reverse().find((e) => e.landsId && e.receipt);
658
- return Response.json({ twin: name, state, protocol: this.up().services[name]?.protocol ?? null, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null, mirror: await this.hasMirror(name) });
1122
+ return Response.json({ twin: name, state, protocol: this.up().services[name]?.protocol ?? null, mirror: Boolean(await this.host.hasMirror?.(name)), root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
659
1123
  }
660
1124
  if (verb === 'refresh' && request.method === 'POST') {
661
1125
  const root = rootForControlRoot(this.controlRoot(name), name);
@@ -666,6 +1130,219 @@ export class WorldDoors {
666
1130
  return Response.json({ error: `no such door: ${request.method} /-/${this.served}/${path}` }, { status: 404 });
667
1131
  }
668
1132
 
1133
+ /** A browser session for this World: the presented token as an HttpOnly cookie scoped to the World's
1134
+ * paths, so a page served under it (a twin's mirror) reads the wire as the token's holder, in the
1135
+ * token's scope. */
1136
+ /** THE LOCAL SESSION: a World served on this machine's loopback never asks the person at it for a token. Any
1137
+ * website can send requests to 127.0.0.1, so what stands in for the token is proof the request is this World's own
1138
+ * page, as Vite and Jupyter check their dev servers: the Host is a loopback name (a rebound DNS name is not, so DNS
1139
+ * rebinding fails), the browser's Origin is exactly this origin (another site's page cannot claim it, so CSRF
1140
+ * fails), and the fetch says it is same-origin. A process on this machine can claim all three: a local World
1141
+ * trusts this machine's user, as a local dev server does. The session is the write token's. A World with no origin
1142
+ * of its own (a shared origin, where every World's pages are one origin) is not trusted so: its page asks for a
1143
+ * token. */
1144
+ private localPage(request: Request, url: URL): boolean {
1145
+ // only where the World has an origin of its own: on a shared origin every World's pages (and each twin's own
1146
+ // HTML) are the same origin, and none may be handed another's session
1147
+ if (!this.host.localTrust?.() || !this.host.browserOrigin?.()) return false;
1148
+ const name = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
1149
+ const loopback = name === 'localhost' || name.endsWith('.localhost') || name === '127.0.0.1' || name === '::1';
1150
+ const site = request.headers.get('sec-fetch-site');
1151
+ // a request that does not say it is same-origin fails closed (every browser this serves says it)
1152
+ return loopback && request.headers.get('origin') === url.origin && site === 'same-origin';
1153
+ }
1154
+ private localSession(url: URL): Response {
1155
+ const own = this.host.browserOrigin?.() ?? null;
1156
+ if (this.sessionCookieName(url) === null) return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
1157
+ return Response.json({ world: this.served, scope: 'write', local: true, ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write'))! } });
1158
+ }
1159
+
1160
+ private session(request: Request, url: URL, scope: 'read' | 'write', key?: { id: string; until: number }): Response {
1161
+ const own = this.host.browserOrigin?.() ?? null;
1162
+ // a World with an origin of its own keeps its session there: asked elsewhere, it says where
1163
+ if (this.sessionCookieName(url) === null) return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
1164
+ const held = this.credential(request).session ?? null;
1165
+ if (request.method === 'DELETE') { this.endSession(held); return Response.json({ world: this.served, ended: true }, { headers: { 'set-cookie': this.sessionCookie(url, '', '; Max-Age=0')! } }); }
1166
+ const cookie = this.sessionCookie(url, held ?? this.openSession(scope, undefined, key))!;
1167
+ const who = held ? this.sessions()[held]?.who : undefined; const issuer = held ? this.sessions()[held]?.issuer : undefined;
1168
+ // `local`: this page is the local World's own (localPage), so the console offers no sign-in or sign-out
1169
+ return Response.json({ world: this.served, scope, ...(who ? { who } : {}), ...(issuer ? { issuer } : {}), ...(own ? { origin: own } : {}), ...(this.localPage(request, url) ? { local: true } : {}) }, { headers: { 'set-cookie': cookie } });
1170
+ }
1171
+
1172
+ /** THE CLOCK: the frozen instant every twin stamps from, kept in the World's store. It moves only
1173
+ * forward once the World has entries (a twin's catch-up stamps each move at its due time, so a
1174
+ * clock set back would put new entries before old ones); the past is reached by branching. */
1175
+ private async clockDoor(request: Request, verb: string | undefined): Promise<Response> {
1176
+ const path = this.host.clockPath?.();
1177
+ if (!path) return Response.json({ error: 'this host keeps no World clock' }, { status: 404 });
1178
+ const store = getActiveWorldStore();
1179
+ const held = store.exists(path) ? (store.read(path) ?? '').trim() : '';
1180
+ const current = held && !Number.isNaN(Date.parse(held)) ? { at: new Date(Date.parse(held)).toISOString(), frozen: true } : { at: new Date().toISOString(), frozen: false };
1181
+ if (!verb && request.method === 'GET') return Response.json(current);
1182
+ const set = (at: number): Response => {
1183
+ // the floor: the later of the frozen instant and the newest entry any twin holds (a push may
1184
+ // land entries past the clock)
1185
+ const newest = this.newestEntry(); const frozenAt = current.frozen ? Date.parse(current.at) : null;
1186
+ const floor = frozenAt === null ? newest : newest === null ? frozenAt : Math.max(frozenAt, newest);
1187
+ if (floor !== null && at < floor) return Response.json({ error: `the clock moves only forward: ${new Date(at).toISOString()} is before ${new Date(floor).toISOString()} (branch to go back)` }, { status: 409 });
1188
+ store.writeAtomic(path, `${new Date(at).toISOString()}\n`);
1189
+ return Response.json({ at: new Date(at).toISOString(), frozen: true });
1190
+ };
1191
+ if (!verb && request.method === 'PUT') {
1192
+ const body = (await request.json().catch(() => null)) as { at?: unknown } | null;
1193
+ const at = typeof body?.at === 'string' ? Date.parse(body.at) : Number.NaN;
1194
+ if (Number.isNaN(at)) return Response.json({ error: 'clock: { at: <ISO-8601 instant> }' }, { status: 400 });
1195
+ return set(at);
1196
+ }
1197
+ if (verb === 'advance' && request.method === 'POST') {
1198
+ const body = (await request.json().catch(() => null)) as { by?: unknown } | null;
1199
+ const m = typeof body?.by === 'string' ? SPAN.exec(body.by.trim()) : null;
1200
+ if (!m) return Response.json({ error: 'advance: { by: "<N>(s|m|h|d)" }' }, { status: 400 });
1201
+ if (!current.frozen) return Response.json({ error: 'the clock is not set: set it first (advancing the wall clock would freeze time as a side effect)' }, { status: 409 });
1202
+ const target = Date.parse(current.at) + Number(m[1]) * SPAN_MS[m[2] as keyof typeof SPAN_MS];
1203
+ if (!Number.isFinite(target) || target > Date.parse('9999-12-31T23:59:59Z')) return Response.json({ error: 'advance: past the last instant a clock can hold' }, { status: 400 });
1204
+ return set(target);
1205
+ }
1206
+ return Response.json({ error: `no such door: ${request.method} clock${verb ? `/${verb}` : ''}` }, { status: 404 });
1207
+ }
1208
+
1209
+ /** THE BRANCHES DOORS: list (read), make one as of an instant, remove one (write). The host makes
1210
+ * and removes them; a host that makes none answers 404, and a view offers no "as of". */
1211
+ private async branchesDoor(request: Request, url: URL, rest: string[], via: 'token' | 'key' | 'session' | null): Promise<Response> {
1212
+ const branches = this.host.branches;
1213
+ if (!branches) return Response.json({ error: `this host makes no branches of ${this.served}` }, { status: 404 });
1214
+ if (rest.length === 0 && request.method === 'GET') return Response.json({ world: this.served, branches: await branches.list() });
1215
+ if (rest.length === 0 && request.method === 'POST') {
1216
+ const body = (await request.json().catch(() => null)) as { at?: { instant?: unknown }; ttl?: unknown; live?: unknown; label?: unknown } | null;
1217
+ const instant = body?.at?.instant;
1218
+ if (instant !== undefined && (typeof instant !== 'string' || Number.isNaN(Date.parse(instant)))) return Response.json({ error: 'branches: { at?: { instant: <ISO-8601> }, ttl?: <seconds>, live?: true, label?: <name> }' }, { status: 400 });
1219
+ if (body?.label !== undefined && (typeof body.label !== 'string' || !/^[a-z0-9][a-z0-9-]{0,23}$/.test(body.label))) return Response.json({ error: 'label: lowercase letters, digits and dashes, at most 24 (it names the branch)' }, { status: 400 });
1220
+ if (body?.live !== undefined && (body.live !== true || instant !== undefined)) return Response.json({ error: 'live: true, and only for a branch as of now (a branch at an instant keeps that instant)' }, { status: 400 });
1221
+ let ttl = body?.ttl === undefined || body.ttl === null ? null : Number(body.ttl);
1222
+ if (ttl !== null && (!Number.isInteger(ttl) || ttl < 60 || ttl > 30 * 86_400)) return Response.json({ error: 'ttl: whole seconds, 60 to 2592000' }, { status: 400 });
1223
+ // WHO MAKES IT, and for how long: the World's token as it asks; a key or a person's session only for a while, and
1224
+ // never past its own end (a branch outlives neither the key nor the session that made it)
1225
+ const now = Date.now();
1226
+ const cred = this.credential(request);
1227
+ const maker = via === 'key' && cred.token ? this.keys().find((k) => sameSecret(keyHash(cred.token!), k.hash)) : undefined;
1228
+ const opened = via === 'session' && cred.session ? this.sessions()[cred.session] : undefined;
1229
+ if (via !== 'token') {
1230
+ const until = maker ? (maker.expiresAt ? Date.parse(maker.expiresAt) : Infinity) : opened ? opened.until : now;
1231
+ const most = Math.floor(Math.min(7 * 86_400_000, until - now) / 1000);
1232
+ if (ttl === null) return Response.json({ error: 'ttl: a branch made with a key or a browser session says how long it lives (seconds, at most 7 days and the key\'s or session\'s own end)' }, { status: 400 });
1233
+ if (most < 60) return Response.json({ error: 'this key or session ends too soon to make a branch' }, { status: 403 });
1234
+ ttl = Math.min(ttl, most);
1235
+ }
1236
+ const person = maker ? this.keyPerson(maker) : opened?.sub;
1237
+ const createdBy: HeldKey['createdBy'] = maker ? { via: 'key', keyId: maker.id } : opened ? { via: 'session', ...(opened.sub ? { sub: opened.sub } : {}), ...(opened.who ? { who: opened.who } : {}), ...(opened.issuer ? { issuer: opened.issuer } : {}) } : { via: 'token' };
1238
+ // the branch reaches this World with a key of its own, never this World's token: made for the same person, ending
1239
+ // with the branch, so whatever ends the person's keys here (their leaving, a revoke, new tokens) ends its reach
1240
+ const parentKey = mintToken('tok_k_');
1241
+ const keyId = mintToken('key_').slice(0, 20);
1242
+ const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
1243
+ this.keepKeys([...this.keys(), { id: keyId, name: 'a branch (being made)', scope: 'write', hash: keyHash(parentKey), createdAt: new Date(now).toISOString(), createdBy, ...(person ? { for: person } : {}), ...(expiresAt ? { expiresAt } : {}), ...(maker ? { madeWith: maker.id } : {}) }]);
1244
+ try {
1245
+ const made = await branches.create(typeof instant === 'string' ? { instant: new Date(Date.parse(instant)).toISOString() } : { ...(body?.live === true ? { live: true } : {}), ...(typeof body?.label === 'string' ? { label: body.label } : {}) }, ttl, url.origin, parentKey);
1246
+ this.keepKeys(this.keys().map((k) => (k.id === keyId ? { ...k, name: `the branch ${made.name}`, branch: made.name, ...(made.expiresAt ? { expiresAt: made.expiresAt } : {}) } : k)));
1247
+ // a browser's session is answered the branch's read token only: a page views a branch, and the parent's
1248
+ // session removes it, so no write token ever reaches a page's script (or a frame beside it)
1249
+ // `origin`: where the branch's own pages are, when it has an origin of its own (a page moves there to view it)
1250
+ const origin = this.host.originOf?.(made.name) ?? null;
1251
+ if (via === 'session') { const { token: _write, ...viewed } = made; return Response.json({ ...viewed, ...(origin ? { origin } : {}) }, { status: 201 }); }
1252
+ return Response.json({ ...made, ...(origin ? { origin } : {}) }, { status: 201 });
1253
+ } catch (error) {
1254
+ this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
1255
+ return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
1256
+ }
1257
+ }
1258
+ if (rest.length === 2 && request.method === 'DELETE') {
1259
+ const name = rest.join('/');
1260
+ if (!(await branches.remove(name))) return Response.json({ error: `${name} is not a branch of ${this.served}` }, { status: 404 });
1261
+ this.keepKeys(this.keys().filter((k) => k.branch !== name)); // its key here goes with it
1262
+ return Response.json({ removed: name });
1263
+ }
1264
+ return Response.json({ error: `no such door: ${request.method} branches${rest.length ? `/${rest.join('/')}` : ''}` }, { status: 404 });
1265
+ }
1266
+
1267
+ /** The newest entry's instant across the World's twins (ms), or null when it has none. */
1268
+ private newestEntry(): number | null {
1269
+ let newest: number | null = null;
1270
+ for (const vendor of Object.keys(this.up().services)) {
1271
+ for (const e of wholeLog(this.stateOf(vendor), this.controlRoot(vendor))) {
1272
+ const t = Date.parse(e.occurredAt);
1273
+ if (!Number.isNaN(t) && (newest === null || t > newest)) newest = t;
1274
+ }
1275
+ }
1276
+ return newest;
1277
+ }
1278
+
1279
+ /** THE MAP: every twin and what it holds, by resource type, and whether it has a mirror. */
1280
+ private async map(): Promise<unknown> {
1281
+ const twins: unknown[] = [];
1282
+ for (const vendor of Object.keys(this.up().services)) {
1283
+ const state = this.stateOf(vendor); const controlRoot = this.controlRoot(vendor);
1284
+ const counts = new Map<string, number>();
1285
+ for (const r of readTree(state, controlRoot)) if (r.deleted !== true) counts.set(r.type, (counts.get(r.type) ?? 0) + 1);
1286
+ twins.push({
1287
+ twin: vendor, position: wholeLog(state, controlRoot).length,
1288
+ mirror: Boolean(await this.host.hasMirror?.(vendor)),
1289
+ root: rootForControlRoot(controlRoot, vendor) ?? null,
1290
+ resources: [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([type, count]) => ({ type, count })),
1291
+ });
1292
+ }
1293
+ return { world: this.served, twins };
1294
+ }
1295
+
1296
+ /** THE TIMELINE: the entries of every twin's log merged, newest first, by each entry's instant, then
1297
+ * twin, then position. The World keeps no order across twins finer than its clock, and this claims
1298
+ * none. `before` is the cursor the previous page answered; `twin` and `trace` narrow it. */
1299
+ private timeline(url: URL): Response {
1300
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? '100'), 500);
1301
+ if (!Number.isInteger(limit) || limit < 1) return Response.json({ error: 'limit: 1-500' }, { status: 400 });
1302
+ const onlyTwin = url.searchParams.get('twin'); const onlyTrace = url.searchParams.get('trace');
1303
+ let before: { at: string; twin: string; position: number } | null = null;
1304
+ const cursor = url.searchParams.get('before');
1305
+ if (cursor) {
1306
+ try {
1307
+ const [at, twin, position] = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as [string, string, number];
1308
+ if (typeof at !== 'string' || typeof twin !== 'string' || !Number.isInteger(position)) throw new Error('shape');
1309
+ before = { at, twin, position };
1310
+ } catch { return Response.json({ error: 'before: not a cursor this door answered' }, { status: 400 }); }
1311
+ }
1312
+ const rows = this.timelineRows().filter((r) => (!onlyTwin || r.twin === onlyTwin) && (!onlyTrace || traceIdOf(r.entry.traceparent) === onlyTrace));
1313
+ const key = (r: TimelineRow) => ({ at: r.entry.occurredAt, twin: r.twin, position: r.position });
1314
+ const page = rows.filter((r) => before === null || newerFirst(before, key(r)) < 0).slice(0, limit + 1);
1315
+ const more = page.length > limit; const shown = page.slice(0, limit);
1316
+ const last = shown[shown.length - 1];
1317
+ const next = more && last ? Buffer.from(JSON.stringify([last.entry.occurredAt, last.twin, last.position])).toString('base64url') : null;
1318
+ return Response.json({ world: this.served, entries: shown, next });
1319
+ }
1320
+
1321
+ /** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
1322
+ * (a twin is read again only when the size or time of its parent log, branch log or branch record
1323
+ * moved), and the merged order is sorted again only when a twin was. */
1324
+ private readonly timelineByTwin = new Map<string, { fingerprint: string; rows: TimelineRow[] }>();
1325
+ private timelineMerged: { fingerprint: string; rows: TimelineRow[] } | null = null;
1326
+ private timelineRows(): TimelineRow[] {
1327
+ const store = getActiveWorldStore();
1328
+ const prints: string[] = [];
1329
+ for (const vendor of Object.keys(this.up().services)) {
1330
+ const state = this.stateOf(vendor); const controlRoot = this.controlRoot(vendor);
1331
+ const fingerprint = [parentLogPath(state, controlRoot), branchLogPath(state, controlRoot), branchMetaPath(state, controlRoot)]
1332
+ .map((p) => { const s = store.stat(p); return s ? `${s.size}:${s.mtimeMs}` : '-'; }).join('|');
1333
+ prints.push(`${vendor}=${fingerprint}`);
1334
+ if (this.timelineByTwin.get(vendor)?.fingerprint === fingerprint) continue;
1335
+ this.timelineByTwin.set(vendor, { fingerprint, rows: wholeLog(state, controlRoot).map((entry, i) => ({ twin: vendor, position: i + 1, entry })) });
1336
+ }
1337
+ const all = prints.join(',');
1338
+ if (this.timelineMerged?.fingerprint !== all) {
1339
+ const key = (r: TimelineRow) => ({ at: r.entry.occurredAt, twin: r.twin, position: r.position });
1340
+ const rows = Object.keys(this.up().services).flatMap((v) => this.timelineByTwin.get(v)?.rows ?? []);
1341
+ this.timelineMerged = { fingerprint: all, rows: rows.sort((a, b) => newerFirst(key(a), key(b))) };
1342
+ }
1343
+ return this.timelineMerged.rows;
1344
+ }
1345
+
669
1346
  /** PUSH: see `landChangeset` — the door hands the body to it. */
670
1347
  private async push(request: Request): Promise<Response> {
671
1348
  const body = (await request.json()) as Changeset | { changeset: Changeset; expect?: Record<string, HistoryReference> };