@volter/world-runtime 2.0.5 → 2.0.6

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.
@@ -31,7 +31,7 @@ import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
33
33
  import { dirname, join, resolve } from 'node:path';
34
- import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, rebaseBranch, } from '@volter/world-core';
34
+ import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, rebaseBranch, } from '@volter/world-core';
35
35
  import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
36
36
  import { fetchFromOrigin } from "./origin.js";
37
37
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
@@ -415,6 +415,9 @@ export class WorldDoors {
415
415
  for (const k of also)
416
416
  ids.add(k.id);
417
417
  this.keepKeys(this.keys().filter((k) => !ids.has(k.id)));
418
+ // a session a revoked key opened ends with it
419
+ if (Object.values(this.sessions()).some((x) => x.key !== undefined && ids.has(x.key)))
420
+ this.keepSessions(Object.fromEntries(Object.entries(this.sessions()).filter(([, x]) => x.key === undefined || !ids.has(x.key))));
418
421
  // 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
422
  for (const k of [...gone, ...also])
420
423
  if (k.branch)
@@ -472,8 +475,10 @@ export class WorldDoors {
472
475
  const who = new URL(request.url).searchParams.get('person');
473
476
  if (via !== 'token' || !who)
474
477
  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) });
478
+ // their keys, the parent keys of the branches they made, and those branches; and the sessions their passes opened
479
+ const revoked = await this.revokeKeys((k) => person(k) === who);
480
+ this.keepSessions(Object.fromEntries(Object.entries(this.sessions()).filter(([, s]) => s.sub !== who)));
481
+ return Response.json({ revoked });
477
482
  }
478
483
  if (id && request.method === 'DELETE') {
479
484
  const found = this.keys().find((k) => k.id === id);
@@ -1053,21 +1058,29 @@ export class WorldDoors {
1053
1058
  return Response.json({ twin: name, state, view: cut.view, position: cut.position, tree: readTree(state, controlRoot, { view: cut.view }) });
1054
1059
  }
1055
1060
  if (kind === 'changesets' && request.method === 'GET') {
1061
+ // with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
1056
1062
  if (name) {
1057
1063
  const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name });
1058
- return Response.json(l.changeset);
1064
+ return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset) });
1059
1065
  }
1060
- return Response.json({ changesets: listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset) });
1066
+ return Response.json({ changesets: listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => ({ ...l.changeset, currentHash: changesetContentHash(l.changeset) })) });
1061
1067
  }
1062
1068
  if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
1063
1069
  const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
1064
1070
  return Response.json(outcome);
1065
1071
  }
1066
1072
  if (kind === 'changesets' && name && verb === 'approve' && request.method === 'POST') {
1067
- const body = (await request.json());
1068
- if (!body.as)
1069
- return Response.json({ error: 'approve: { as, note? }' }, { status: 400 });
1070
- return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal: body.as, ...(body.note ? { note: body.note } : {}) }));
1073
+ const body = (await request.json().catch(() => ({})));
1074
+ // a browser's session signs as whom it was opened for (a platform's pass names the person; this machine's own page
1075
+ // is the person at it), never as whatever the page says; a token or a key names its principal, as before, and so
1076
+ // does a session the World's write token opened (it is that token's holder, who names themselves)
1077
+ const held = via === 'session' ? this.sessions()[this.credential(request).session ?? ''] : undefined;
1078
+ const named = typeof body.as === 'string' && body.as.trim() ? body.as.trim().slice(0, 80) : undefined;
1079
+ const tokenSession = held !== undefined && !held.who && held.key === undefined && held.scope === 'write';
1080
+ const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : named;
1081
+ if (!principal)
1082
+ return Response.json({ error: via === 'session' && !tokenSession ? 'this session names no person to sign as: open the World from its platform, or approve with its token' : 'approve: { as, note? } — name who approves' }, { status: via === 'session' && !tokenSession ? 403 : 400 });
1083
+ return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal, ...(body.note ? { note: body.note } : {}) }));
1071
1084
  }
1072
1085
  if (kind === 'links' && !name && request.method === 'GET')
1073
1086
  return Response.json({ links: this.links() });
@@ -1361,8 +1374,10 @@ export class WorldDoors {
1361
1374
  const cookie = this.sessionCookie(url, held ?? this.openSession(scope, undefined, key));
1362
1375
  const who = held ? this.sessions()[held]?.who : undefined;
1363
1376
  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 } });
1377
+ // `local`: this page is the local World's own (localPage), so the console offers no sign-in or sign-out;
1378
+ // `signsAs`: whom an approval from this session is recorded as, or null when the page must name them (a token's)
1379
+ const signsAs = who ?? (this.host.localTrust?.() ? 'this machine' : null);
1380
+ return Response.json({ world: this.served, scope, signsAs, ...(who ? { who } : {}), ...(issuer ? { issuer } : {}), ...(own ? { origin: own } : {}), ...(this.localPage(request, url) ? { local: true } : {}) }, { headers: { 'set-cookie': cookie } });
1366
1381
  }
