@volter/world-runtime 2.0.5 → 2.0.7

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.
@@ -73,7 +73,13 @@
73
73
  // ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
74
74
  // backend session, so session state leaks between them and outlives the
75
75
  // connection that set it: SET (search_path, timezone…), temp tables and
76
- // PREPAREd statements are visible to every other connection; session
76
+ // SQL PREPAREd statements are visible to every other connection (a
77
+ // statement prepared over the protocol is the connection's own: a name
78
+ // another live connection holds is renamed for it, and its own SQL
79
+ // EXECUTE/DEALLOCATE of that name is renamed with it; scopeNames — except
80
+ // in CREATE TABLE … AS EXECUTE, a comment between the verb and the name,
81
+ // a Query holding a backslash, and a DEALLOCATE sent through Parse, which
82
+ // reach the name as the backend holds it); session
77
83
  // advisory locks do NOT exclude (a second connection's
78
84
  // pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
79
85
  // to the connection that sent it, not to the one that ran LISTEN.
@@ -236,6 +242,220 @@ function readOnlySql(text) {
236
242
  const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
237
243
  const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
238
244
 
245
+ /** Prepared statements' protocol names each connection gives, kept apart where they would collide.
246
+ * Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
247
+ * starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
248
+ * walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
249
+ * live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
250
+ * live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
251
+ * DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
252
+ * (NAMEDATALEN), and handled as bytes, never decoded. A closed connection's statements are closed on the next turn
253
+ * that begins outside any transaction (flushClosed). */
254
+ const NAMEDATALEN = 63;
255
+ /** backend statement name (latin1 of its bytes, first 63) -> the session holding it */
256
+ const heldStatements = new Map();
257
+ /** backend statement names of closed connections, closed at the next clean turn */
258
+ const closedStatements = [];
259
+ let renamed = 0;
260
+ /** A name no statement has: what another connection's statement name is, to a connection that never prepared it. */
261
+ const UNPREPARED = Buffer.from('volter_unprepared', 'latin1');
262
+ const nameKey = (bytes) => bytes.subarray(0, NAMEDATALEN).toString('latin1');
263
+
264
+ /** The name at `from` (its bytes, to the NUL) and where the field after it starts. */
265
+ function cstring(data, from) {
266
+ const end = data.indexOf(0, from);
267
+ return { bytes: data.subarray(from, end), next: end + 1 };
268
+ }
269
+
270
+ /** The backend's name for a statement this connection names `bytes`; `claim` (a Parse on the connection's turn) takes
271
+ * one for a new statement, anything else only looks its name up. */
272
+ function statementName(session, bytes, claim) {
273
+ if (bytes.length === 0) return bytes;
274
+ const own = session.names.get(bytes.toString('latin1'));
275
+ if (own) return own;
276
+ if (!claim) {
277
+ // a statement it never prepared: the backend answers as Postgres does, never with another live connection's
278
+ // statement of that name (it would not exist in this connection's own session)
279
+ const holder = heldStatements.get(nameKey(bytes));
280
+ return holder && holder !== session && !holder.closed ? UNPREPARED : bytes;
281
+ }
282
+ const key = nameKey(bytes);
283
+ const holder = heldStatements.get(key);
284
+ const backend = holder && holder !== session ? Buffer.from(`volter_s${(renamed += 1)}`, 'latin1') : bytes;
285
+ heldStatements.set(nameKey(backend), session);
286
+ session.names.set(bytes.toString('latin1'), backend);
287
+ return backend;
288
+ }
289
+
290
+ /** The message with its statement names as the backend knows them. Portals keep the client's names: a named portal
291
+ * lives only inside its transaction, and one transaction runs at a time. */
292
+ function scopeNames(session, message, claim) {
293
+ const data = asBuffer(message);
294
+ const kind = data[0];
295
+ let parts;
296
+ if (kind === 0x50 /* P */) {
297
+ const name = cstring(data, 5);
298
+ parts = [statementName(session, name.bytes, claim), Buffer.from([0]), data.subarray(name.next)];
299
+ } else if (kind === 0x42 /* B */) {
300
+ const portal = cstring(data, 5);
301
+ const statement = cstring(data, portal.next);
302
+ parts = [data.subarray(5, portal.next), statementName(session, statement.bytes, false), Buffer.from([0]), data.subarray(statement.next)];
303
+ } else if ((kind === 0x44 /* D */ || kind === 0x43 /* C */) && data[5] === 0x53 /* S */) {
304
+ const name = cstring(data, 6);
305
+ const backend = statementName(session, name.bytes, false);
306
+ parts = [data.subarray(5, 6), backend, Buffer.from([0]), data.subarray(name.next)];
307
+ } else if (kind === 0x51 /* Q */ && claim) {
308
+ return sqlNames(session, message);
309
+ } else {
310
+ return message;
311
+ }
312
+ const body = Buffer.concat(parts);
313
+ if (body.length === data.length - 5 && body.equals(data.subarray(5))) return message;
314
+ const out = Buffer.alloc(5 + body.length);
315
+ out[0] = kind;
316
+ out.writeInt32BE(4 + body.length, 1);
317
+ body.copy(out, 5);
318
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
319
+ }
320
+
321
+ /** A statement of a simple Query that DEALLOCATEs or EXECUTEs one statement, at the statement's start: the verb
322
+ * (EXPLAIN's options before EXECUTE included) and the name, quoted or not. */
323
+ const STATEMENT_NAMED = /^((?:EXPLAIN\s+(?:\([^)]*\)\s*)?(?:ANALYZE\s+)?(?:VERBOSE\s+)?)?(?:DEALLOCATE\s+(?:PREPARE\s+)?|EXECUTE\s+))(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))(?=[\s(;]|$)/i;
324
+ /** a quoted name as it is; an unquoted one folds to lower case, as Postgres folds it */
325
+ const folded = (quoted, name) => (quoted !== undefined ? quoted.replace(/""/g, '"') : name.toLowerCase());
326
+ const EMPTIES_ALL = /^(?:DEALLOCATE\s+(?:PREPARE\s+)?ALL|DISCARD\s+ALL)\s*;?\s*$/i;
327
+
328
+ /** Where each statement of a Query begins, outside string literals, quoted identifiers, dollar-quoted bodies and
329
+ * comments (a statement's leading comments skipped), so a rewrite never touches a literal's text. */
330
+ function statementStarts(sql) {
331
+ const starts = [];
332
+ let atStart = true;
333
+ for (let i = 0; i < sql.length;) {
334
+ const c = sql[i];
335
+ if (/\s/.test(c)) { i += 1; continue; }
336
+ if (c === '-' && sql[i + 1] === '-') { const end = sql.indexOf('\n', i); i = end < 0 ? sql.length : end + 1; continue; }
337
+ if (c === '/' && sql[i + 1] === '*') {
338
+ let depth = 1; let j = i + 2; // Postgres's block comments nest
339
+ while (j < sql.length && depth > 0) { if (sql.startsWith('/*', j)) { depth += 1; j += 2; } else if (sql.startsWith('*/', j)) { depth -= 1; j += 2; } else j += 1; }
340
+ i = j; continue;
341
+ }
342
+ if (atStart) { starts.push(i); atStart = false; }
343
+ if (c === ';') { atStart = true; i += 1; continue; }
344
+ if (c === "'" || c === '"') {
345
+ // E'…' takes backslash escapes; a plain '…' and "…" end at their quote, a doubled one inside
346
+ const escapes = c === "'" && /[Ee]/.test(sql[i - 1] ?? '') && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? '');
347
+ let j = i + 1;
348
+ while (j < sql.length) {
349
+ if (escapes && sql[j] === '\\') { j += 2; continue; }
350
+ if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
351
+ j += 1;
352
+ }
353
+ i = j + 1; continue;
354
+ }
355
+ // a `$` inside an identifier (`a$b$c`) opens nothing, as Postgres's lexer reads it
356
+ const dollar = c === '$' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '') ? /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) : null;
357
+ if (dollar) { const end = sql.indexOf(dollar[0], i + dollar[0].length); i = end < 0 ? sql.length : end + dollar[0].length; continue; }
358
+ i += 1;
359
+ }
360
+ return starts;
361
+ }
362
+
363
+ /** Each statement of the Query that DEALLOCATEs or EXECUTEs one name: where it is, its verb and the name. */
364
+ function namedStatements(sql) {
365
+ const out = [];
366
+ for (const at of statementStarts(sql)) {
367
+ const m = STATEMENT_NAMED.exec(sql.slice(at));
368
+ if (m) out.push({ at, length: m[0].length, verb: m[1], name: folded(m[2], m[3]) });
369
+ }
370
+ return out;
371
+ }
372
+
373
+ /** A simple Query's DEALLOCATE or EXECUTE of a statement this connection prepared under a name of the host's own
374
+ * (another connection held the client's), or of a name another live connection holds: the statement it means, never
375
+ * the other connection's. Only statements' own text is rewritten (statementStarts); CREATE TABLE … AS EXECUTE and
376
+ * a DEALLOCATE sent through Parse are not. */
377
+ function sqlNames(session, message) {
378
+ const data = asBuffer(message);
379
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
380
+ // a backslash can end a plain '…' literal (standard_conforming_strings off, which one connection can set for the
381
+ // shared session): such a Query is not read, as statementSkeleton does not read one
382
+ if (sql.includes('\\')) return message;
383
+ let text = sql;
384
+ for (const n of namedStatements(sql).reverse()) {
385
+ const backend = statementName(session, Buffer.from(n.name, 'latin1'), false).toString('latin1');
386
+ if (backend !== n.name) text = `${text.slice(0, n.at)}${n.verb}"${backend}"${text.slice(n.at + n.length)}`;
387
+ }
388
+ if (text === sql) return message;
389
+ const body = Buffer.concat([Buffer.from(text, 'latin1'), Buffer.from([0])]);
390
+ const out = Buffer.alloc(5 + body.length);
391
+ out[0] = 0x51;
392
+ out.writeInt32BE(4 + body.length, 1);
393
+ body.copy(out, 5);
394
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
395
+ }
396
+
397
+ /** What a client message closes: a statement (Close 'S', or a Query that is one DEALLOCATE), by the client's name,
398
+ * or ALL (a Query that is one DEALLOCATE ALL or DISCARD ALL, emptying the one session's statements, every
399
+ * connection's, as they did before scoping). */
400
+ const ALL = Symbol('all');
401
+ function closing(message) {
402
+ const data = asBuffer(message);
403
+ if (data[0] === 0x43 && data[5] === 0x53) { const name = cstring(data, 6).bytes; return name.length ? name : undefined; }
404
+ if (data[0] === 0x51) {
405
+ // one statement alone in its Query (one inside a longer Query settles when the connection closes)
406
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
407
+ const starts = statementStarts(sql);
408
+ if (starts.length !== 1 && !(starts.length === 2 && /^;\s*$/.test(sql.slice(starts[1])))) return undefined;
409
+ if (EMPTIES_ALL.test(sql.slice(starts[0]))) return ALL;
410
+ const named = namedStatements(sql);
411
+ if (named.length === 1 && /^DEALLOCATE/i.test(named[0].verb)) return Buffer.from(named[0].name, 'latin1');
412
+ }
413
+ return undefined;
414
+ }
415
+
416
+ /** Free what a message closed once the backend has answered it: CloseComplete ('3') or the DEALLOCATE's
417
+ * CommandComplete ('C') first; a Close Postgres skipped (an earlier error in its batch) frees nothing. */
418
+ function settleClose(session, name, response) {
419
+ if (!name || !response || !response.length || (response[0] !== 0x33 && response[0] !== 0x43)) return;
420
+ if (name === ALL) {
421
+ heldStatements.clear();
422
+ for (const other of sessions.values()) other.names.clear();
423
+ } else forget(session, name);
424
+ }
425
+
426
+ /** A statement the connection closed (Close 'S'): its backend name is free again. */
427
+ function forget(session, bytes) {
428
+ const backend = session.names.get(bytes.toString('latin1'));
429
+ if (!backend) return;
430
+ session.names.delete(bytes.toString('latin1'));
431
+ if (heldStatements.get(nameKey(backend)) === session) heldStatements.delete(nameKey(backend));
432
+ }
433
+
434
+ /** A closed connection's statements, to close on the next clean turn (a leak otherwise: each connection's statements
435
+ * outlived it in the one session, 300 short connections x 20 statements aborted the backend). */
436
+ function releaseStatements(session) {
437
+ for (const backend of session.names.values()) if (heldStatements.get(nameKey(backend)) === session) closedStatements.push(backend);
438
+ session.names.clear();
439
+ }
440
+
441
+ /** Close closed connections' statements with the protocol's Close (a SQL DEALLOCATE would replace the unnamed
442
+ * statement a batch may still Bind), at the start of a turn outside any transaction. */
443
+ async function flushClosed(db) {
444
+ while (closedStatements.length) {
445
+ const backend = closedStatements.shift();
446
+ const body = Buffer.concat([Buffer.from('S', 'latin1'), backend, Buffer.from([0])]);
447
+ const close = Buffer.alloc(5 + body.length);
448
+ close[0] = 0x43;
449
+ close.writeInt32BE(4 + body.length, 1);
450
+ body.copy(close, 5);
451
+ try {
452
+ await db.execProtocolRaw(new Uint8Array(close));
453
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
454
+ } catch { /* the backend's own failure surfaces on the next real message */ }
455
+ if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
456
+ }
457
+ }
458
+
239
459
  /** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
240
460
  * PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
241
461
  function noteStatement(session, message) {
@@ -508,11 +728,13 @@ const server = net.createServer(async (socket) => {
508
728
  const token = {};
509
729
  let holding = false;
510
730
  const session = { socket, busy: false, idleSince: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
511
- wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map() };
731
+ wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
732
+ names: new Map() };
512
733
  sessions.set(token, session);
513
734
  socket.on('close', () => {
514
735
  session.closed = true;
515
736
  sessions.delete(token);
737
+ releaseStatements(session);
516
738
  if (guestBatch === token) {
517
739
  // a guest gone mid-batch: undo what it did in the holder's transaction
518
740
  void dbReady
@@ -537,6 +759,11 @@ const server = net.createServer(async (socket) => {
537
759
  auth: { method: 'trust' },
538
760
  async onMessage(data, { isAuthenticated, clientParams }) {
539
761
  if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
762
+ const client = data;
763
+ const closes = closing(client);
764
+ // looked up, not claimed, while the turn is decided: a Parse claims its name on the connection's own turn,
765
+ // after the closed connections' names are released (a reconnect's `s0` is its own again)
766
+ data = scopeNames(session, client, false);
540
767
  const db = await dbReady;
541
768
  // a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
542
769
  if (session.guestExpired) {
@@ -576,6 +803,7 @@ const server = net.createServer(async (socket) => {
576
803
  return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
577
804
  }
578
805
  if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
806
+ data = scopeNames(session, client, true);
579
807
  noteStatement(session, data);
580
808
  if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
581
809
  let raw;
@@ -587,6 +815,7 @@ const server = net.createServer(async (socket) => {
587
815
  process.exit(1);
588
816
  }
589
817
  const response = withoutEarlyReady(data, raw);
818
+ settleClose(session, closes, response);
590
819
  const status = lastReadyStatus(response);
591
820
  if (status === null) { session.guestRunning = false; return response; } // mid-batch: the guest keeps its turn until its Sync
592
821
  // the savepoint is gone if the holder's transaction ended meanwhile (its socket closed): quiet, not fatal
@@ -605,6 +834,9 @@ const server = net.createServer(async (socket) => {
605
834
  }
606
835
  holding = true;
607
836
  session.busy = true;
837
+ // a turn that begins outside any transaction first closes what closed connections left
838
+ if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(await dbReady);
839
+ data = scopeNames(session, client, true);
608
840
  noteStatement(session, data);
609
841
  // libpq's default: no database parameter means the user's name
610
842
  const own = clientParams?.database || clientParams?.user;
@@ -621,6 +853,7 @@ const server = net.createServer(async (socket) => {
621
853
  session.busy = false;
622
854
  session.idleSince = Date.now();
623
855
  const response = withoutEarlyReady(data, raw);
856
+ settleClose(session, closes, response);
624
857
  const status = lastReadyStatus(response);
625
858
  session.midBatch = status === null;
626
859
  if (status !== null) session.status = status;
@@ -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.7",
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",
@@ -73,7 +73,13 @@
73
73
  // ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
74
74
  // backend session, so session state leaks between them and outlives the
75
75
  // connection that set it: SET (search_path, timezone…), temp tables and
76
- // PREPAREd statements are visible to every other connection; session
76
+ // SQL PREPAREd statements are visible to every other connection (a
77
+ // statement prepared over the protocol is the connection's own: a name
78
+ // another live connection holds is renamed for it, and its own SQL
79
+ // EXECUTE/DEALLOCATE of that name is renamed with it; scopeNames — except
80
+ // in CREATE TABLE … AS EXECUTE, a comment between the verb and the name,
81
+ // a Query holding a backslash, and a DEALLOCATE sent through Parse, which
82
+ // reach the name as the backend holds it); session
77
83
  // advisory locks do NOT exclude (a second connection's
78
84
  // pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
79
85
  // to the connection that sent it, not to the one that ran LISTEN.
@@ -236,6 +242,220 @@ function readOnlySql(text) {
236
242
  const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
237
243
  const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
238
244
 
245
+ /** Prepared statements' protocol names each connection gives, kept apart where they would collide.
246
+ * Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
247
+ * starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
248
+ * walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
249
+ * live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
250
+ * live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
251
+ * DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
252
+ * (NAMEDATALEN), and handled as bytes, never decoded. A closed connection's statements are closed on the next turn
253
+ * that begins outside any transaction (flushClosed). */
254
+ const NAMEDATALEN = 63;
255
+ /** backend statement name (latin1 of its bytes, first 63) -> the session holding it */
256
+ const heldStatements = new Map();
257
+ /** backend statement names of closed connections, closed at the next clean turn */
258
+ const closedStatements = [];
259
+ let renamed = 0;
260
+ /** A name no statement has: what another connection's statement name is, to a connection that never prepared it. */
261
+ const UNPREPARED = Buffer.from('volter_unprepared', 'latin1');
262
+ const nameKey = (bytes) => bytes.subarray(0, NAMEDATALEN).toString('latin1');
263
+
264
+ /** The name at `from` (its bytes, to the NUL) and where the field after it starts. */
265
+ function cstring(data, from) {
266
+ const end = data.indexOf(0, from);
267
+ return { bytes: data.subarray(from, end), next: end + 1 };
268
+ }
269
+
270
+ /** The backend's name for a statement this connection names `bytes`; `claim` (a Parse on the connection's turn) takes
271
+ * one for a new statement, anything else only looks its name up. */
272
+ function statementName(session, bytes, claim) {
273
+ if (bytes.length === 0) return bytes;
274
+ const own = session.names.get(bytes.toString('latin1'));
275
+ if (own) return own;
276
+ if (!claim) {
277
+ // a statement it never prepared: the backend answers as Postgres does, never with another live connection's
278
+ // statement of that name (it would not exist in this connection's own session)
279
+ const holder = heldStatements.get(nameKey(bytes));
280
+ return holder && holder !== session && !holder.closed ? UNPREPARED : bytes;
281
+ }
282
+ const key = nameKey(bytes);
283
+ const holder = heldStatements.get(key);
284
+ const backend = holder && holder !== session ? Buffer.from(`volter_s${(renamed += 1)}`, 'latin1') : bytes;
285
+ heldStatements.set(nameKey(backend), session);
286
+ session.names.set(bytes.toString('latin1'), backend);
287
+ return backend;
288
+ }
289
+
290
+ /** The message with its statement names as the backend knows them. Portals keep the client's names: a named portal
291
+ * lives only inside its transaction, and one transaction runs at a time. */
292
+ function scopeNames(session, message, claim) {
293
+ const data = asBuffer(message);
294
+ const kind = data[0];
295
+ let parts;
296
+ if (kind === 0x50 /* P */) {
297
+ const name = cstring(data, 5);
298
+ parts = [statementName(session, name.bytes, claim), Buffer.from([0]), data.subarray(name.next)];
299
+ } else if (kind === 0x42 /* B */) {
300
+ const portal = cstring(data, 5);
301
+ const statement = cstring(data, portal.next);
302
+ parts = [data.subarray(5, portal.next), statementName(session, statement.bytes, false), Buffer.from([0]), data.subarray(statement.next)];
303
+ } else if ((kind === 0x44 /* D */ || kind === 0x43 /* C */) && data[5] === 0x53 /* S */) {
304
+ const name = cstring(data, 6);
305
+ const backend = statementName(session, name.bytes, false);
306
+ parts = [data.subarray(5, 6), backend, Buffer.from([0]), data.subarray(name.next)];
307
+ } else if (kind === 0x51 /* Q */ && claim) {
308
+ return sqlNames(session, message);
309
+ } else {
310
+ return message;
311
+ }
312
+ const body = Buffer.concat(parts);
313
+ if (body.length === data.length - 5 && body.equals(data.subarray(5))) return message;
314
+ const out = Buffer.alloc(5 + body.length);
315
+ out[0] = kind;
316
+ out.writeInt32BE(4 + body.length, 1);
317
+ body.copy(out, 5);
318
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
319
+ }
320
+
321
+ /** A statement of a simple Query that DEALLOCATEs or EXECUTEs one statement, at the statement's start: the verb
322
+ * (EXPLAIN's options before EXECUTE included) and the name, quoted or not. */
323
+ const STATEMENT_NAMED = /^((?:EXPLAIN\s+(?:\([^)]*\)\s*)?(?:ANALYZE\s+)?(?:VERBOSE\s+)?)?(?:DEALLOCATE\s+(?:PREPARE\s+)?|EXECUTE\s+))(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))(?=[\s(;]|$)/i;
324
+ /** a quoted name as it is; an unquoted one folds to lower case, as Postgres folds it */
325
+ const folded = (quoted, name) => (quoted !== undefined ? quoted.replace(/""/g, '"') : name.toLowerCase());
326
+ const EMPTIES_ALL = /^(?:DEALLOCATE\s+(?:PREPARE\s+)?ALL|DISCARD\s+ALL)\s*;?\s*$/i;
327
+
328
+ /** Where each statement of a Query begins, outside string literals, quoted identifiers, dollar-quoted bodies and
329
+ * comments (a statement's leading comments skipped), so a rewrite never touches a literal's text. */
330
+ function statementStarts(sql) {
331
+ const starts = [];
332
+ let atStart = true;
333
+ for (let i = 0; i < sql.length;) {
334
+ const c = sql[i];
335
+ if (/\s/.test(c)) { i += 1; continue; }
336
+ if (c === '-' && sql[i + 1] === '-') { const end = sql.indexOf('\n', i); i = end < 0 ? sql.length : end + 1; continue; }
337
+ if (c === '/' && sql[i + 1] === '*') {
338
+ let depth = 1; let j = i + 2; // Postgres's block comments nest
339
+ while (j < sql.length && depth > 0) { if (sql.startsWith('/*', j)) { depth += 1; j += 2; } else if (sql.startsWith('*/', j)) { depth -= 1; j += 2; } else j += 1; }
340
+ i = j; continue;
341
+ }
342
+ if (atStart) { starts.push(i); atStart = false; }
343
+ if (c === ';') { atStart = true; i += 1; continue; }
344
+ if (c === "'" || c === '"') {
345
+ // E'…' takes backslash escapes; a plain '…' and "…" end at their quote, a doubled one inside
346
+ const escapes = c === "'" && /[Ee]/.test(sql[i - 1] ?? '') && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? '');
347
+ let j = i + 1;
348
+ while (j < sql.length) {
349
+ if (escapes && sql[j] === '\\') { j += 2; continue; }
350
+ if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
351
+ j += 1;
352
+ }
353
+ i = j + 1; continue;
354
+ }
355
+ // a `$` inside an identifier (`a$b$c`) opens nothing, as Postgres's lexer reads it
356
+ const dollar = c === '$' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '') ? /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) : null;
357
+ if (dollar) { const end = sql.indexOf(dollar[0], i + dollar[0].length); i = end < 0 ? sql.length : end + dollar[0].length; continue; }
358
+ i += 1;
359
+ }
360
+ return starts;
361
+ }
362
+
363
+ /** Each statement of the Query that DEALLOCATEs or EXECUTEs one name: where it is, its verb and the name. */
364
+ function namedStatements(sql) {
365
+ const out = [];
366
+ for (const at of statementStarts(sql)) {
367
+ const m = STATEMENT_NAMED.exec(sql.slice(at));
368
+ if (m) out.push({ at, length: m[0].length, verb: m[1], name: folded(m[2], m[3]) });
369
+ }
370
+ return out;
371
+ }
372
+
373
+ /** A simple Query's DEALLOCATE or EXECUTE of a statement this connection prepared under a name of the host's own
374
+ * (another connection held the client's), or of a name another live connection holds: the statement it means, never
375
+ * the other connection's. Only statements' own text is rewritten (statementStarts); CREATE TABLE … AS EXECUTE and
376
+ * a DEALLOCATE sent through Parse are not. */
377
+ function sqlNames(session, message) {
378
+ const data = asBuffer(message);
379
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
380
+ // a backslash can end a plain '…' literal (standard_conforming_strings off, which one connection can set for the
381
+ // shared session): such a Query is not read, as statementSkeleton does not read one
382
+ if (sql.includes('\\')) return message;
383
+ let text = sql;
384
+ for (const n of namedStatements(sql).reverse()) {
385
+ const backend = statementName(session, Buffer.from(n.name, 'latin1'), false).toString('latin1');
386
+ if (backend !== n.name) text = `${text.slice(0, n.at)}${n.verb}"${backend}"${text.slice(n.at + n.length)}`;
387
+ }
388
+ if (text === sql) return message;
389
+ const body = Buffer.concat([Buffer.from(text, 'latin1'), Buffer.from([0])]);
390
+ const out = Buffer.alloc(5 + body.length);
391
+ out[0] = 0x51;
392
+ out.writeInt32BE(4 + body.length, 1);
393
+ body.copy(out, 5);
394
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
395
+ }
396
+
397
+ /** What a client message closes: a statement (Close 'S', or a Query that is one DEALLOCATE), by the client's name,
398
+ * or ALL (a Query that is one DEALLOCATE ALL or DISCARD ALL, emptying the one session's statements, every
399
+ * connection's, as they did before scoping). */
400
+ const ALL = Symbol('all');
401
+ function closing(message) {
402
+ const data = asBuffer(message);
403
+ if (data[0] === 0x43 && data[5] === 0x53) { const name = cstring(data, 6).bytes; return name.length ? name : undefined; }
404
+ if (data[0] === 0x51) {
405
+ // one statement alone in its Query (one inside a longer Query settles when the connection closes)
406
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
407
+ const starts = statementStarts(sql);
408
+ if (starts.length !== 1 && !(starts.length === 2 && /^;\s*$/.test(sql.slice(starts[1])))) return undefined;
409
+ if (EMPTIES_ALL.test(sql.slice(starts[0]))) return ALL;
410
+ const named = namedStatements(sql);
411
+ if (named.length === 1 && /^DEALLOCATE/i.test(named[0].verb)) return Buffer.from(named[0].name, 'latin1');
412
+ }
413
+ return undefined;
414
+ }
415
+
416
+ /** Free what a message closed once the backend has answered it: CloseComplete ('3') or the DEALLOCATE's
417
+ * CommandComplete ('C') first; a Close Postgres skipped (an earlier error in its batch) frees nothing. */
418
+ function settleClose(session, name, response) {
419
+ if (!name || !response || !response.length || (response[0] !== 0x33 && response[0] !== 0x43)) return;
420
+ if (name === ALL) {
421
+ heldStatements.clear();
422
+ for (const other of sessions.values()) other.names.clear();
423
+ } else forget(session, name);
424
+ }
425
+
426
+ /** A statement the connection closed (Close 'S'): its backend name is free again. */
427
+ function forget(session, bytes) {
428
+ const backend = session.names.get(bytes.toString('latin1'));
429
+ if (!backend) return;
430
+ session.names.delete(bytes.toString('latin1'));
431
+ if (heldStatements.get(nameKey(backend)) === session) heldStatements.delete(nameKey(backend));
432
+ }
433
+
434
+ /** A closed connection's statements, to close on the next clean turn (a leak otherwise: each connection's statements
435
+ * outlived it in the one session, 300 short connections x 20 statements aborted the backend). */
436
+ function releaseStatements(session) {
437
+ for (const backend of session.names.values()) if (heldStatements.get(nameKey(backend)) === session) closedStatements.push(backend);
438
+ session.names.clear();
439
+ }
440
+
441
+ /** Close closed connections' statements with the protocol's Close (a SQL DEALLOCATE would replace the unnamed
442
+ * statement a batch may still Bind), at the start of a turn outside any transaction. */
443
+ async function flushClosed(db) {
444
+ while (closedStatements.length) {
445
+ const backend = closedStatements.shift();
446
+ const body = Buffer.concat([Buffer.from('S', 'latin1'), backend, Buffer.from([0])]);
447
+ const close = Buffer.alloc(5 + body.length);
448
+ close[0] = 0x43;
449
+ close.writeInt32BE(4 + body.length, 1);
450
+ body.copy(close, 5);
451
+ try {
452
+ await db.execProtocolRaw(new Uint8Array(close));
453
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
454
+ } catch { /* the backend's own failure surfaces on the next real message */ }
455
+ if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
456
+ }
457
+ }
458
+
239
459
  /** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
240
460
  * PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
241
461
  function noteStatement(session, message) {
@@ -508,11 +728,13 @@ const server = net.createServer(async (socket) => {
508
728
  const token = {};
509
729
  let holding = false;
510
730
  const session = { socket, busy: false, idleSince: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
511
- wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map() };
731
+ wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
732
+ names: new Map() };
512
733
  sessions.set(token, session);
513
734
  socket.on('close', () => {
514
735
  session.closed = true;
515
736
  sessions.delete(token);
737
+ releaseStatements(session);
516
738
  if (guestBatch === token) {
517
739
  // a guest gone mid-batch: undo what it did in the holder's transaction
518
740
  void dbReady
@@ -537,6 +759,11 @@ const server = net.createServer(async (socket) => {
537
759
  auth: { method: 'trust' },
538
760
  async onMessage(data, { isAuthenticated, clientParams }) {
539
761
  if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
762
+ const client = data;
763
+ const closes = closing(client);
764
+ // looked up, not claimed, while the turn is decided: a Parse claims its name on the connection's own turn,
765
+ // after the closed connections' names are released (a reconnect's `s0` is its own again)
766
+ data = scopeNames(session, client, false);
540
767
  const db = await dbReady;
541
768
  // a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
542
769
  if (session.guestExpired) {
@@ -576,6 +803,7 @@ const server = net.createServer(async (socket) => {
576
803
  return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
577
804
  }
578
805
  if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
806
+ data = scopeNames(session, client, true);
579
807
  noteStatement(session, data);
580
808
  if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
581
809
  let raw;
@@ -587,6 +815,7 @@ const server = net.createServer(async (socket) => {
587
815
  process.exit(1);
588
816
  }
589
817
  const response = withoutEarlyReady(data, raw);
818
+ settleClose(session, closes, response);
590
819
  const status = lastReadyStatus(response);
591
820
  if (status === null) { session.guestRunning = false; return response; } // mid-batch: the guest keeps its turn until its Sync
592
821
  // the savepoint is gone if the holder's transaction ended meanwhile (its socket closed): quiet, not fatal
@@ -605,6 +834,9 @@ const server = net.createServer(async (socket) => {
605
834
  }
606
835
  holding = true;
607
836
  session.busy = true;
837
+ // a turn that begins outside any transaction first closes what closed connections left
838
+ if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(await dbReady);
839
+ data = scopeNames(session, client, true);
608
840
  noteStatement(session, data);
609
841
  // libpq's default: no database parameter means the user's name
610
842
  const own = clientParams?.database || clientParams?.user;
@@ -621,6 +853,7 @@ const server = net.createServer(async (socket) => {
621
853
  session.busy = false;
622
854
  session.idleSince = Date.now();
623
855
  const response = withoutEarlyReady(data, raw);
856
+ settleClose(session, closes, response);
624
857
  const status = lastReadyStatus(response);
625
858
  session.midBatch = status === null;
626
859
  if (status !== null) session.status = status;
@@ -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