@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, readHistoryView, worldPaths } from '@volter/world-core';
1
+ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant, historyDigest, historyEntries, readHistoryView, worldPaths } 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,20 +14,30 @@ 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, readFileSync, writeFileSync, rmSync } from 'node:fs';
21
29
  import { blobDigest, getActiveBlobStore, loadCheck, mirrorShellUnder, openSealedCredential, readTwinRequestJournal, sealingKey, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
22
30
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
+ import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
32
+ import { createHash } from 'node:crypto';
23
33
  import { dirname, join, resolve } from 'node:path';
24
- import { appendActionIfAbsent, branchEntries, confirmAction, readTree, stateDirName, wholeLog, worldNow, parentEntries, rebaseBranch, } from '@volter/world-core';
25
- import { approveWorldChangeset, createWorldChangeset, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
34
+ import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, rebaseBranch, } from '@volter/world-core';
35
+ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
26
36
  import { fetchFromOrigin } from "./origin.js";
27
37
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
28
38
  import { loadWorldConfig } from "./configs.js";
29
- import { adaptersFor, packMirrorExports, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from "./root.js";
30
- import { downWorld, statusWorld, upWorld } from "./runtime.js";
39
+ import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from "./root.js";
40
+ import { clockFile, downWorld, statusWorld, upWorld } from "./runtime.js";
31
41
  export const TOKEN_HEADER = 'x-volter-token';
32
42
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
33
43
  export const TWINS_KEY_HEADER = 'x-twins-key';
@@ -70,6 +80,12 @@ export function readServeRecord(worldRoot) {
70
80
  }
71
81
  return rec;
72
82
  }
83
+ /** Where a World keeps its browser sessions (WorldDoors): a host that rotates the tokens without the doors at hand
84
+ * clears it itself. */
85
+ export function sessionsFile(worldRoot) { return join(worldRoot, stateDirName(), 'sessions.json'); }
86
+ /** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
87
+ export function keysFile(worldRoot) { return join(worldRoot, stateDirName(), 'keys.json'); }
88
+ const keyHash = (key) => createHash('sha256').update(key).digest('hex');
73
89
  function mintToken(prefix) { return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`; }
74
90
  function writeSecret(path, value) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${value}\n`, { mode: 0o600 }); chmodSync(path, 0o600); }
75
91
  /** The served name: `bare.name` (`acme/team`), else `<id>/<id>`. */
@@ -114,6 +130,20 @@ export async function mountWorld(name, opts = {}) {
114
130
  const declared = loadWorldConfig(configRef, worldRoot).config.services.find((s) => s.id === vendor)?.colocate?.scenarioPath;
115
131
  return declared ? resolve(worldRoot, declared) : null;
116
132
  },
133
+ // a local mirror is the pack's own, built by its builder (at serve under Bun, prebuilt under Node)
134
+ mirror: (vendor, file) => localMirrorFile(worldRoot, vendor, file),
135
+ hasMirror: async (vendor) => (await localMirror(worldRoot, vendor)) !== null,
136
+ mirrorSignIn: async (vendor, as, ctx) => { const mirror = await localMirror(worldRoot, vendor); try {
137
+ return (await mirror?.signIn?.(as, ctx)) ?? null;
138
+ }
139
+ catch {
140
+ return null;
141
+ } },
142
+ clockPath: () => clockFile(worldRoot, name),
143
+ ...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
144
+ ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
145
+ ...(opts.localTrust ? { localTrust: () => true } : {}),
146
+ ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
117
147
  // a local twin is its own process: the wire forwards to the port it listens on
118
148
  twinFetch: (vendor) => {
119
149
  const target = instance?.services[vendor]?.url;
@@ -154,6 +184,37 @@ export async function mountWorld(name, opts = {}) {
154
184
  },
155
185
  };
156
186
  }