1367
1382
  /** THE CLOCK: the frozen instant every twin stamps from, kept in the World's store. It moves only
1368
1383
  * forward once the World has entries (a twin's catch-up stamps each move at its due time, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -68,7 +68,7 @@
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@volter/world-core": "2.0.4",
71
- "@volter/world-console": "2.0.5"
71
+ "@volter/world-console": "2.0.6"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
@@ -32,7 +32,7 @@ import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type Tr
32
32
  import { createHash } from 'node:crypto';
33
33
  import { dirname, join, resolve } from 'node:path';
34
34
  import {
35
- appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
35
+ appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
36
36
  parentEntries, rebaseBranch, type Changeset, type Entry, type Receipt, type RootConfig,
37
37
  } from '@volter/world-core';
38
38
  import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
@@ -407,6 +407,8 @@ export class WorldDoors {
407
407
  const also = this.keys().filter((k) => !ids.has(k.id) && k.madeWith !== undefined && ids.has(k.madeWith)); // branches a revoked key made
408
408
  for (const k of also) ids.add(k.id);
409
409
  this.keepKeys(this.keys().filter((k) => !ids.has(k.id)));
410
+ // a session a revoked key opened ends with it
411
+ if (Object.values(this.sessions()).some((x) => x.key !== undefined && ids.has(x.key))) this.keepSessions(Object.fromEntries(Object.entries(this.sessions()).filter(([, x]) => x.key === undefined || !ids.has(x.key))));
410
412
  // 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
413
  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
414
  return [...ids];
@@ -447,8 +449,10 @@ export class WorldDoors {
447
449
  // every key made by or for a person: their leaving the org ends what they hold here (the token's act)
448
450
  const who = new URL(request.url).searchParams.get('person');
449
451
  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
+ // their keys, the parent keys of the branches they made, and those branches; and the sessions their passes opened
453
+ const revoked = await this.revokeKeys((k) => person(k) === who);
454
+ this.keepSessions(Object.fromEntries(Object.entries(this.sessions()).filter(([, s]) => s.sub !== who)));
455
+ return Response.json({ revoked });
452
456
  }
453
457
  if (id && request.method === 'DELETE') {
454
458
  const found = this.keys().find((k) => k.id === id);
@@ -931,17 +935,25 @@ export class WorldDoors {
931
935
  return Response.json({ twin: name, state, view: cut.view, position: cut.position, tree: readTree(state, controlRoot, { view: cut.view }) });
932
936
  }
933
937
  if (kind === 'changesets' && request.method === 'GET') {
934
- if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json(l.changeset); }
935
- return Response.json({ changesets: listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset) });
938
+ // with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
939
+ if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset) }); }
940
+ return Response.json({ changesets: listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => ({ ...l.changeset, currentHash: changesetContentHash(l.changeset) })) });
936
941
  }
937
942
  if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
938
943
  const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
939
944
  return Response.json(outcome);
940
945
  }
941
946
  if (kind === 'changesets' && name && verb === 'approve' && request.method === 'POST') {
942
- const body = (await request.json()) as { as?: string; note?: string };
943
- if (!body.as) return Response.json({ error: 'approve: { as, note? }' }, { status: 400 });
944
- return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal: body.as, ...(body.note ? { note: body.note } : {}) }));
947
+ const body = (await request.json().catch(() => ({}))) as { as?: string; note?: string };
948
+ // a browser's session signs as whom it was opened for (a platform's pass names the person; this machine's own page
949
+ // is the person at it), never as whatever the page says; a token or a key names its principal, as before, and so
950
+ // does a session the World's write token opened (it is that token's holder, who names themselves)
951
+ const held = via === 'session' ? this.sessions()[this.credential(request).session ?? ''] : undefined;
952
+ const named = typeof body.as === 'string' && body.as.trim() ? body.as.trim().slice(0, 80) : undefined;
953
+ const tokenSession = held !== undefined && !held.who && held.key === undefined && held.scope === 'write';
954
+ const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : named;
955
+ if (!principal) return Response.json({ error: via === 'session' && !tokenSession ? 'this session names no person to sign as: open the World from its platform, or approve with its token' : 'approve: { as, note? } — name who approves' }, { status: via === 'session' && !tokenSession ? 403 : 400 });
956
+ return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal, ...(body.note ? { note: body.note } : {}) }));
945
957
  }
946
958
  if (kind === 'links' && !name && request.method === 'GET') return Response.json({ links: this.links() });
947
959
  if (kind === 'links' && name && request.method === 'PUT') {
@@ -1165,8 +1177,10 @@ export class WorldDoors {
1165
1177
  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
1178
  const cookie = this.sessionCookie(url, held ?? this.openSession(scope, undefined, key))!;
1167
1179
  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 } });
1180
+ // `local`: this page is the local World's own (localPage), so the console offers no sign-in or sign-out;
1181
+ // `signsAs`: whom an approval from this session is recorded as, or null when the page must name them (a token's)
1182
+ const signsAs = who ?? (this.host.localTrust?.() ? 'this machine' : null);
1183
+ return Response.json({ world: this.served, scope, signsAs, ...(who ? { who } : {}), ...(issuer ? { issuer } : {}), ...(own ? { origin: own } : {}), ...(this.localPage(request, url) ? { local: true } : {}) }, { headers: { 'set-cookie': cookie } });
1170
1184
  }
1171
1185
 
1172
1186
  /** THE CLOCK: the frozen instant every twin stamps from, kept in the World's store. It moves only