@volter/world-runtime 2.0.45 → 2.0.47

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.
@@ -39,7 +39,7 @@ export { fetchFromOrigin, worldOrigin } from './origin.js';
39
39
  export type { FetchOptions, FetchOutcome } from './origin.js';
40
40
  export { branchWorld, checkoutWorld } from './branch.js';
41
41
  export type { BranchOptions } from './branch.js';
42
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.js';
42
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCredentials, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.js';
43
43
  export type { DeployTwinOutcome, MaterializedRoot } from './root.js';
44
44
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.js';
45
45
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.js';
package/dist/src/index.js CHANGED
@@ -29,7 +29,7 @@ export { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttac
29
29
  export { resetWorld, seedEntryFor, seedWorld } from "./placeholder.js";
30
30
  export { fetchFromOrigin, worldOrigin } from "./origin.js";
31
31
  export { branchWorld, checkoutWorld } from "./branch.js";
32
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
32
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCredentials, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
33
33
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from "./served-world.js";
34
34
  export { LocalBranches } from "./local-branches.js";
35
35
  export { serveWorldView, VIEW_CONSOLE_BASE } from "./world-view.js";
@@ -5,6 +5,16 @@ import type { WorldConfig } from './schema.js';
5
5
  * seals is wrapped under. Made on first use, 0600, never printed. */
6
6
  export { userKek, userKekPath } from '@volter/world-core';
7
7
  export declare function credentialPath(worldRoot: string, vendor: string): string;
8
+ /** Rewrap every credential sealed beside the World (each vendor's and each link's) under the newest version of the key
9
+ * that wrapped it: an operator's act after rotating the key, so no record still needs an old version. `current` is only
10
+ * what the sealer vouched for; a record it cannot (sealed under a host key, or by another vault key, a version it
11
+ * could not read), one changed while it was rewrapped (sealed again meanwhile, never overwritten) or one that does not
12
+ * read is `left`, by name. */
13
+ export declare function rewrapTwinCredentials(worldRoot: string): Promise<{
14
+ rewrapped: string[];
15
+ current: string[];
16
+ left: string[];
17
+ }>;
8
18
  /** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
9
19
  * for that destination only, so pointing the root or link elsewhere never sends it there. */
10
20
  export declare function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at: string | undefined, bound: string): Promise<{
package/dist/src/root.js CHANGED
@@ -30,6 +30,53 @@ export { userKek, userKekPath } from '@volter/world-core';
30
30
  export function credentialPath(worldRoot, vendor) {
31
31
  return join(resolve(worldRoot), stateDirName(), 'credentials', `${vendor}.json`);
32
32
  }
33
+ /** Rewrap every credential sealed beside the World (each vendor's and each link's) under the newest version of the key
34
+ * that wrapped it: an operator's act after rotating the key, so no record still needs an old version. `current` is only
35
+ * what the sealer vouched for; a record it cannot (sealed under a host key, or by another vault key, a version it
36
+ * could not read), one changed while it was rewrapped (sealed again meanwhile, never overwritten) or one that does not
37
+ * read is `left`, by name. */
38
+ export async function rewrapTwinCredentials(worldRoot) {
39
+ const store = getActiveWorldStore();
40
+ const sealer = currentSealer();
41
+ const dir = dirname(credentialPath(worldRoot, 'x'));
42
+ const names = store.list(dir).filter((f) => /^[a-z0-9][a-z0-9_-]*\.json$/.test(f)).map((f) => f.replace(/\.json$/, ''));
43
+ const out = { rewrapped: [], current: [], left: [] };
44
+ for (const name of names) {
45
+ const path = credentialPath(worldRoot, name);
46
+ const text = store.read(path);
47
+ if (text === null)
48
+ continue;
49
+ let sealed;
50
+ try {
51
+ sealed = JSON.parse(text);
52
+ }
53
+ catch {
54
+ out.left.push(name);
55
+ continue;
56
+ }
57
+ if (!sealer.rewrap) {
58
+ out.left.push(name);
59
+ continue;
60
+ }
61
+ const result = await sealer.rewrap(sealed);
62
+ if (result.status === 'current') {
63
+ out.current.push(name);
64
+ continue;
65
+ }
66
+ if (result.status === 'foreign') {
67
+ out.left.push(name);
68
+ continue;
69
+ }
70
+ // sealed again while the vault rewrapped it: the new credential stands, and this record is not reported moved
71
+ if (store.read(path) !== text) {
72
+ out.left.push(name);
73
+ continue;
74
+ }
75
+ store.write(path, `${JSON.stringify(result.sealed, null, 2)}\n`, { secret: true });
76
+ out.rewrapped.push(name);
77
+ }
78
+ return out;
79
+ }
33
80
  /** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
34
81
  * for that destination only, so pointing the root or link elsewhere never sends it there. */
35
82
  export async function sealTwinCredential(worldRoot, vendor, payload, at, bound) {
@@ -31,12 +31,12 @@ 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, changesetContentHash, 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, readBranchMeta, 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";
38
38
  import { loadWorldConfig } from "./configs.js";
39
- import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
39
+ import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
40
40
  import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from "./runtime.js";
41
41
  export const TOKEN_HEADER = 'x-volter-token';
42
42
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
@@ -1256,9 +1256,19 @@ export class WorldDoors {
1256
1256
  const opensSeal = (kind === 'links' && name && (request.method === 'PUT' || request.method === 'DELETE'))
1257
1257
  || (kind === 'twins' && name && (verb === 'root' || verb === 'credential') && request.method === 'PUT')
1258
1258
  // a check runs in a local World's own process, with all it can reach: putting or removing one is the same act
1259
- || (kind === 'checks' && name && (request.method === 'PUT' || request.method === 'DELETE'));
1259
+ || (kind === 'checks' && name && (request.method === 'PUT' || request.method === 'DELETE'))
1260
+ // rewrapping the World's sealed credentials is its holder's act, after a rotation of the key that wrapped them
1261
+ || (kind === 'credentials' && name === 'rewrap' && request.method === 'POST');
1260
1262
  if (opensSeal && via === 'key')
1261
1263
  return Response.json({ error: 'opening a seal is the World\'s token\'s act, or a person\'s at its page: a key cannot point this World at a vendor, seal a credential, or change its checks' }, { status: 403 });
1264
+ // THE REWRAP: after an operator rotates the vault key this World's credentials are wrapped under, each record's DEK
1265
+ // is wrapped again under the newest version (transit/rewrap: the DEK never leaves the vault); the answer names
1266
+ // which moved, which were already current, and which this sealer does not wrap (sealed under a host key)
1267
+ if (kind === 'credentials' && name === 'rewrap' && request.method === 'POST') {
1268
+ if (scope !== 'write')
1269
+ return Response.json({ error: 'rewrapping needs the World\'s write access' }, { status: 403 });
1270
+ return Response.json(await this.serialized('credentials', () => rewrapTwinCredentials(this.worldRoot)));
1271
+ }
1262
1272
  if (kind === 'links' && name && request.method === 'PUT') {
1263
1273
  if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name) || this.up().services[name])
1264
1274
  return Response.json({ error: 'a link is named [a-z0-9_-] and is not a twin of this World' }, { status: 400 });
@@ -1315,8 +1325,31 @@ export class WorldDoors {
1315
1325
  const store = getActiveWorldStore();
1316
1326
  const key = () => { const t = store.read(tokenPath)?.trim(); if (!t)
1317
1327
  throw new Error(`${this.served} has no origin — PUT /-/${this.served}/origin { url, token }`); return t; };
1318
- if (!name && request.method === 'GET')
1319
- return Response.json({ origin: statusWorld(this.name, this.worldRoot).origin ?? null });
1328
+ if (!name && request.method === 'GET') {
1329
+ const origin = statusWorld(this.name, this.worldRoot).origin ?? null;
1330
+ if (!origin)
1331
+ return Response.json({ origin: null });
1332
+ // git's "ahead, behind" for this World and its origin, from what is here: ahead, the changes not cut and the
1333
+ // changesets not pushed; behind, per twin, what the last fetch brought that this branch has not taken (a pull
1334
+ // rebases onto it). As of the last fetch: what the origin gained since is known at the next one.
1335
+ const changes = diffWorld(this.name, { root: this.worldRoot }).actions.length;
1336
+ const changesets = listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => !l.changeset.applied || l.changeset.applied.outcome === 'refused').length;
1337
+ const behind = {};
1338
+ for (const twin of Object.keys(this.up().services)) {
1339
+ const meta = readBranchMeta(this.stateOf(twin), this.controlRoot(twin));
1340
+ // nothing fetched since the branch took it (a pull, a push or a rebase clears it) is nothing behind
1341
+ const fetched = meta?.fetchedOrigin;
1342
+ const on = meta?.origin ?? meta?.parent;
1343
+ if (!fetched || !on) {
1344
+ behind[twin] = 0;
1345
+ continue;
1346
+ }
1347
+ const gained = fetched.position - on.position;
1348
+ // a same-length history rewritten is drift too, read as the fetch reads it (a path origin has no remote view)
1349
+ behind[twin] = gained > 0 ? gained : (fetched.remoteView ?? fetched.view) !== (on.remoteView ?? on.view) ? 1 : 0;
1350
+ }
1351
+ return Response.json({ origin, ahead: { changes, changesets }, behind, asOf: origin.fetchedAt ?? null });
1352
+ }
1320
1353
  if (!name && request.method === 'PUT') {
1321
1354
  const body = (await request.json());
1322
1355
  const at = /^(https?:\/\/[^/]+)\/([a-z0-9][a-z0-9_-]*\/[a-z0-9][a-z0-9_-]*)\/?$/i.exec(body.url ?? '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.45",
3
+ "version": "2.0.47",
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",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.27",
70
+ "@volter/world-core": "2.0.29",
71
71
  "@volter/world-console": "2.0.13"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.2",
77
- "@volter/world-core": "2.0.27",
77
+ "@volter/world-core": "2.0.29",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.27"
87
+ "@volter/twin-mongodb": "0.1.29"
88
88
  }
89
89
  }
package/src/index.ts CHANGED
@@ -161,7 +161,7 @@ export type { FetchOptions, FetchOutcome } from './origin.ts';
161
161
 
162
162
  export { branchWorld, checkoutWorld } from './branch.ts';
163
163
  export type { BranchOptions } from './branch.ts';
164
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
164
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCredentials, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
165
165
  export type { DeployTwinOutcome, MaterializedRoot } from './root.ts';
166
166
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.ts';
167
167
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
package/src/root.ts CHANGED
@@ -37,6 +37,33 @@ export { userKek, userKekPath } from '@volter/world-core';
37
37
  export function credentialPath(worldRoot: string, vendor: string): string {
38
38
  return join(resolve(worldRoot), stateDirName(), 'credentials', `${vendor}.json`);
39
39
  }
40
+ /** Rewrap every credential sealed beside the World (each vendor's and each link's) under the newest version of the key
41
+ * that wrapped it: an operator's act after rotating the key, so no record still needs an old version. `current` is only
42
+ * what the sealer vouched for; a record it cannot (sealed under a host key, or by another vault key, a version it
43
+ * could not read), one changed while it was rewrapped (sealed again meanwhile, never overwritten) or one that does not
44
+ * read is `left`, by name. */
45
+ export async function rewrapTwinCredentials(worldRoot: string): Promise<{ rewrapped: string[]; current: string[]; left: string[] }> {
46
+ const store = getActiveWorldStore(); const sealer = currentSealer();
47
+ const dir = dirname(credentialPath(worldRoot, 'x'));
48
+ const names = store.list(dir).filter((f) => /^[a-z0-9][a-z0-9_-]*\.json$/.test(f)).map((f) => f.replace(/\.json$/, ''));
49
+ const out = { rewrapped: [] as string[], current: [] as string[], left: [] as string[] };
50
+ for (const name of names) {
51
+ const path = credentialPath(worldRoot, name);
52
+ const text = store.read(path); if (text === null) continue;
53
+ let sealed: SealedCredential;
54
+ try { sealed = JSON.parse(text) as SealedCredential; } catch { out.left.push(name); continue; }
55
+ if (!sealer.rewrap) { out.left.push(name); continue; }
56
+ const result = await sealer.rewrap(sealed);
57
+ if (result.status === 'current') { out.current.push(name); continue; }
58
+ if (result.status === 'foreign') { out.left.push(name); continue; }
59
+ // sealed again while the vault rewrapped it: the new credential stands, and this record is not reported moved
60
+ if (store.read(path) !== text) { out.left.push(name); continue; }
61
+ store.write(path, `${JSON.stringify(result.sealed, null, 2)}\n`, { secret: true });
62
+ out.rewrapped.push(name);
63
+ }
64
+ return out;
65
+ }
66
+
40
67
  /** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
41
68
  * for that destination only, so pointing the root or link elsewhere never sends it there. */
42
69
  export async function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at: string | undefined, bound: string): Promise<{ placedAt: string; fingerprint: string }> {
@@ -33,13 +33,13 @@ import { createHash } from 'node:crypto';
33
33
  import { dirname, join, resolve } from 'node:path';
34
34
  import {
35
35
  appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
36
- parentEntries, rebaseBranch, type Changeset, type Entry, type Receipt, type RootConfig,
36
+ parentEntries, readBranchMeta, 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';
39
39
  import { fetchFromOrigin } from './origin.ts';
40
40
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
41
41
  import { loadWorldConfig } from './configs.ts';
42
- import { adaptersFor, credentialPath, packMirror, type PackMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from './root.ts';
42
+ import { adaptersFor, credentialPath, packMirror, type PackMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from './root.ts';
43
43
  import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from './runtime.ts';
44
44
  import type { WorldInstance } from './schema.ts';
45
45
 
@@ -1094,8 +1094,17 @@ export class WorldDoors {
1094
1094
  const opensSeal = (kind === 'links' && name && (request.method === 'PUT' || request.method === 'DELETE'))
1095
1095
  || (kind === 'twins' && name && (verb === 'root' || verb === 'credential') && request.method === 'PUT')
1096
1096
  // a check runs in a local World's own process, with all it can reach: putting or removing one is the same act
1097
- || (kind === 'checks' && name && (request.method === 'PUT' || request.method === 'DELETE'));
1097
+ || (kind === 'checks' && name && (request.method === 'PUT' || request.method === 'DELETE'))
1098
+ // rewrapping the World's sealed credentials is its holder's act, after a rotation of the key that wrapped them
1099
+ || (kind === 'credentials' && name === 'rewrap' && request.method === 'POST');
1098
1100
  if (opensSeal && via === 'key') return Response.json({ error: 'opening a seal is the World\'s token\'s act, or a person\'s at its page: a key cannot point this World at a vendor, seal a credential, or change its checks' }, { status: 403 });
1101
+ // THE REWRAP: after an operator rotates the vault key this World's credentials are wrapped under, each record's DEK
1102
+ // is wrapped again under the newest version (transit/rewrap: the DEK never leaves the vault); the answer names
1103
+ // which moved, which were already current, and which this sealer does not wrap (sealed under a host key)
1104
+ if (kind === 'credentials' && name === 'rewrap' && request.method === 'POST') {
1105
+ if (scope !== 'write') return Response.json({ error: 'rewrapping needs the World\'s write access' }, { status: 403 });
1106
+ return Response.json(await this.serialized('credentials', () => rewrapTwinCredentials(this.worldRoot)));
1107
+ }
1099
1108
  if (kind === 'links' && name && request.method === 'PUT') {
1100
1109
  if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name) || this.up().services[name]) return Response.json({ error: 'a link is named [a-z0-9_-] and is not a twin of this World' }, { status: 400 });
1101
1110
  const body = (await request.json()) as { origin?: string };
@@ -1131,7 +1140,26 @@ export class WorldDoors {
1131
1140
  const tokenPath = join(this.worldRoot, stateDirName(), 'origin.token');
1132
1141
  const store = getActiveWorldStore();
1133
1142
  const key = (): string => { const t = store.read(tokenPath)?.trim(); if (!t) throw new Error(`${this.served} has no origin — PUT /-/${this.served}/origin { url, token }`); return t; };
1134
- if (!name && request.method === 'GET') return Response.json({ origin: statusWorld(this.name, this.worldRoot).origin ?? null });
1143
+ if (!name && request.method === 'GET') {
1144
+ const origin = statusWorld(this.name, this.worldRoot).origin ?? null;
1145
+ if (!origin) return Response.json({ origin: null });
1146
+ // git's "ahead, behind" for this World and its origin, from what is here: ahead, the changes not cut and the
1147
+ // changesets not pushed; behind, per twin, what the last fetch brought that this branch has not taken (a pull
1148
+ // rebases onto it). As of the last fetch: what the origin gained since is known at the next one.
1149
+ const changes = diffWorld(this.name, { root: this.worldRoot }).actions.length;
1150
+ const changesets = listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => !l.changeset.applied || l.changeset.applied.outcome === 'refused').length;
1151
+ const behind: Record<string, number> = {};
1152
+ for (const twin of Object.keys(this.up().services)) {
1153
+ const meta = readBranchMeta(this.stateOf(twin), this.controlRoot(twin));
1154
+ // nothing fetched since the branch took it (a pull, a push or a rebase clears it) is nothing behind
1155
+ const fetched = meta?.fetchedOrigin; const on = meta?.origin ?? meta?.parent;
1156
+ if (!fetched || !on) { behind[twin] = 0; continue; }
1157
+ const gained = fetched.position - on.position;
1158
+ // a same-length history rewritten is drift too, read as the fetch reads it (a path origin has no remote view)
1159
+ behind[twin] = gained > 0 ? gained : (fetched.remoteView ?? fetched.view) !== (on.remoteView ?? on.view) ? 1 : 0;
1160
+ }
1161
+ return Response.json({ origin, ahead: { changes, changesets }, behind, asOf: origin.fetchedAt ?? null });
1162
+ }
1135
1163
  if (!name && request.method === 'PUT') {
1136
1164
  const body = (await request.json()) as { url?: string; token?: string; views?: Record<string, HistoryReference> };
1137
1165
  const at = /^(https?:\/\/[^/]+)\/([a-z0-9][a-z0-9_-]*\/[a-z0-9][a-z0-9_-]*)\/?$/i.exec(body.url ?? '');