187
+ /** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
188
+ const localMirrors = new Map();
189
+ const builtMirrorFiles = new Map();
190
+ function localMirror(worldRoot, vendor) {
191
+ const key = `${worldRoot}\0${vendor}`;
192
+ let held = localMirrors.get(key);
193
+ if (!held) {
194
+ held = packMirror(vendor, worldRoot).catch(() => null);
195
+ localMirrors.set(key, held);
196
+ }
197
+ return held;
198
+ }
199
+ async function localMirrorFile(worldRoot, vendor, file) {
200
+ const mirror = await localMirror(worldRoot, vendor);
201
+ if (!mirror)
202
+ return null;
203
+ if (file === 'index.html')
204
+ return new Response(mirror.html(), { headers: { 'content-type': 'text/html; charset=utf-8' } });
205
+ const build = file === 'assets/app.js' ? mirror.client : file === 'assets/styles.css' ? mirror.styles : undefined;
206
+ if (!build)
207
+ return null;
208
+ const key = `${worldRoot}\0${vendor}\0${file}`;
209
+ let held = builtMirrorFiles.get(key);
210
+ // a failed build is not kept: the next request builds again
211
+ if (!held) {
212
+ held = build().catch(() => { builtMirrorFiles.delete(key); return null; });
213
+ builtMirrorFiles.set(key, held);
214
+ }
215
+ const body = await held;
216
+ return body === null ? null : new Response(body, { headers: { 'content-type': file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8' } });
217
+ }
157
218
  /** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
158
219
  export async function serveWorld(name, opts = {}) {
159
220
  const mounted = await mountWorld(name, opts);
@@ -231,6 +292,32 @@ function streamEnv(id, protocol) {
231
292
  return own;
232
293
  return { [`${id.toUpperCase().replace(/-/g, '_')}_TWIN_URL`]: `${protocol}://twin:twin@\${host}:\${port}/twin` };
233
294
  }
295
+ /** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
296
+ * label: lowercase letters, digits and single hyphens, 63 at most). A host maps a label back by the
297
+ * Worlds it serves, never by parsing it. */
298
+ export function worldOriginLabel(served) {
299
+ const [org, world] = served.split('/');
300
+ const part = /^[a-z0-9](?:[a-z0-9]|-(?!-))*[a-z0-9]$|^[a-z0-9]$/;
301
+ if (!org || !world || !part.test(org) || !part.test(world))
302
+ return null;
303
+ const label = `${world}--${org}`;
304
+ return label.length <= 63 ? label : null;
305
+ }
306
+ /** The header the doors set on a read-scope request to a twin: the twin refuses the caller's writes
307
+ * and still makes the vendor's own moves. It only restricts, so a twin trusts it without a token. */
308
+ export const READ_ONLY_HEADER = 'x-volter-read-only';
309
+ const MIRROR_ASSET = /^assets\/[A-Za-z0-9._-]+$/;
310
+ const SPAN = /^(\d+(?:\.\d+)?)(s|m|h|d)$/;
311
+ const SPAN_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
312
+ /** A W3C traceparent's trace id, or null when the value is not one. */
313
+ function traceIdOf(traceparent) {
314
+ const m = typeof traceparent === 'string' ? /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/.exec(traceparent) : null;
315
+ return m && !/^0+$/.test(m[1]) ? m[1] : null;
316
+ }
317
+ /** Timeline order, newest first: by the entry's instant, then twin, then position. */
318
+ function newerFirst(a, b) {
319
+ return b.at.localeCompare(a.at) || b.twin.localeCompare(a.twin) || b.position - a.position;
320
+ }
234
321
  export class WorldDoors {
235
322
  name;
236
323
  worldRoot;
@@ -250,45 +337,289 @@ export class WorldDoors {
250
337
  this.readToken = readToken;
251
338
  }
252
339
  /** New tokens; a request presenting the old ones is refused from the next call on. */
253
- retoken(token, readToken) { this.token = token; this.readToken = readToken; }
340
+ // new tokens end everything the old ones reached: every session and every named key
341
+ retoken(token, readToken) { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); this.signIns.clear(); }
342
+ /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
343
+ * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
344
+ * keeps them; each lasts 30 days from its opening. */
345
+ sessionsPath() { return sessionsFile(this.worldRoot); }
346
+ held = null;
347
+ sessions() {
348
+ if (this.held === null) {
349
+ try {
350
+ this.held = JSON.parse(getActiveWorldStore().read(this.sessionsPath()) ?? '{}');
351
+ }
352
+ catch {
353
+ this.held = {};
354
+ }
355
+ }
356
+ return this.held;
357
+ }
358
+ keepSessions(all) {
359
+ const now = Date.now();
360
+ // the expired go, and at most the 100 newest are kept (a browser that keeps losing its cookie cannot grow it)
361
+ this.held = Object.fromEntries(Object.entries(all).filter(([, s]) => s.until > now).sort(([, a], [, b]) => b.until - a.until).slice(0, 100));
362
+ getActiveWorldStore().write(this.sessionsPath(), `${JSON.stringify(this.held)}\n`, { secret: true });
363
+ }
364
+ /** A new session of `scope`: its id, the cookie's value. A token's lasts 30 days; a person's from a pass, `who`,
365
+ * lasts as long as the pass's platform says (twelve hours), so a person removed there loses it within the day. */
366
+ openSession(scope, person, key) {
367
+ const id = mintToken('ses_');
368
+ let held = this.sessions();
369
+ // a person holds at most five sessions here: passes (anyone's with one) never crowd out everyone else's
370
+ if (person) {
371
+ const theirs = Object.entries(held).filter(([, x]) => x.who === person.who).sort(([, a], [, b]) => b.until - a.until);
372
+ if (theirs.length >= 5) {
373
+ const drop = new Set(theirs.slice(4).map(([k]) => k));
374
+ held = Object.fromEntries(Object.entries(held).filter(([k]) => !drop.has(k)));
375
+ }
376
+ }
377
+ const until = Math.min(Date.now() + (person?.ttlMs ?? 30 * 86_400_000), key?.until ?? Infinity);
378
+ this.keepSessions({ ...held, [id]: { scope, until, ...(person ? { who: person.who, issuer: person.issuer, ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
379
+ return id;
380
+ }
381
+ /** NAMED KEYS: what an app or a script holds (docs/contributing/architecture.md, "The hosted product"), each named
382
+ * when made, shown once, kept only as its hash, and revoked alone; the World's two tokens stay its first keys. */
383
+ heldKeys = null;
384
+ keys() {
385
+ if (this.heldKeys === null) {
386
+ try {
387
+ this.heldKeys = JSON.parse(getActiveWorldStore().read(keysFile(this.worldRoot)) ?? '[]');
388
+ }
389
+ catch {
390
+ this.heldKeys = [];
391
+ }
392
+ }
393
+ return this.heldKeys;
394
+ }
395
+ keepKeys(all) { this.heldKeys = all; getActiveWorldStore().write(keysFile(this.worldRoot), `${JSON.stringify(all)}\n`, { secret: true }); }
396
+ /** When a key was last used, to the minute (a key in constant use is not a write per request). */
397
+ touchKey(key) {
398
+ const now = new Date();
399
+ if (key.lastUsedAt && now.getTime() - Date.parse(key.lastUsedAt) < 60_000)
400
+ return;
401
+ this.keepKeys(this.keys().map((k) => (k.id === key.id ? { ...k, lastUsedAt: now.toISOString() } : k)));
402
+ }
403
+ /** `GET|POST /-/<world>/keys`, `DELETE /-/<world>/keys/<id>`, `DELETE /-/<world>/keys?person=<sub>`: named keys are
404
+ * managed with write access by the World's token or a person's session, never by a key (a key cannot make a key
405
+ * that outlives it). Each records who made it and whom it is for; the token may give one an end and a person, and
406
+ * revoke every key made by or for a person (their leaving the org). */
407
+ /** The person a key is for: whom it was made for, else the person whose session made it. */
408
+ keyPerson(k) { return k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.sub : undefined); }
409
+ /** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
410
+ * keys) a revoked key made. Answers the revoked keys' ids. */
411
+ async revokeKeys(pick) {
412
+ const gone = this.keys().filter(pick);
413
+ const ids = new Set(gone.map((k) => k.id));
414
+ const also = this.keys().filter((k) => !ids.has(k.id) && k.madeWith !== undefined && ids.has(k.madeWith)); // branches a revoked key made
415
+ for (const k of also)
416
+ ids.add(k.id);
417
+ this.keepKeys(this.keys().filter((k) => !ids.has(k.id)));
418
+ // the keys are gone whatever a removal answers; a branch that stays is said, not hidden (its parent key no longer opens this World)
419
+ for (const k of [...gone, ...also])
420
+ if (k.branch)
421
+ await this.host.branches?.remove(k.branch).catch((error) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${error instanceof Error ? error.message : String(error)}`); return false; });
422
+ return [...ids];
423
+ }
424
+ async keysDoor(request, id, scope, via, session) {
425
+ if (scope !== 'write')
426
+ return Response.json({ error: 'keys are managed with write access' }, { status: 403 });
427
+ if (via === 'key')
428
+ return Response.json({ error: 'a key manages no keys: use the World\'s token, or its page' }, { status: 403 });
429
+ const now = Date.now();
430
+ const live = () => this.keys().filter((k) => !k.expiresAt || Date.parse(k.expiresAt) > now);
431
+ const view = (k) => ({ 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 });
432
+ const person = (k) => this.keyPerson(k);
433
+ if (!id && request.method === 'GET')
434
+ return Response.json({ keys: live().map(view) });
435
+ if (!id && request.method === 'POST') {
436
+ const body = (await request.json().catch(() => ({})));
437
+ const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
438
+ if (!name)
439
+ return Response.json({ error: 'a key needs a name: what holds it (an app, a CI job, a laptop)' }, { status: 400 });
440
+ const keyScope = body.scope === 'read' ? 'read' : 'write';
441
+ // a session a platform's pass opened makes no keys: a key outlives the session, and a script on this origin (a
442
+ // stored page) could make one in the person's name. Keys for a platform's Worlds are made on the platform.
443
+ const opener = via === 'session' && session ? this.sessions()[session] : undefined;
444
+ if (opener?.issuer)
445
+ 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 });
446
+ // `for`, `replace` and an end are the token's to give (a platform making a command's key for a person)
447
+ if ((body.for !== undefined || body.replace !== undefined) && via !== 'token')
448
+ return Response.json({ error: 'for and replace are the World token\'s' }, { status: 403 });
449
+ const forWhom = typeof body.for === 'string' && /^[A-Za-z0-9:._@-]{1,160}$/.test(body.for) ? body.for : undefined;
450
+ let expiresAt;
451
+ if (body.expiresAt !== undefined) {
452
+ const t = typeof body.expiresAt === 'string' ? Date.parse(body.expiresAt) : NaN;
453
+ if (!(t > now))
454
+ return Response.json({ error: 'expiresAt: an ISO instant in the future' }, { status: 400 });
455
+ expiresAt = new Date(t).toISOString();
456
+ }
457
+ // replace: the key of the same name for the same person goes (one command, one key: asking again does not pile them up)
458
+ let kept = live();
459
+ if (body.replace === true)
460
+ kept = kept.filter((k) => !(k.name === name && person(k) === forWhom));
461
+ if (kept.length >= 200)
462
+ return Response.json({ error: 'this World holds 200 keys: revoke some first' }, { status: 409 });
463
+ const opened = via === 'session' && session ? this.sessions()[session] : undefined;
464
+ const createdBy = via === 'session' ? { via: 'session', ...(opened?.sub ? { sub: opened.sub } : {}), ...(opened?.who ? { who: opened.who } : {}), ...(opened?.issuer ? { issuer: opened.issuer } : {}) } : { via: 'token' };
465
+ const key = mintToken('tok_k_');
466
+ const held = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(expiresAt ? { expiresAt } : {}) };
467
+ this.keepKeys([...kept, held]);
468
+ return Response.json({ ...view(held), key }, { status: 201, headers: { 'cache-control': 'no-store' } });
469
+ }
470
+ if (!id && request.method === 'DELETE') {
471
+ // every key made by or for a person: their leaving the org ends what they hold here (the token's act)
472
+ const who = new URL(request.url).searchParams.get('person');
473
+ if (via !== 'token' || !who)
474
+ return Response.json({ error: 'DELETE /keys?person=<subject>, with the World\'s token' }, { status: via !== 'token' ? 403 : 400 });
475
+ // their keys, the parent keys of the branches they made, and those branches
476
+ return Response.json({ revoked: await this.revokeKeys((k) => person(k) === who) });
477
+ }
478
+ if (id && request.method === 'DELETE') {
479
+ const found = this.keys().find((k) => k.id === id);
480
+ if (!found)
481
+ return Response.json({ error: `no key ${id}` }, { status: 404 });
482
+ // a branch's parent key takes its branch with it, and a key takes the branches it made
483
+ await this.revokeKeys((k) => k.id === id);
484
+ return Response.json({ revoked: id, name: found.name });
485
+ }
486
+ return Response.json({ error: 'keys: GET or POST /keys, DELETE /keys/<id> or /keys?person=<subject>' }, { status: 405 });
487
+ }
488
+ /** Passes this World has already spent: each opens one session. */
489
+ spent = spentPasses();
490
+ /** A PASS opens a session: a person a trusted platform signed a pass for, into this World, at its own origin, asked
491
+ * from its own page (the console reads the pass from its address's fragment and posts it here). */
492
+ /** The org its host records as this World's owner (`<state>/owner`, written at its making or its claim), or null. */
493
+ recordedOwner() { try {
494
+ const o = getActiveWorldStore().read(join(this.worldRoot, stateDirName(), 'owner'))?.trim() ?? '';
495
+ return /^[A-Za-z0-9_-]{1,80}$/.test(o) ? o : null;
496
+ }
497
+ catch {
498
+ return null;
499
+ } }
500
+ async passSession(request, url, pass) {
501
+ const issuers = this.host.passIssuers?.() ?? [];
502
+ if (issuers.length === 0)
503
+ return Response.json({ error: `${this.served} trusts no platform's passes` }, { status: 401 });
504
+ const own = this.host.browserOrigin?.() ?? null;
505
+ // a pass opens only a World with an origin of its own: on a shared origin every other World's pages would reach the
506
+ // session it opened
507
+ if (own === null)
508
+ return Response.json({ error: `${this.served} has no origin of its own, where a pass could open it` }, { status: 409 });
509
+ if (this.sessionCookieName(url) === null)
510
+ return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
511
+ const checked = await verifyPass(pass, { issuers, world: this.served, audience: own, seen: this.spent });
512
+ if (!checked.ok)
513
+ return Response.json({ error: `that pass does not open ${this.served}: ${checked.reason}` }, { status: 401 });
514
+ // a World its host records an owner for opens only for that org: a platform that holds the World under another org
515
+ // (a slug taken again, a claim gone wrong) cannot open it
516
+ const owner = this.recordedOwner();
517
+ if (owner !== null && checked.claims.org !== owner)
518
+ return Response.json({ error: `that pass does not open ${this.served}: it names another org than the one that owns it` }, { status: 401 });
519
+ const who = checked.claims.email ?? checked.claims.name ?? checked.claims.sub;
520
+ const cookie = this.sessionCookie(url, this.openSession(checked.claims.scope, { who, ttlMs: 12 * 3_600_000, issuer: checked.claims.iss, sub: checked.claims.sub }));
521
+ // `issuer`: the platform the pass came from, verified, which the page offers as the way back (never an address from
522
+ // its own URL, which anyone can write)
523
+ return Response.json({ world: this.served, scope: checked.claims.scope, who, issuer: checked.claims.iss, origin: own }, { headers: { 'set-cookie': cookie } });
524
+ }
525
+ /** A named key that still opens this World: not revoked, not expired. */
526
+ liveKey(id) { const k = this.keys().find((x) => x.id === id); return !!k && (!k.expiresAt || Date.parse(k.expiresAt) > Date.now()); }
527
+ /** The named key a request presents, with the end of its life. */
528
+ presentedKey(request) {
529
+ const token = this.credential(request).token;
530
+ const k = token ? this.keys().find((x) => sameSecret(keyHash(token), x.hash)) : undefined;
531
+ return k ? { id: k.id, until: k.expiresAt ? Date.parse(k.expiresAt) : Infinity } : undefined;
532
+ }
533
+ endSession(id) { if (id && id in this.sessions()) {
534
+ const { [id]: _ended, ...rest } = this.sessions();
535
+ this.keepSessions(rest);
536
+ } }
537
+ endSessions() { if (Object.keys(this.sessions()).length)
538
+ this.keepSessions({}); }
254
539
  /** The world's twins, by vendor, once booted. */
255
540
  twins() { return Object.keys(this.up().services); }
256
541
  up() { return this.host.layout(); }
257
542
  /** The world's token, presented the way the app's SDK presents a credential: the world's own header,
258
543
  * the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
259
544
  * GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
260
- scope(request) { return this.match(request)?.scope ?? null; }
261
- /** The World token the request presented, in whichever form, and the scope it opens. */
262
- match(request) {
263
- const authorization = request.headers.get('authorization') ?? '';
264
- const bearer = /^(?:Bearer|token)\s+(\S+)$/i.exec(authorization)?.[1];
265
- const basic = /^Basic\s+(\S+)$/i.exec(authorization)?.[1];
266
- const basicSecret = basic ? (() => { try {
267
- const text = Buffer.from(basic, 'base64').toString('utf8');
268
- const i = text.indexOf(':');
269
- return i >= 0 ? text.slice(i + 1) : text;
270
- }
271
- catch {
272
- return undefined;
273
- } })() : undefined;
274
- // a browser holding a session for this World (POST …/session): what a mirror page reads the wire with
275
- const cookie = /(?:^|;\s*)volter_world=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
276
- // every form presented is a candidate: a mirror page sends the VENDOR's bearer (the X token its
277
- // sign-in holds) beside the World's session cookie, and the cookie is what opens the World
278
- const headed = [request.headers.get(TOKEN_HEADER), request.headers.get(TWINS_KEY_HEADER), bearer, basicSecret];
279
- const session = cookie ? decodeURIComponent(cookie) : null;
280
- for (const [scope, token] of [['write', this.token], ['read', this.readToken]]) {
281
- if (headed.includes(token))
282
- return { scope, token, fromSession: false };
283
- if (session === token)
284
- return { scope, token, fromSession: true };
285
- }
286
- return null;
545
+ scope(request) { return this.credential(request).scope; }
546
+ /** The request's scope, whether it came by a token the caller presented or by a browser's session
547
+ * (the cookie `POST …/session` set), and the token that opened it. Every form presented is a
548
+ * candidate: a mirror page sends the VENDOR's bearer (the token its sign-in holds) beside the World's
549
+ * session cookie, and the cookie is what opens the World. With a browser origin of its own
550
+ * (`browserOrigin`), a World honours its session only on that origin: a session cookie on any other
551
+ * (a host's shared origin, path-addressed) opens nothing. */
552
+ /** What this World issued, for world-access to decide a grant against: its two tokens, its named keys, its sessions. */
553
+ issued() {
554
+ return {
555
+ world: this.served, token: this.token, readToken: this.readToken,
556
+ // a NAMED KEY (an app's or a script's, revoked alone): known by its hash; it acts as the token of its scope
557
+ key: (p) => { if (!p.startsWith('tok_k_'))
558
+ return null; const held = this.keys().find((k) => sameSecret(keyHash(p), k.hash)); if (!held || (held.expiresAt && Date.parse(held.expiresAt) <= Date.now()))
559
+ return null; this.touchKey(held); return held.scope; },
560
+ // a session a shared link's read key opened ends with that key: revoked or expired, it opens nothing
561
+ 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; },
562
+ };
563
+ }
564
+ credential(request) {
565
+ const presented = presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER });
566
+ const name = this.sessionCookieName(new URL(request.url));
567
+ // the name is one of two fixed literals (sessionCookieName); the separator browsers use is "; "
568
+ const cookie = name === null ? undefined : new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
569
+ // the decision is world-access's (the architecture's "access is decided in one place"); this World says what it issued
570
+ const grant = grantOf(presented, cookie ? decodeURIComponent(cookie) : null, this.issued());
571
+ if (!grant)
572
+ return { scope: null, via: presented.length ? 'token' : null, token: null };
573
+ // `token` is only ever the secret the caller presented (a named key is answered as itself): the World's own tokens
574
+ // never reach a key's or a session's holder
575
+ if (grant.via === 'session')
576
+ return { scope: grant.scope, via: 'session', token: null, session: grant.session, ...(grant.who ? { who: grant.who } : {}) };
577
+ return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
578
+ }
579
+ /** The session cookie's name where `url` reached this World, or null where a session opens nothing:
580
+ * on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
581
+ * the path-scoped cookie a shared origin has always carried. */
582
+ sessionCookieName(url) {
583
+ const own = this.host.browserOrigin?.() ?? null;
584
+ if (own === null)
585
+ return 'volter_world';
586
+ if (url.host !== new URL(own).host)
587
+ return null;
588
+ return url.protocol === 'https:' ? '__Host-volter_world' : 'volter_world';
589
+ }
590
+ /** The session cookie for `url`, set to `value`: host-only over the whole origin on the World's own
591
+ * origin (a `__Host-` cookie must be), scoped to the World's paths on a shared one. Null where a
592
+ * session opens nothing (another World's origin). */
593
+ sessionCookie(url, value, extra = '') {
594
+ const name = this.sessionCookieName(url);
595
+ if (name === null)
596
+ return null;
597
+ const own = this.host.browserOrigin?.() ?? null;
598
+ return `${name}=${value}; Path=${own ? '/' : `/${this.served}/`}; HttpOnly; SameSite=Strict${url.protocol === 'https:' ? '; Secure' : ''}${extra}`;
599
+ }
600
+ /** A browser's session opens the World only to the World's own pages: a request the session cookie carries must
601
+ * say `Sec-Fetch-Site: same-origin` (a browser sets it; a page cannot), or be a read the person navigated to
602
+ * (`none`: a typed or bookmarked address). Another page of the same site (a sibling World's origin shares its
603
+ * suffix) is refused its reads too, so a twin that echoes the caller's Origin with credentials cannot hand one
604
+ * World's data to another World's page; a request with no header fails closed. Null when it may pass. */
605
+ crossOriginSession(request, via) {
606
+ if (via !== 'session')
607
+ return null;
608
+ const site = request.headers.get('sec-fetch-site');
609
+ if (site === 'same-origin')
610
+ return null;
611
+ const read = request.method === 'GET' || request.method === 'HEAD';
612
+ if (read && site === 'none')
613
+ return null;
614
+ return Response.json({ error: read
615
+ ? "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)"
616
+ : "a World's browser session writes only from the World's own pages (Sec-Fetch-Site: same-origin)" }, { status: 403 });
287
617
  }
288
618
  /** Open one connection on a byte-stream door for the holder of the World's (write) token; null when
289
619
  * the token is not the World's or the World has no such stream. */
290
620
  openStream(token, id, sink, peer) {
291
- if (token !== this.token || !this.host.openStream || !(id in (this.host.streams?.() ?? {})))
621
+ // the World's write token or a write key opens a stream (world-access decides, as for every door)
622
+ if (grantOf([token], null, this.issued())?.scope !== 'write' || !this.host.openStream || !(id in (this.host.streams?.() ?? {})))
292
623
  return null;
293
624
  return this.host.openStream(id, sink, peer);
294
625
  }
@@ -317,8 +648,8 @@ export class WorldDoors {
317
648
  // place under this World, and the token the injector carries to them — the presented one
318
649
  if (url.pathname === `${prefix}.well-known/volter-world` && request.method === 'GET') {
319
650
  // the manifest hands its caller the token it presented; a session's token is HttpOnly, never echoed to script
320
- const matched = this.match(request);
321
- if (matched === null || matched.fromSession)
651
+ const matched = this.credential(request);
652
+ if (matched.token === null || matched.via === 'session')
322
653
  return Response.json({ error: 'token required' }, { status: 401 });
323
654
  const presented = matched.token;
324
655
  const vendors = Object.fromEntries(Object.keys(this.up().services).map((v) => [v, `${url.origin}/${this.served}/${v}`]));
@@ -330,44 +661,146 @@ export class WorldDoors {
330
661
  const endpoints = Object.fromEntries(Object.keys(vendors).flatMap((v) => { const name = APP_READ_ENDPOINT_ENV[v]?.injectEnv; return name ? [[name, vendors[v]]] : []; }));
331
662
  return Response.json({ name: this.served, vendors, ca: null, proxy: null, env: { ...endpoints, VOLTER_TWINS_KEY: presented }, ...(Object.keys(streams).length ? { streams } : {}) });
332
663
  }
333
- if (url.pathname.startsWith(prefix))
334
- return await this.wire(request, url, url.pathname.slice(prefix.length));
664
+ if (url.pathname.startsWith(prefix)) {
665
+ const rest = url.pathname.slice(prefix.length);
666
+ const mirror = /^([a-z0-9_-]+)\/mirror(?:\/(.*))?$/.exec(rest);
667
+ if (mirror)
668
+ return await this.mirror(request, url, mirror[1], mirror[2] ?? '');
669
+ return await this.wire(request, url, rest);
670
+ }
335
671
  return Response.json({ error: `not this world: serving /${this.served}/` }, { status: 404 });
336
672
  }
337
673
  catch (error) {
338
674
  return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
339
675
  }
340
676
  }
677
+ /** THE MIRROR MOUNT: `/<served>/<vendor>/mirror/` and `…/mirror/assets/<file>`, keyless and GET only —
678
+ * the pack's own shell and client. The shell's <base> is the twin's place in this World, so the
679
+ * mirror reads and writes the World's wire with the browser's session (`POST …/session`); any
680
+ * other path under mirror/ is one of the mirror's own routes (a reloaded deep link) and gets the
681
+ * shell. A link is not a twin and has no mirror. */
682
+ async mirror(request, url, vendor, rest, /** answered at the twin's place (the vendor's site) */ atSite = false) {
683
+ if (request.method !== 'GET' && request.method !== 'HEAD')
684
+ return Response.json({ error: 'the mirror mount serves the shell and its assets only' }, { status: 405, headers: { allow: 'GET, HEAD' } });
685
+ const isAsset = MIRROR_ASSET.test(rest);
686
+ if (rest.startsWith('assets/') && !isAsset)
687
+ return Response.json({ error: 'no such asset' }, { status: 404 });
688
+ if (!this.up().services[vendor] || !this.host.mirror)
689
+ return Response.json({ error: `no mirror for ${vendor} in ${this.served}` }, { status: 404 });
690
+ const file = await this.host.mirror(vendor, isAsset ? rest : 'index.html');
691
+ if (!file || file.status !== 200)
692
+ return Response.json({ error: isAsset ? 'no such asset' : `no mirror for ${vendor} in ${this.served}` }, { status: 404 });
693
+ if (isAsset)
694
+ return file;
695
+ // the shell's own address is the vendor's site at the twin's place (wire, above): a page opened here goes there, so
696
+ // a mirror that routes by its path sees its home, not `mirror/` (the browser keeps any `#/…` route)
697
+ if (rest === '' && !atSite && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? ''))
698
+ return new Response(null, { status: 302, headers: { location: `/${this.served}/${vendor}/${url.search}` } });
699
+ // the shell's <base> is the twin's place under this World (its reads go to the World's wire), its assets stay under
700
+ // mirror/, and its `#/…` links keep working under that base (world-core mirror-shell.ts)
701
+ let html = mirrorShellUnder(await file.text(), `/${this.served}/${vendor}/`);
702
+ // SIGNED IN AS the config says (`signIn.as`): the screens are handed the account and a credential the World minted
703
+ // for it, before they start. Only to a request that may write this World (a read link signs no one in, and minting
704
+ // is a write), on the vendor's site (never the keyless mount), at the World's own origin: a shared origin's other
705
+ // pages, and a rebound name reaching this port, are not this World's pages. Checked here, whoever called.
706
+ const own = this.host.browserOrigin?.() ?? null;
707
+ const signs = atSite && own !== null && url.host === new URL(own).host && this.credential(request).scope === 'write';
708
+ const as = signs ? loadWorldConfig(this.configRef, this.worldRoot).config.services.find((s) => s.id === vendor)?.signIn?.as : undefined;
709
+ const given = as ? await this.signedIn(vendor, as, url.origin) : null;
710
+ if (given) {
711
+ // data, never markup: every character that could end the script or the line is escaped, and the replacement is a
712
+ // function, so no `$&` or `$'` in a value is expanded into the page
713
+ const data = JSON.stringify({ account: given.account, token: given.token }).replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
714
+ html = html.replace(/<script/i, (tag) => `<script>window.__VOLTER_SIGN_IN__=${data};</script>${tag}`);
715
+ }
716
+ // where the console lives on the World's own origin, only that origin frames the World's pages (no clickjacking);
717
+ // a page that carries a credential is never stored, and differs by who asked
718
+ 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'" } : {}) } });
719
+ }
720
+ /** A vendor's screens' sign-in (`signIn.as`), once per account every twelve hours while this World is served (rotating the
721
+ * tokens forgets them): the pack finds the account and mints its credential through the twin's own doors; a failure
722
+ * is not kept, so the next page asks again. An account or credential that is not plain printable text is refused. */
723
+ signIns = new Map();
724
+ signInAt = new Map();
725
+ signedIn(vendor, as, origin) {
726
+ const key = `${vendor}\0${as}`;
727
+ if (Date.now() - (this.signInAt.get(key) ?? 0) > 43_200_000)
728
+ this.signIns.delete(key);
729
+ let held = this.signIns.get(key);
730
+ if (!held) {
731
+ this.signInAt.set(key, Date.now());
732
+ const twin = this.host.twinFetch(vendor);
733
+ held = !twin || !this.host.mirrorSignIn ? Promise.resolve(null) : this.host.mirrorSignIn(vendor, as, {
734
+ root: this.controlRoot(vendor),
735
+ twin: (path, init) => twin(new Request(`${origin}${path.startsWith('/') ? path : `/${path}`}`, init)),
736
+ }).then((given) => (given && /^[\x21-\x7e]{1,512}$/.test(given.token) && /^[^\x00-\x1f\x7f]{1,256}$/.test(given.account) ? given : null)).catch(() => null);
737
+ this.signIns.set(key, held);
738
+ void held.then((given) => { if (!given)
739
+ this.signIns.delete(key); });
740
+ }
741
+ return held;
742
+ }
743
+ /** Whether a twin enforces a read-scope request itself (its manifest names `requestScopes: ['read']`):
744
+ * only then may the read token reach it with anything but GET or HEAD. Asked once per twin. */
745
+ readEnforced = new Map();
746
+ async enforcesRead(vendor, twin, origin) {
747
+ // asked again after a minute: a twin that was down, or a pack swapped under a running World, is seen
748
+ const known = this.readEnforced.get(vendor);
749
+ if (known !== undefined && Date.now() - known.at < 60_000)
750
+ return known.enforced;
751
+ let enforced = false;
752
+ try {
753
+ const answer = await twin(new Request(`${origin}/twin`, { headers: { [READ_ONLY_HEADER]: '1' } }));
754
+ if (answer.ok) {
755
+ const scopes = (await answer.json()).requestScopes;
756
+ enforced = Array.isArray(scopes) && scopes.includes('read');
757
+ }
758
+ }
759
+ catch {
760
+ enforced = false;
761
+ }
762
+ this.readEnforced.set(vendor, { enforced, at: Date.now() });
763
+ return enforced;
764
+ }
341
765
  /** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
342
766
  async wire(request, url, rest) {
343
767
  const [vendor, ...tail] = rest.split('/');
344
- if (tail[0] === 'mirror')
345
- return await this.mirror(request, vendor, tail.slice(1).join('/'));
346
- const scope = this.scope(request);
768
+ const { scope, via } = this.credential(request);
347
769
  const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
348
770
  if (scope === null && !manifestAsk)
349
771
  return Response.json({ error: 'token required' }, { status: 401 });
350
- if (scope === 'read' && request.method !== 'GET' && request.method !== 'HEAD')
351
- return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
772
+ const foreign = this.crossOriginSession(request, via);
773
+ if (foreign)
774
+ return foreign;
775
+ const reads = request.method === 'GET' || request.method === 'HEAD';
352
776
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
353
777
  // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
354
- // a person's browser landing on a twin's root (a mirror that moved to a `#/…` route by assigning
355
- // location, which resolves against the twin's <base>) is sent to the mirror; the browser keeps the hash
356
- if (request.method === 'GET' && tail.join('/') === '' && request.headers.get('sec-fetch-dest') === 'document' && this.up().services[vendor] && (await this.hasMirror(vendor))) {
357
- return new Response(null, { status: 302, headers: { location: `/${this.served}/${vendor}/mirror/` } });
358
- }
778
+ // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
779
+ const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
359
780
  const link = this.link(vendor);
360
- if (link)
781
+ if (link) {
782
+ if (scope === 'read' && !reads)
783
+ return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
361
784
  return await this.forward(request, url, tail.join('/'), vendor, link.origin);
785
+ }
362
786
  const twin = this.host.twinFetch(vendor);
363
787
  if (!twin)
364
788
  return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
789
+ // THE READ SCOPE: the twin is told the request is read-only and refuses the caller's writes itself
790
+ // (a Slack read over POST passes, a post is refused); a twin that cannot enforce that gets reads only
791
+ const enforced = scope === 'read' && (await this.enforcesRead(vendor, twin, url.origin));
792
+ if (scope === 'read' && !reads && !enforced)
793
+ return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
365
794
  const headers = new Headers(request.headers);
366
795
  headers.delete(TOKEN_HEADER);
367
796
  headers.delete(TWINS_KEY_HEADER);
368
797
  headers.delete('host');
798
+ // at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
799
+ // that would write for its caller is refused too); at any other, its GET passes as it always has
800
+ if (enforced)
801
+ headers.set(READ_ONLY_HEADER, '1');
369
802
  // the browser's World session is the World's credential, never the twin's (a tunnel would hand it on)
370
- const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !c.startsWith('volter_world='));
803
+ const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_world=/.test(c));
371
804
  if (cookies.length)
372
805
  headers.set('cookie', cookies.join('; '));
373
806
  else
@@ -381,45 +814,43 @@ export class WorldDoors {
381
814
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
382
815
  if (answer.status === 101)
383
816
  return answer;
817
+ // THE VENDOR'S SITE: a page the twin does not serve as a page itself (it serves an OAuth screen, a redirect) is the
818
+ // vendor's own screens, the mirror, answered at that very address: the twin's place is the vendor's site, and a
819
+ // mirror's routes are real URLs, reloadable and linkable, as a dev server falls back to its app for unknown pages
820
+ const servedAsPage = (answer.status >= 300 && answer.status < 400) || (answer.status < 300 && (answer.headers.get('content-type') ?? '').includes('text/html'));
821
+ // a credential reached it: the manifest's keyless GET never becomes a page (and never signs anyone in). A resource
822
+ // the twin answers (a JSON link, a stored file, a PDF: 2xx) opened in a tab is that resource, as the vendor's own
823
+ // address would be; an address it refuses or has nothing at (a browser brings no vendor token), and the site's
824
+ // home, are the screens
825
+ const unanswered = answer.status >= 400 || tail.join('/') === '';
826
+ if (page && scope !== null && !servedAsPage && unanswered && (await this.host.hasMirror?.(vendor))) {
827
+ await answer.body?.cancel().catch(() => undefined);
828
+ return await this.mirror(request, url, vendor, '', true);
829
+ }
384
830
  // staleness is a fact on the wire: a twin with a root says when the vendor was last observed
385
831
  const out = new Headers(answer.headers);
386
832
  const observedAt = this.observedAt(vendor);
387
833
  if (observedAt)
388
834
  out.set('x-volter-observed-at', observedAt);
835
+ // served on the World's origin beside its session: a twin's bytes are never sniffed into a script or a page,
836
+ // and a twin cannot set (fix, shadow or clear) the World's own session cookie
837
+ out.set('x-content-type-options', 'nosniff');
838
+ // a stored file opened as a page (an uploaded SVG, an XML document) runs nothing on the World's origin: sandboxed, it is
839
+ // an origin of its own with no scripts, so it can neither read the session's World nor make it a key
840
+ const type = (answer.headers.get('content-type') ?? '').toLowerCase();
841
+ if (page && answer.status < 300 && /^(image\/svg\+xml|application\/xhtml\+xml|text\/xml|application\/xml)\b/.test(type))
842
+ out.set('content-security-policy', 'sandbox');
843
+ // a page and an API call to one address are answered differently (the vendor's site): caches keep them apart
844
+ out.append('vary', 'Sec-Fetch-Dest');
845
+ const setCookies = answer.headers.getSetCookie();
846
+ if (setCookies.some((c) => /^\s*(?:__Host-)?volter_world=/i.test(c))) {
847
+ out.delete('set-cookie');
848
+ for (const c of setCookies)
849
+ if (!/^\s*(?:__Host-)?volter_world=/i.test(c))
850
+ out.append('set-cookie', c);
851
+ }
389
852
  return new Response(answer.body, { status: answer.status, headers: out });
390
853
  }
391
- /** Whether the twin's pack ships a mirror (its vendor's UI over this World's state). */
392
- async hasMirror(vendor) {
393
- const mod = await packMirrorExports(vendor, this.worldRoot).catch(() => null);
394
- return !!mod && Object.keys(mod).some((n) => /^\w+MirrorHtml$/.test(n)) && Object.keys(mod).some((n) => /^build\w+MirrorClient$/.test(n));
395
- }
396
- /** THE MIRROR MOUNT, as the hosted World serves it (apps/cloud supervisor.ts): `/<org>/<world>/<vendor>/mirror/`
397
- * and `…/mirror/assets/*`, keyless, GET only — the pack's own shell and bundle. The shell's <base> is the
398
- * twin's place under this World, so its reads go to the keyed wire with the browser's World session. */
399
- async mirror(request, vendor, rest) {
400
- if (request.method !== 'GET')
401
- return Response.json({ error: 'the mirror mount serves the shell and its assets only' }, { status: 404 });
402
- const isAsset = /^assets\/[A-Za-z0-9._-]+$/.test(rest);
403
- if (rest.startsWith('assets/') && !isAsset)
404
- return Response.json({ error: 'no such asset' }, { status: 404 });
405
- if (!this.up().services[vendor])
406
- return Response.json({ error: 'no mirror for this twin' }, { status: 404 });
407
- const mod = await packMirrorExports(vendor, this.worldRoot);
408
- const pick = (re) => { const k = mod && Object.keys(mod).find((n) => re.test(n) && typeof mod[n] === 'function'); return k ? mod[k] : undefined; };
409
- const html = pick(/^\w+MirrorHtml$/);
410
- const client = pick(/^build\w+MirrorClient$/);
411
- const styles = pick(/^\w+MirrorStyles$/);
412
- if (!html || !client)
413
- return Response.json({ error: 'no mirror for this twin' }, { status: 404 });
414
- if (rest === 'assets/app.js')
415
- return new Response(await client(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
416
- if (rest === 'assets/styles.css' && styles)
417
- return new Response(await styles(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
418
- if (isAsset)
419
- return Response.json({ error: 'no such asset' }, { status: 404 });
420
- const shell = mirrorShellUnder(await html(), `/${this.served}/${vendor}/`);
421
- return new Response(shell, { headers: { 'content-type': 'text/html; charset=utf-8' } });
422
- }
423
854
  linksPath() { return join(this.worldRoot, stateDirName(), 'links.json'); }
424
855
  /** The World's links: name → the vendor origin it forwards to. */
425
856
  links() {
@@ -494,14 +925,32 @@ export class WorldDoors {
494
925
  // form body, the session set here, then on to a page of this World
495
926
  // a form or beacon from another site never opens or ends a session here (the console, on this host's
496
927
  // next port, is same-site)
497
- const crossSite = request.headers.get('sec-fetch-site') === 'cross-site';
928
+ // With an origin of its own, a World's session is opened and ended only from its own pages (or an address typed
929
+ // in): a sibling World is the same site and must not end or re-issue it (a logout CSRF). A request that says
930
+ // nothing (not a browser) is left to the token the form carries.
931
+ const site = request.headers.get('sec-fetch-site');
932
+ const crossSite = this.host.browserOrigin?.() ? site !== null && site !== 'same-origin' && site !== 'none' : site === 'cross-site';
498
933
  if (sessionPath === 'session' && request.method === 'POST' && (request.headers.get('content-type') ?? '').startsWith('application/x-www-form-urlencoded')) {
499
934
  if (crossSite)
500
935
  return new Response('A session is opened from this host\'s own pages.', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } });
501
936
  const form = new URLSearchParams(await request.text());
502
937
  const token = form.get('token') ?? '';
503
- if (token !== this.token && token !== this.readToken)
938
+ // the token or a named key typed into the form, decided by world-access as every door's is
939
+ const found = token ? grantOf([token], null, this.issued()) : null;
940
+ // the World's token opens a session here; a named key is an app's and opens none, except a READ key (a shared
941
+ // read-only link), whose session is bound to it as the header door's is: read only, ended with the key
942
+ const granted = found?.via === 'token' || (found?.via === 'key' && found.scope === 'read') ? found : null;
943
+ if (!granted)
504
944
  return new Response('That token does not open this World.', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8' } });
945
+ if (this.sessionCookieName(url) === null)
946
+ 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' } });
947
+ 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;
948
+ if (granted.via === 'key' && !bound)
949
+ return new Response('That token does not open this World.', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8' } });
950
+ const cookie = this.sessionCookie(url, this.openSession(granted.scope, undefined, bound));
951
+ // a World with an origin of its own keeps its session there: asked elsewhere, it says where
952
+ if (cookie === null)
953
+ 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' } });
505
954
  const next = form.get('next') ?? '';
506
955
  // the path the browser would land on, normalized (%2e%2e resolves), and only inside this World
507
956
  const landing = (() => { try {
@@ -512,20 +961,67 @@ export class WorldDoors {
512
961
  return '';
513
962
  } })();
514
963
  const to = next.startsWith('/') && !next.startsWith('//') && !/[\\\s]/.test(next) && landing.startsWith(`/${this.served}/`) ? landing : `/${this.served}/`;
515
- const secure = url.protocol === 'https:' ? '; Secure' : '';
516
- return new Response(null, { status: 303, headers: { location: to, 'set-cookie': `volter_world=${encodeURIComponent(token)}; Path=/${this.served}/; HttpOnly; SameSite=Strict${secure}` } });
964
+ return new Response(null, { status: 303, headers: { location: to, 'set-cookie': cookie } });
517
965
  }
518
966
  if (((sessionPath === 'session' && request.method === 'DELETE') || (sessionPath === 'session/end' && request.method === 'POST')) && !crossSite) {
519
- const secure = url.protocol === 'https:' ? '; Secure' : '';
520
- 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}` } });
967
+ this.endSession(this.credential(request).session ?? null);
968
+ const cookie = this.sessionCookie(url, '', '; Max-Age=0');
969
+ return new Response(JSON.stringify({ world: this.served, ended: true }), { status: 200, headers: { 'content-type': 'application/json', ...(cookie ? { 'set-cookie': cookie } : {}) } });
970
+ }
971
+ // a person arriving from a platform with a pass: from this World's own page only (the pass is in its fragment)
972
+ const pass = request.headers.get('x-volter-pass');
973
+ if (pass !== null && sessionPath === 'session' && request.method === 'POST') {
974
+ if (crossSite || (this.host.browserOrigin?.() && site !== 'same-origin'))
975
+ return Response.json({ error: "a pass opens a session from this World's own page" }, { status: 403 });
976
+ return await this.passSession(request, url, pass);
521
977
  }
522
- const scope = this.scope(request);
978
+ const { scope, via } = this.credential(request);
979
+ // a local World's own page asking for its session with nothing to show: it is the person at this machine
980
+ if (scope === null && sessionPath === 'session' && request.method === 'POST' && this.localPage(request, url))
981
+ return this.localSession(url);
523
982
  if (scope === null)
524
983
  return Response.json({ error: 'token required' }, { status: 401 });
525
- if (scope === 'read' && request.method !== 'GET')
526
- return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
527
984
  const parts = path.split('/').filter(Boolean);
528
985
  const [kind, name, verb] = parts;
986
+ // a browser session is opened by either token: the read token's is how a view-only link opens a mirror
987
+ // the session gate comes first: a page that holds only the cookie (a sibling World's, the same site) can neither
988
+ // re-issue nor end the session through this door
989
+ const foreign = this.crossOriginSession(request, via);
990
+ if (foreign)
991
+ return foreign;
992
+ // a named key is an app's: it opens no browser session (which would outlive the key and could make keys of its own),
993
+ // except a READ key, which is how a shared read-only link opens the World's pages: its session reads only, makes no
994
+ // keys, and ends when the key is revoked or expires
995
+ if (kind === 'session' && !name && request.method === 'POST' && via === 'key' && scope !== 'read')
996
+ return Response.json({ error: 'a key opens no browser session: open the World with its token, or from the platform' }, { status: 403 });
997
+ if (kind === 'session' && !name && (request.method === 'POST' || request.method === 'DELETE'))
998
+ return this.session(request, url, scope, via === 'key' ? this.presentedKey(request) : undefined);
999
+ if (kind === 'keys')
1000
+ return this.keysDoor(request, name, scope, via, this.credential(request).session);
1001
+ if (scope === 'read' && request.method !== 'GET')
1002
+ return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1003
+ if (kind === 'clock')
1004
+ return await this.clockDoor(request, name);
1005
+ if (kind === 'branches')
1006
+ return await this.branchesDoor(request, url, parts.slice(1), via);
1007
+ // each twin's history cut at an instant: where a branch "as of" that instant starts
1008
+ if (kind === 'history' && !name && request.method === 'GET') {
1009
+ const instant = url.searchParams.get('at') ?? '';
1010
+ if (Number.isNaN(Date.parse(instant)))
1011
+ return Response.json({ error: 'history?at=<ISO-8601 instant>' }, { status: 400 });
1012
+ const views = {};
1013
+ withAncestryLock(() => { for (const vendor of Object.keys(this.up().services))
1014
+ views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); });
1015
+ return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
1016
+ }
1017
+ if (kind === 'map' && !name && request.method === 'GET')
1018
+ return Response.json(await this.map());
1019
+ if (kind === 'timeline' && !name && request.method === 'GET')
1020
+ return this.timeline(url);
1021
+ if (kind === 'diff' && !name && request.method === 'GET') {
1022
+ const delta = diffWorld(this.name, { root: this.worldRoot });
1023
+ 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 })) });
1024
+ }
529
1025
  if (kind === 'log' && name && request.method === 'GET') {
530
1026
  const state = this.stateOf(name);
531
1027
  const after = Number(url.searchParams.get('after') ?? '0');
@@ -573,13 +1069,6 @@ export class WorldDoors {
573
1069
  return Response.json({ error: 'approve: { as, note? }' }, { status: 400 });
574
1070
  return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal: body.as, ...(body.note ? { note: body.note } : {}) }));
575
1071
  }
576
- // a browser session for this World: the presented token as an HttpOnly cookie scoped to the World's
577
- // paths, so a page served under it (a twin's mirror) reads the wire as the token's holder
578
- if (kind === 'session' && !name && request.method === 'POST') {
579
- const presented = this.match(request).token;
580
- const secure = url.protocol === 'https:' ? '; Secure' : '';
581
- 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}` } });
582
- }
583
1072
  if (kind === 'links' && !name && request.method === 'GET')
584
1073
  return Response.json({ links: this.links() });
585
1074
  if (kind === 'links' && name && request.method === 'PUT') {
@@ -644,9 +1133,10 @@ export class WorldDoors {
644
1133
  const body = (await request.json());
645
1134
  const at = /^(https?:\/\/[^/]+)\/([a-z0-9][a-z0-9_-]*\/[a-z0-9][a-z0-9_-]*)\/?$/i.exec(body.url ?? '');
646
1135
  if (!at || !body.token)
647
- return Response.json({ error: 'PUT origin: { url: "https://<host>/<org>/<world>", token }' }, { status: 400 });
1136
+ return Response.json({ error: 'PUT origin: { url: "https://<host>/<org>/<world>", token, views? }' }, { status: 400 });
648
1137
  store.write(tokenPath, `${body.token}\n`, { secret: true });
649
- const fetched = await this.serialized('push', () => fetchFromOrigin(this.name, { root: this.worldRoot, url: at[1], namespace: at[2], key: body.token, full: true }));
1138
+ // `views` (the origin's history door answers them) clones each twin's history as of a cut
1139
+ 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 } : {}) }));
650
1140
  return Response.json({ origin: fetched.origin, appended: fetched.appended });
651
1141
  }
652
1142
  if (name === 'pull' && request.method === 'POST') {
@@ -707,6 +1197,14 @@ export class WorldDoors {
707
1197
  if (kind === 'push' && request.method === 'POST')
708
1198
  return await this.serialized('push', () => this.push(request));
709
1199
  if (kind === 'deploy' && request.method === 'POST') {
1200
+ // a deploy acts on the real vendors: a browser's session deploys only what the request itself names, so no link,
1201
+ // page or request built from a path (a console route, a crafted URL) deploys by being opened. A caller presenting
1202
+ // the token is the operator's own tool, as before.
1203
+ if (via === 'session') {
1204
+ const asked = (await request.json().catch(() => null));
1205
+ if (asked?.confirm !== (name ?? this.served))
1206
+ return Response.json({ error: `a browser deploys only what it names: POST { "confirm": "${name ?? this.served}" }` }, { status: 400 });
1207
+ }
710
1208
  const outcomes = await this.serialized('push', () => deployWorld(this.name, { root: this.worldRoot, instance: this.up(), ...(name ? { changeset: name } : {}) }));
711
1209
  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 } : {}) })) });
712
1210
  }
@@ -811,7 +1309,7 @@ export class WorldDoors {
811
1309
  refresh = null;
812
1310
  }
813
1311
  const last = [...parentEntries(state, this.controlRoot(name))].reverse().find((e) => e.landsId && e.receipt);
814
- 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) });
1312
+ 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 });
815
1313
  }
816
1314
  if (verb === 'refresh' && request.method === 'POST') {
817
1315
  const root = rootForControlRoot(this.controlRoot(name), name);
@@ -822,6 +1320,252 @@ export class WorldDoors {
822
1320
  }
823
1321
  return Response.json({ error: `no such door: ${request.method} /-/${this.served}/${path}` }, { status: 404 });
824
1322
  }
1323
+ /** A browser session for this World: the presented token as an HttpOnly cookie scoped to the World's
1324
+ * paths, so a page served under it (a twin's mirror) reads the wire as the token's holder, in the
1325
+ * token's scope. */
1326
+ /** THE LOCAL SESSION: a World served on this machine's loopback never asks the person at it for a token. Any
1327
+ * 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
1328
+ * page, as Vite and Jupyter check their dev servers: the Host is a loopback name (a rebound DNS name is not, so DNS
1329
+ * rebinding fails), the browser's Origin is exactly this origin (another site's page cannot claim it, so CSRF
1330
+ * fails), and the fetch says it is same-origin. A process on this machine can claim all three: a local World
1331
+ * trusts this machine's user, as a local dev server does. The session is the write token's. A World with no origin
1332
+ * of its own (a shared origin, where every World's pages are one origin) is not trusted so: its page asks for a
1333
+ * token. */
1334
+ localPage(request, url) {
1335
+ // only where the World has an origin of its own: on a shared origin every World's pages (and each twin's own
1336
+ // HTML) are the same origin, and none may be handed another's session
1337
+ if (!this.host.localTrust?.() || !this.host.browserOrigin?.())
1338
+ return false;
1339
+ const name = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
1340
+ const loopback = name === 'localhost' || name.endsWith('.localhost') || name === '127.0.0.1' || name === '::1';
1341
+ const site = request.headers.get('sec-fetch-site');
1342
+ // a request that does not say it is same-origin fails closed (every browser this serves says it)
1343
+ return loopback && request.headers.get('origin') === url.origin && site === 'same-origin';
1344
+ }
1345
+ localSession(url) {
1346
+ const own = this.host.browserOrigin?.() ?? null;
1347
+ if (this.sessionCookieName(url) === null)
1348
+ return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
1349
+ return Response.json({ world: this.served, scope: 'write', local: true, ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write')) } });
1350
+ }
1351
+ session(request, url, scope, key) {
1352
+ const own = this.host.browserOrigin?.() ?? null;
1353
+ // a World with an origin of its own keeps its session there: asked elsewhere, it says where
1354
+ if (this.sessionCookieName(url) === null)
1355
+ return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
1356
+ const held = this.credential(request).session ?? null;
1357
+ if (request.method === 'DELETE') {
1358
+ this.endSession(held);
1359
+ return Response.json({ world: this.served, ended: true }, { headers: { 'set-cookie': this.sessionCookie(url, '', '; Max-Age=0') } });
1360
+ }
1361
+ const cookie = this.sessionCookie(url, held ?? this.openSession(scope, undefined, key));
1362
+ const who = held ? this.sessions()[held]?.who : undefined;
1363
+ const issuer = held ? this.sessions()[held]?.issuer : undefined;
1364
+ // `local`: this page is the local World's own (localPage), so the console offers no sign-in or sign-out
1365
+ return Response.json({ world: this.served, scope, ...(who ? { who } : {}), ...(issuer ? { issuer } : {}), ...(own ? { origin: own } : {}), ...(this.localPage(request, url) ? { local: true } : {}) }, { headers: { 'set-cookie': cookie } });
1366
+ }
1367
+ /** THE CLOCK: the frozen instant every twin stamps from, kept in the World's store. It moves only
1368
+ * forward once the World has entries (a twin's catch-up stamps each move at its due time, so a
1369
+ * clock set back would put new entries before old ones); the past is reached by branching. */
1370
+ async clockDoor(request, verb) {
1371
+ const path = this.host.clockPath?.();
1372
+ if (!path)
1373
+ return Response.json({ error: 'this host keeps no World clock' }, { status: 404 });
1374
+ const store = getActiveWorldStore();
1375
+ const held = store.exists(path) ? (store.read(path) ?? '').trim() : '';
1376
+ const current = held && !Number.isNaN(Date.parse(held)) ? { at: new Date(Date.parse(held)).toISOString(), frozen: true } : { at: new Date().toISOString(), frozen: false };
1377
+ if (!verb && request.method === 'GET')
1378
+ return Response.json(current);
1379
+ const set = (at) => {
1380
+ // the floor: the later of the frozen instant and the newest entry any twin holds (a push may
1381
+ // land entries past the clock)
1382
+ const newest = this.newestEntry();
1383
+ const frozenAt = current.frozen ? Date.parse(current.at) : null;
1384
+ const floor = frozenAt === null ? newest : newest === null ? frozenAt : Math.max(frozenAt, newest);
1385
+ if (floor !== null && at < floor)
1386
+ 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 });
1387
+ store.writeAtomic(path, `${new Date(at).toISOString()}\n`);
1388
+ return Response.json({ at: new Date(at).toISOString(), frozen: true });
1389
+ };
1390
+ if (!verb && request.method === 'PUT') {
1391
+ const body = (await request.json().catch(() => null));
1392
+ const at = typeof body?.at === 'string' ? Date.parse(body.at) : Number.NaN;
1393
+ if (Number.isNaN(at))
1394
+ return Response.json({ error: 'clock: { at: <ISO-8601 instant> }' }, { status: 400 });
1395
+ return set(at);
1396
+ }
1397
+ if (verb === 'advance' && request.method === 'POST') {
1398
+ const body = (await request.json().catch(() => null));
1399
+ const m = typeof body?.by === 'string' ? SPAN.exec(body.by.trim()) : null;
1400
+ if (!m)
1401
+ return Response.json({ error: 'advance: { by: "<N>(s|m|h|d)" }' }, { status: 400 });
1402
+ if (!current.frozen)
1403
+ 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 });
1404
+ const target = Date.parse(current.at) + Number(m[1]) * SPAN_MS[m[2]];
1405
+ if (!Number.isFinite(target) || target > Date.parse('9999-12-31T23:59:59Z'))
1406
+ return Response.json({ error: 'advance: past the last instant a clock can hold' }, { status: 400 });
1407
+ return set(target);
1408
+ }
1409
+ return Response.json({ error: `no such door: ${request.method} clock${verb ? `/${verb}` : ''}` }, { status: 404 });
1410
+ }
1411
+ /** THE BRANCHES DOORS: list (read), make one as of an instant, remove one (write). The host makes
1412
+ * and removes them; a host that makes none answers 404, and a view offers no "as of". */
1413
+ async branchesDoor(request, url, rest, via) {
1414
+ const branches = this.host.branches;
1415
+ if (!branches)
1416
+ return Response.json({ error: `this host makes no branches of ${this.served}` }, { status: 404 });
1417
+ if (rest.length === 0 && request.method === 'GET')
1418
+ return Response.json({ world: this.served, branches: await branches.list() });
1419
+ if (rest.length === 0 && request.method === 'POST') {
1420
+ const body = (await request.json().catch(() => null));
1421
+ const instant = body?.at?.instant;
1422
+ if (instant !== undefined && (typeof instant !== 'string' || Number.isNaN(Date.parse(instant))))
1423
+ return Response.json({ error: 'branches: { at?: { instant: <ISO-8601> }, ttl?: <seconds>, live?: true, label?: <name> }' }, { status: 400 });
1424
+ if (body?.label !== undefined && (typeof body.label !== 'string' || !/^[a-z0-9][a-z0-9-]{0,23}$/.test(body.label)))
1425
+ return Response.json({ error: 'label: lowercase letters, digits and dashes, at most 24 (it names the branch)' }, { status: 400 });
1426
+ if (body?.live !== undefined && (body.live !== true || instant !== undefined))
1427
+ return Response.json({ error: 'live: true, and only for a branch as of now (a branch at an instant keeps that instant)' }, { status: 400 });
1428
+ let ttl = body?.ttl === undefined || body.ttl === null ? null : Number(body.ttl);
1429
+ if (ttl !== null && (!Number.isInteger(ttl) || ttl < 60 || ttl > 30 * 86_400))
1430
+ return Response.json({ error: 'ttl: whole seconds, 60 to 2592000' }, { status: 400 });
1431
+ // 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
1432
+ // never past its own end (a branch outlives neither the key nor the session that made it)
1433
+ const now = Date.now();
1434
+ const cred = this.credential(request);
1435
+ const maker = via === 'key' && cred.token ? this.keys().find((k) => sameSecret(keyHash(cred.token), k.hash)) : undefined;
1436
+ const opened = via === 'session' && cred.session ? this.sessions()[cred.session] : undefined;
1437
+ if (via !== 'token') {
1438
+ const until = maker ? (maker.expiresAt ? Date.parse(maker.expiresAt) : Infinity) : opened ? opened.until : now;
1439
+ const most = Math.floor(Math.min(7 * 86_400_000, until - now) / 1000);
1440
+ if (ttl === null)
1441
+ 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 });
1442
+ if (most < 60)
1443
+ return Response.json({ error: 'this key or session ends too soon to make a branch' }, { status: 403 });
1444
+ ttl = Math.min(ttl, most);
1445
+ }
1446
+ const person = maker ? this.keyPerson(maker) : opened?.sub;
1447
+ const 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' };
1448
+ // the branch reaches this World with a key of its own, never this World's token: made for the same person, ending
1449
+ // with the branch, so whatever ends the person's keys here (their leaving, a revoke, new tokens) ends its reach
1450
+ const parentKey = mintToken('tok_k_');
1451
+ const keyId = mintToken('key_').slice(0, 20);
1452
+ const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
1453
+ 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 } : {}) }]);
1454
+ try {
1455
+ 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);
1456
+ this.keepKeys(this.keys().map((k) => (k.id === keyId ? { ...k, name: `the branch ${made.name}`, branch: made.name, ...(made.expiresAt ? { expiresAt: made.expiresAt } : {}) } : k)));
1457
+ // a browser's session is answered the branch's read token only: a page views a branch, and the parent's
1458
+ // session removes it, so no write token ever reaches a page's script (or a frame beside it)
1459
+ // `origin`: where the branch's own pages are, when it has an origin of its own (a page moves there to view it)
1460
+ const origin = this.host.originOf?.(made.name) ?? null;
1461
+ if (via === 'session') {
1462
+ const { token: _write, ...viewed } = made;
1463
+ return Response.json({ ...viewed, ...(origin ? { origin } : {}) }, { status: 201 });
1464
+ }
1465
+ return Response.json({ ...made, ...(origin ? { origin } : {}) }, { status: 201 });
1466
+ }
1467
+ catch (error) {
1468
+ this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
1469
+ return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
1470
+ }
1471
+ }
1472
+ if (rest.length === 2 && request.method === 'DELETE') {
1473
+ const name = rest.join('/');
1474
+ if (!(await branches.remove(name)))
1475
+ return Response.json({ error: `${name} is not a branch of ${this.served}` }, { status: 404 });
1476
+ this.keepKeys(this.keys().filter((k) => k.branch !== name)); // its key here goes with it
1477
+ return Response.json({ removed: name });
1478
+ }
1479
+ return Response.json({ error: `no such door: ${request.method} branches${rest.length ? `/${rest.join('/')}` : ''}` }, { status: 404 });
1480
+ }
1481
+ /** The newest entry's instant across the World's twins (ms), or null when it has none. */
1482
+ newestEntry() {
1483
+ let newest = null;
1484
+ for (const vendor of Object.keys(this.up().services)) {
1485
+ for (const e of wholeLog(this.stateOf(vendor), this.controlRoot(vendor))) {
1486
+ const t = Date.parse(e.occurredAt);
1487
+ if (!Number.isNaN(t) && (newest === null || t > newest))
1488
+ newest = t;
1489
+ }
1490
+ }
1491
+ return newest;
1492
+ }
1493
+ /** THE MAP: every twin and what it holds, by resource type, and whether it has a mirror. */
1494
+ async map() {
1495
+ const twins = [];
1496
+ for (const vendor of Object.keys(this.up().services)) {
1497
+ const state = this.stateOf(vendor);
1498
+ const controlRoot = this.controlRoot(vendor);
1499
+ const counts = new Map();
1500
+ for (const r of readTree(state, controlRoot))
1501
+ if (r.deleted !== true)
1502
+ counts.set(r.type, (counts.get(r.type) ?? 0) + 1);
1503
+ twins.push({
1504
+ twin: vendor, position: wholeLog(state, controlRoot).length,
1505
+ mirror: Boolean(await this.host.hasMirror?.(vendor)),
1506
+ root: rootForControlRoot(controlRoot, vendor) ?? null,
1507
+ resources: [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([type, count]) => ({ type, count })),
1508
+ });
1509
+ }
1510
+ return { world: this.served, twins };
1511
+ }
1512
+ /** THE TIMELINE: the entries of every twin's log merged, newest first, by each entry's instant, then
1513
+ * twin, then position. The World keeps no order across twins finer than its clock, and this claims
1514
+ * none. `before` is the cursor the previous page answered; `twin` and `trace` narrow it. */
1515
+ timeline(url) {
1516
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? '100'), 500);
1517
+ if (!Number.isInteger(limit) || limit < 1)
1518
+ return Response.json({ error: 'limit: 1-500' }, { status: 400 });
1519
+ const onlyTwin = url.searchParams.get('twin');
1520
+ const onlyTrace = url.searchParams.get('trace');
1521
+ let before = null;
1522
+ const cursor = url.searchParams.get('before');
1523
+ if (cursor) {
1524
+ try {
1525
+ const [at, twin, position] = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
1526
+ if (typeof at !== 'string' || typeof twin !== 'string' || !Number.isInteger(position))
1527
+ throw new Error('shape');
1528
+ before = { at, twin, position };
1529
+ }
1530
+ catch {
1531
+ return Response.json({ error: 'before: not a cursor this door answered' }, { status: 400 });
1532
+ }
1533
+ }
1534
+ const rows = this.timelineRows().filter((r) => (!onlyTwin || r.twin === onlyTwin) && (!onlyTrace || traceIdOf(r.entry.traceparent) === onlyTrace));
1535
+ const key = (r) => ({ at: r.entry.occurredAt, twin: r.twin, position: r.position });
1536
+ const page = rows.filter((r) => before === null || newerFirst(before, key(r)) < 0).slice(0, limit + 1);
1537
+ const more = page.length > limit;
1538
+ const shown = page.slice(0, limit);
1539
+ const last = shown[shown.length - 1];
1540
+ const next = more && last ? Buffer.from(JSON.stringify([last.entry.occurredAt, last.twin, last.position])).toString('base64url') : null;
1541
+ return Response.json({ world: this.served, entries: shown, next });
1542
+ }
1543
+ /** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
1544
+ * (a twin is read again only when the size or time of its parent log, branch log or branch record
1545
+ * moved), and the merged order is sorted again only when a twin was. */
1546
+ timelineByTwin = new Map();
1547
+ timelineMerged = null;
1548
+ timelineRows() {
1549
+ const store = getActiveWorldStore();
1550
+ const prints = [];
1551
+ for (const vendor of Object.keys(this.up().services)) {
1552
+ const state = this.stateOf(vendor);
1553
+ const controlRoot = this.controlRoot(vendor);
1554
+ const fingerprint = [parentLogPath(state, controlRoot), branchLogPath(state, controlRoot), branchMetaPath(state, controlRoot)]
1555
+ .map((p) => { const s = store.stat(p); return s ? `${s.size}:${s.mtimeMs}` : '-'; }).join('|');
1556
+ prints.push(`${vendor}=${fingerprint}`);
1557
+ if (this.timelineByTwin.get(vendor)?.fingerprint === fingerprint)
1558
+ continue;
1559
+ this.timelineByTwin.set(vendor, { fingerprint, rows: wholeLog(state, controlRoot).map((entry, i) => ({ twin: vendor, position: i + 1, entry })) });
1560
+ }
1561
+ const all = prints.join(',');
1562
+ if (this.timelineMerged?.fingerprint !== all) {
1563
+ const key = (r) => ({ at: r.entry.occurredAt, twin: r.twin, position: r.position });
1564
+ const rows = Object.keys(this.up().services).flatMap((v) => this.timelineByTwin.get(v)?.rows ?? []);
1565
+ this.timelineMerged = { fingerprint: all, rows: rows.sort((a, b) => newerFirst(key(a), key(b))) };
1566
+ }
1567
+ return this.timelineMerged.rows;
1568
+ }
825
1569
  /** PUSH: see `landChangeset` — the door hands the body to it. */
826
1570
  async push(request) {
827
1571
  const body = (await request.json());