@volter/world-runtime 2.0.31 → 2.0.33

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.
package/dist/src/cli.js CHANGED
@@ -111,10 +111,10 @@ function printHelp() {
111
111
  # merged, occurredAt-ordered feed of the twins' action ledgers — one
112
112
  # \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
113
113
  # follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
114
- # --requests: also merge the opt-in request journals — reads/404s,
114
+ # --requests: also merge the request journals — reads/404s,
115
115
  # plus WHICH credential arrived in WHICH header/query param, named +
116
- # fingerprinted, never the value — written by twins under
117
- # VOLTER_TWIN_REQUEST_JOURNAL=1)
116
+ # fingerprinted, never the value — kept by every twin and link
117
+ # unless VOLTER_TWIN_REQUEST_JOURNAL=0)
118
118
  volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
119
119
  # pull from the placeholder remote: the seed entry runs with every twin's
120
120
  # window open, so the defaults land as observed state (diff empty, nothing pending)
@@ -314,10 +314,11 @@ export declare class WorldDoors {
314
314
  origin: string;
315
315
  }>;
316
316
  private link;
317
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
318
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
317
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
318
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
319
319
  * answer (status, headers, body) is streamed back as it arrives. */
320
320
  private forward;
321
+ private forwardSealed;
321
322
  /** The instant of the twin's last completed refresh (its marker's `observedAt`), when it has a root. */
322
323
  private observedAt;
323
324
  /** The world's own doors under `/-/<org>/<world>/`. */
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
29
- import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
29
+ import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
30
30
  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';
@@ -278,7 +278,8 @@ export async function serveWorld(name, opts = {}) {
278
278
  // doors answer 503.
279
279
  let consoleOrigin = null;
280
280
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
281
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
281
+ // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
282
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
282
283
  const url = `http://${host}:${server.port}`;
283
284
  try {
284
285
  await mounted.boot(url);
@@ -850,7 +851,7 @@ export class WorldDoors {
850
851
  return foreign;
851
852
  const reads = request.method === 'GET' || request.method === 'HEAD';
852
853
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
853
- // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
854
+ // the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
854
855
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
855
856
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
856
857
  const link = this.link(vendor);
@@ -934,10 +935,39 @@ export class WorldDoors {
934
935
  return held === null ? {} : JSON.parse(held);
935
936
  }
936
937
  link(name) { return this.links()[name] ?? null; }
937
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
938
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
938
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
939
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
939
940
  * answer (status, headers, body) is streamed back as it arrives. */
940
941
  async forward(request, url, rest, name, origin) {
942
+ // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
943
+ // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
944
+ const caller = new Headers(request.headers);
945
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
946
+ caller.delete(h);
947
+ // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
948
+ const authorization = caller.get('authorization');
949
+ const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
950
+ if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued())))
951
+ caller.delete('authorization');
952
+ // an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
953
+ // name, with the credential shape of what the caller sent on. As an audit device does, the request is
954
+ // written before the real credential is used, and a request whose audit cannot be written is refused.
955
+ const credentials = twinRequestCredentials(caller, url);
956
+ if (!journalTwinRequest(name, { phase: 'request', method: request.method, path: `/${rest}`, status: 0, credentials }, this.controlRoot(name))) {
957
+ return Response.json({ error: `the ${name} link's audit could not be written: nothing was sent` }, { status: 503 });
958
+ }
959
+ const startedAt = Date.now();
960
+ let status = 500;
961
+ try {
962
+ const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
963
+ status = answer.status;
964
+ return answer;
965
+ }
966
+ finally {
967
+ journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials }, this.controlRoot(name));
968
+ }
969
+ }
970
+ async forwardSealed(request, caller, url, rest, name, origin) {
941
971
  const base = validateRemoteOrigin(origin);
942
972
  // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
943
973
  let credential;
@@ -952,9 +982,7 @@ export class WorldDoors {
952
982
  const target = new URL(`${base.origin}/${rest}${url.search}`);
953
983
  if (target.origin !== base.origin)
954
984
  return Response.json({ error: 'the path left the link origin' }, { status: 400 });
955
- const headers = new Headers(request.headers);
956
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
957
- headers.delete(h);
985
+ const headers = new Headers(caller);
958
986
  for (const [k, v] of Object.entries(credential.headers))
959
987
  headers.set(k, v);
960
988
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
@@ -1300,12 +1328,13 @@ export class WorldDoors {
1300
1328
  }
1301
1329
  if (kind === 'requests' && !name && request.method === 'GET') {
1302
1330
  // the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
1303
- // requests by twin and status class, the response time's p50/p95, the top routes
1331
+ // requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
1304
1332
  const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000;
1305
1333
  const since = Date.now() - sinceMs;
1306
1334
  const twins = [];
1307
- for (const vendor of Object.keys(this.up().services)) {
1308
- const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
1335
+ for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
1336
+ // a link's request entries (written before it forwards) are its audit, not traffic: its outcomes are counted
1337
+ const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor), { since }).filter((e) => e.phase !== 'request' && (!e.at || Date.parse(e.at) >= since));
1309
1338
  const byStatus = {};
1310
1339
  const routes = new Map();
1311
1340
  const times = [];
@@ -1321,7 +1350,7 @@ export class WorldDoors {
1321
1350
  const pct = (p) => (times.length ? times[Math.min(times.length - 1, Math.floor((p / 100) * times.length))] : null);
1322
1351
  twins.push({ twin: vendor, requests: entries.length, byStatus, p50Ms: pct(50), p95Ms: pct(95), topRoutes: [...routes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([route, requests]) => ({ route, requests })) });
1323
1352
  }
1324
- return Response.json({ world: this.served, hours: sinceMs / 3_600_000, twins, requests: twins.reduce((n, t) => n + t.requests, 0) });
1353
+ return Response.json({ world: this.served, hours: sinceMs / 3_600_000, twins, requests: twins.reduce((n, t) => n + t.requests, 0), journalFailures: twins.reduce((n, t) => n + twinRequestJournalFailures(t.twin, this.controlRoot(t.twin)), 0) });
1325
1354
  }
1326
1355
  if (kind === 'twins' && !name && request.method === 'GET') {
1327
1356
  // every twin of the world as a consumer sees it — the status door mapped over the world; what a
@@ -6,9 +6,9 @@ export type TailWorldOptions = {
6
6
  follow?: boolean;
7
7
  /** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
8
8
  json?: boolean;
9
- /** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
10
- * by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
11
- * status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
9
+ /** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
10
+ * twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
11
+ * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
12
12
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
13
13
  requests?: boolean;
14
14
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
package/dist/src/tail.js CHANGED
@@ -6,8 +6,8 @@
6
6
  // this just watches what the twins already record. A ledger dir may not exist until a service
7
7
  // records its first action, so discovery re-runs on every poll and a missing ledger is a
8
8
  // notice, never an error.
9
- import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
10
- import { join } from 'node:path';
9
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
10
+ import { basename, dirname, join } from 'node:path';
11
11
  import { stateDirName } from '@volter/world-core';
12
12
  import { statusWorld } from "./runtime.js";
13
13
  /** Every action ledger a world service currently owns. One world service can record actions
@@ -22,6 +22,15 @@ function serviceLedgers(dataDir, service, file = 'actions.jsonl') {
22
22
  .map((entry) => join(stateRoot, entry.name, file))
23
23
  .sort();
24
24
  }
25
+ /** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
26
+ function linkNames(root) {
27
+ try {
28
+ return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
25
34
  /** Read the complete lines appended to `path` since the recorded offset, advancing the offset
26
35
  * only past the last newline — a half-written trailing line stays unconsumed until the writer
27
36
  * finishes it, so a poll can never emit a torn row. */
@@ -34,10 +43,24 @@ function readNewLines(path, offsets) {
34
43
  catch {
35
44
  return []; // vanished between discovery and read (e.g. a purge mid-tail) — next poll rediscovers
36
45
  }
37
- if (size < offset)
38
- offset = 0; // rewritten shorter (scrub/rebuild) — replay rather than silently skip
39
- if (size === offset)
40
- return [];
46
+ let closed = [];
47
+ if (size < offset) {
48
+ // a request journal that closed into its next segment (world-core serve.ts): what was appended there
49
+ // after the last poll is read from the segment first; any other shorter rewrite (scrub/rebuild) replays
50
+ const segment = newestSegment(path);
51
+ let closedSize = -1;
52
+ try {
53
+ closedSize = segment ? statSync(segment).size : -1;
54
+ }
55
+ catch { /* gone between list and stat */ }
56
+ if (segment && closedSize >= offset)
57
+ closed = readNewLines(segment, new Map([[segment, offset]]));
58
+ offset = 0;
59
+ }
60
+ if (size === offset) {
61
+ offsets.set(path, offset);
62
+ return closed;
63
+ }
41
64
  const fd = openSync(path, 'r');
42
65
  const buffer = Buffer.alloc(size - offset);
43
66
  try {
@@ -47,10 +70,29 @@ function readNewLines(path, offsets) {
47
70
  closeSync(fd);
48
71
  }
49
72
  const lastNewline = buffer.lastIndexOf(0x0a);
50
- if (lastNewline < 0)
51
- return [];
73
+ if (lastNewline < 0) {
74
+ offsets.set(path, offset);
75
+ return closed;
76
+ }
52
77
  offsets.set(path, offset + lastNewline + 1);
53
- return buffer.subarray(0, lastNewline).toString('utf8').split('\n').filter((line) => line.trim() !== '');
78
+ return [...closed, ...buffer.subarray(0, lastNewline).toString('utf8').split('\n').filter((line) => line.trim() !== '')];
79
+ }
80
+ /** A journal's newest closed segment (`<file>.<n>`), or null. */
81
+ function newestSegment(path) {
82
+ const dir = dirname(path);
83
+ const base = basename(path);
84
+ let best = 0;
85
+ try {
86
+ for (const name of readdirSync(dir)) {
87
+ const n = name.startsWith(`${base}.`) ? Number(name.slice(base.length + 1)) : NaN;
88
+ if (Number.isInteger(n) && n > best)
89
+ best = n;
90
+ }
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ return best ? `${path}.${best}` : null;
54
96
  }
55
97
  /** Local wall-clock `HH:MM:SS.mmm` — the operator is watching a live feed on this machine. */
56
98
  function formatOccurredAt(occurredAt) {
@@ -76,6 +118,9 @@ function formatEntry(entry, json) {
76
118
  const creds = (record.credentials ?? [])
77
119
  .map((c) => `${c.name ?? '?'}${c.in === 'query' ? '(query)' : ''}=${c.empty ? 'EMPTY' : `${c.scheme ? `${c.scheme} ` : ''}${(c.fp ?? '').replace(/^sha256:/, '').slice(0, 8)}`}`)
78
120
  .join(' ');
121
+ // a link's request entry, written before it forwards: the request on its way, its outcome follows
122
+ if (record.phase === 'request')
123
+ return `${formatOccurredAt(entryInstant(entry))} ${entry.service} → ${record.method ?? '?'} ${record.path ?? '?'}${creds ? ` [${creds}]` : ''}`;
79
124
  return `${formatOccurredAt(entryInstant(entry))} ${entry.service} ${record.method ?? '?'} ${record.path ?? '?'} ${record.status ?? '?'}${typeof record.ms === 'number' ? ` in ${record.ms}ms` : ''}${creds ? ` [${creds}]` : ''}`;
80
125
  }
81
126
  return `${formatOccurredAt(record.occurredAt)} ${entry.service} ${record.operation ?? record.op ?? '?'} ${record.subject?.id ?? '?'}`;
@@ -105,9 +150,11 @@ function sleep(ms, signal) {
105
150
  export async function tailWorldActions(name, options = {}) {
106
151
  const root = options.root ?? process.cwd();
107
152
  const instance = statusWorld(name, root);
108
- const known = Object.keys(instance.services);
153
+ // a link (an opened seal) journals its requests as a twin does, under its own name
154
+ const links = linkNames(root);
155
+ const known = [...new Set([...Object.keys(instance.services), ...links])];
109
156
  for (const service of options.services ?? []) {
110
- if (!instance.services[service]) {
157
+ if (!known.includes(service)) {
111
158
  throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
112
159
  }
113
160
  }
@@ -143,6 +190,8 @@ export async function tailWorldActions(name, options = {}) {
143
190
  };
144
191
  emit(collect());
145
192
  for (const service of services) {
193
+ if (links.includes(service) && !instance.services[service])
194
+ continue; // a link records requests, never actions
146
195
  if (serviceLedgers(instance.dirs.data, service).length === 0) {
147
196
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
148
197
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.31",
3
+ "version": "2.0.33",
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.18",
71
- "@volter/world-console": "2.0.10"
70
+ "@volter/world-core": "2.0.20",
71
+ "@volter/world-console": "2.0.12"
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.18",
77
+ "@volter/world-core": "2.0.20",
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.18"
87
+ "@volter/twin-mongodb": "0.1.20"
88
88
  }
89
89
  }
package/src/cli.ts CHANGED
@@ -112,10 +112,10 @@ function printHelp(): void {
112
112
  # merged, occurredAt-ordered feed of the twins' action ledgers — one
113
113
  # \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
114
114
  # follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
115
- # --requests: also merge the opt-in request journals — reads/404s,
115
+ # --requests: also merge the request journals — reads/404s,
116
116
  # plus WHICH credential arrived in WHICH header/query param, named +
117
- # fingerprinted, never the value — written by twins under
118
- # VOLTER_TWIN_REQUEST_JOURNAL=1)
117
+ # fingerprinted, never the value — kept by every twin and link
118
+ # unless VOLTER_TWIN_REQUEST_JOURNAL=0)
119
119
  volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
120
120
  # pull from the placeholder remote: the seed entry runs with every twin's
121
121
  # window open, so the defaults land as observed state (diff empty, nothing pending)
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
29
- import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
29
+ import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
30
30
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
@@ -252,7 +252,8 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
252
252
  // doors answer 503.
253
253
  let consoleOrigin: string | null = null;
254
254
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
255
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
255
+ // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
256
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
256
257
  const url = `http://${host}:${server.port}`;
257
258
  try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
258
259
  // the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
@@ -765,7 +766,7 @@ export class WorldDoors {
765
766
  if (foreign) return foreign;
766
767
  const reads = request.method === 'GET' || request.method === 'HEAD';
767
768
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
768
- // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
769
+ // the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
769
770
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
770
771
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
771
772
  const link = this.link(vendor!);
@@ -835,10 +836,36 @@ export class WorldDoors {
835
836
  }
836
837
  private link(name: string): { origin: string } | null { return this.links()[name] ?? null; }
837
838
 
838
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
839
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
839
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
840
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
840
841
  * answer (status, headers, body) is streamed back as it arrives. */
841
842
  private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
843
+ // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
844
+ // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
845
+ const caller = new Headers(request.headers);
846
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
847
+ // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
848
+ const authorization = caller.get('authorization');
849
+ const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
850
+ if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued()))) caller.delete('authorization');
851
+ // an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
852
+ // name, with the credential shape of what the caller sent on. As an audit device does, the request is
853
+ // written before the real credential is used, and a request whose audit cannot be written is refused.
854
+ const credentials = twinRequestCredentials(caller, url);
855
+ if (!journalTwinRequest(name, { phase: 'request', method: request.method, path: `/${rest}`, status: 0, credentials }, this.controlRoot(name))) {
856
+ return Response.json({ error: `the ${name} link's audit could not be written: nothing was sent` }, { status: 503 });
857
+ }
858
+ const startedAt = Date.now(); let status = 500;
859
+ try {
860
+ const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
861
+ status = answer.status;
862
+ return answer;
863
+ } finally {
864
+ journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials }, this.controlRoot(name));
865
+ }
866
+ }
867
+
868
+ private async forwardSealed(request: Request, caller: Headers, url: URL, rest: string, name: string, origin: string): Promise<Response> {
842
869
  const base = validateRemoteOrigin(origin);
843
870
  // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
844
871
  let credential: Awaited<ReturnType<typeof openSealedAt>>;
@@ -846,8 +873,7 @@ export class WorldDoors {
846
873
  if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
847
874
  const target = new URL(`${base.origin}/${rest}${url.search}`);
848
875
  if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
849
- const headers = new Headers(request.headers);
850
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) headers.delete(h);
876
+ const headers = new Headers(caller);
851
877
  for (const [k, v] of Object.entries(credential.headers)) headers.set(k, v);
852
878
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
853
879
  const controller = new AbortController();
@@ -1120,17 +1146,18 @@ export class WorldDoors {
1120
1146
  }
1121
1147
  if (kind === 'requests' && !name && request.method === 'GET') {
1122
1148
  // the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
1123
- // requests by twin and status class, the response time's p50/p95, the top routes
1149
+ // requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
1124
1150
  const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000; const since = Date.now() - sinceMs;
1125
1151
  const twins: Array<{ twin: string; requests: number; byStatus: Record<string, number>; p50Ms: number | null; p95Ms: number | null; topRoutes: Array<{ route: string; requests: number }> }> = [];
1126
- for (const vendor of Object.keys(this.up().services)) {
1127
- const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
1152
+ for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
1153
+ // a link's request entries (written before it forwards) are its audit, not traffic: its outcomes are counted
1154
+ const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor), { since }).filter((e) => e.phase !== 'request' && (!e.at || Date.parse(e.at) >= since));
1128
1155
  const byStatus: Record<string, number> = {}; const routes = new Map<string, number>(); const times: number[] = [];
1129
1156
  for (const e of entries) { const cls = `${String(e.status)[0]}xx`; byStatus[cls] = (byStatus[cls] ?? 0) + 1; const route = `${e.method} ${e.path.replace(/\/[0-9]+(?=\/|$)/g, '/{id}')}`; routes.set(route, (routes.get(route) ?? 0) + 1); if (typeof e.ms === 'number') times.push(e.ms); }
1130
1157
  times.sort((a, b) => a - b); const pct = (p: number): number | null => (times.length ? times[Math.min(times.length - 1, Math.floor((p / 100) * times.length))]! : null);
1131
1158
  twins.push({ twin: vendor, requests: entries.length, byStatus, p50Ms: pct(50), p95Ms: pct(95), topRoutes: [...routes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([route, requests]) => ({ route, requests })) });
1132
1159
  }
1133
- return Response.json({ world: this.served, hours: sinceMs / 3_600_000, twins, requests: twins.reduce((n, t) => n + t.requests, 0) });
1160
+ return Response.json({ world: this.served, hours: sinceMs / 3_600_000, twins, requests: twins.reduce((n, t) => n + t.requests, 0), journalFailures: twins.reduce((n, t) => n + twinRequestJournalFailures(t.twin, this.controlRoot(t.twin)), 0) });
1134
1161
  }
1135
1162
  if (kind === 'twins' && !name && request.method === 'GET') {
1136
1163
  // every twin of the world as a consumer sees it — the status door mapped over the world; what a
package/src/tail.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  // this just watches what the twins already record. A ledger dir may not exist until a service
7
7
  // records its first action, so discovery re-runs on every poll and a missing ledger is a
8
8
  // notice, never an error.
9
- import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
10
- import { join } from 'node:path';
9
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
10
+ import { basename, dirname, join } from 'node:path';
11
11
  import { stateDirName } from '@volter/world-core';
12
12
  import { statusWorld } from './runtime.ts';
13
13
 
@@ -40,9 +40,9 @@ export type TailWorldOptions = {
40
40
  follow?: boolean;
41
41
  /** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
42
42
  json?: boolean;
43
- /** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
44
- * by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
45
- * status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
43
+ /** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
44
+ * twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
45
+ * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
46
46
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
47
47
  requests?: boolean;
48
48
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
@@ -68,6 +68,11 @@ function serviceLedgers(dataDir: string, service: string, file: 'actions.jsonl'
68
68
  .sort();
69
69
  }
70
70
 
71
+ /** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
72
+ function linkNames(root: string): string[] {
73
+ try { return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')) as Record<string, unknown>); } catch { return []; }
74
+ }
75
+
71
76
  /** Read the complete lines appended to `path` since the recorded offset, advancing the offset
72
77
  * only past the last newline — a half-written trailing line stays unconsumed until the writer
73
78
  * finishes it, so a poll can never emit a torn row. */
@@ -79,8 +84,16 @@ function readNewLines(path: string, offsets: Map<string, number>): string[] {
79
84
  } catch {
80
85
  return []; // vanished between discovery and read (e.g. a purge mid-tail) — next poll rediscovers
81
86
  }
82
- if (size < offset) offset = 0; // rewritten shorter (scrub/rebuild) — replay rather than silently skip
83
- if (size === offset) return [];
87
+ let closed: string[] = [];
88
+ if (size < offset) {
89
+ // a request journal that closed into its next segment (world-core serve.ts): what was appended there
90
+ // after the last poll is read from the segment first; any other shorter rewrite (scrub/rebuild) replays
91
+ const segment = newestSegment(path);
92
+ let closedSize = -1; try { closedSize = segment ? statSync(segment).size : -1; } catch { /* gone between list and stat */ }
93
+ if (segment && closedSize >= offset) closed = readNewLines(segment, new Map([[segment, offset]]));
94
+ offset = 0;
95
+ }
96
+ if (size === offset) { offsets.set(path, offset); return closed; }
84
97
  const fd = openSync(path, 'r');
85
98
  const buffer = Buffer.alloc(size - offset);
86
99
  try {
@@ -89,9 +102,17 @@ function readNewLines(path: string, offsets: Map<string, number>): string[] {
89
102
  closeSync(fd);
90
103
  }
91
104
  const lastNewline = buffer.lastIndexOf(0x0a);
92
- if (lastNewline < 0) return [];
105
+ if (lastNewline < 0) { offsets.set(path, offset); return closed; }
93
106
  offsets.set(path, offset + lastNewline + 1);
94
- return buffer.subarray(0, lastNewline).toString('utf8').split('\n').filter((line) => line.trim() !== '');
107
+ return [...closed, ...buffer.subarray(0, lastNewline).toString('utf8').split('\n').filter((line) => line.trim() !== '')];
108
+ }
109
+
110
+ /** A journal's newest closed segment (`<file>.<n>`), or null. */
111
+ function newestSegment(path: string): string | null {
112
+ const dir = dirname(path); const base = basename(path);
113
+ let best = 0;
114
+ try { for (const name of readdirSync(dir)) { const n = name.startsWith(`${base}.`) ? Number(name.slice(base.length + 1)) : NaN; if (Number.isInteger(n) && n > best) best = n; } } catch { return null; }
115
+ return best ? `${path}.${best}` : null;
95
116
  }
96
117
 
97
118
  /** Local wall-clock `HH:MM:SS.mmm` — the operator is watching a live feed on this machine. */
@@ -118,6 +139,8 @@ function formatEntry(entry: TailEntry, json: boolean): string {
118
139
  const creds = (record.credentials ?? [])
119
140
  .map((c) => `${c.name ?? '?'}${c.in === 'query' ? '(query)' : ''}=${c.empty ? 'EMPTY' : `${c.scheme ? `${c.scheme} ` : ''}${(c.fp ?? '').replace(/^sha256:/, '').slice(0, 8)}`}`)
120
141
  .join(' ');
142
+ // a link's request entry, written before it forwards: the request on its way, its outcome follows
143
+ if (record.phase === 'request') return `${formatOccurredAt(entryInstant(entry))} ${entry.service} → ${record.method ?? '?'} ${record.path ?? '?'}${creds ? ` [${creds}]` : ''}`;
121
144
  return `${formatOccurredAt(entryInstant(entry))} ${entry.service} ${record.method ?? '?'} ${record.path ?? '?'} ${record.status ?? '?'}${typeof record.ms === 'number' ? ` in ${record.ms}ms` : ''}${creds ? ` [${creds}]` : ''}`;
122
145
  }
123
146
  return `${formatOccurredAt(record.occurredAt)} ${entry.service} ${record.operation ?? record.op ?? '?'} ${record.subject?.id ?? '?'}`;
@@ -150,9 +173,11 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
150
173
  export async function tailWorldActions(name: string, options: TailWorldOptions = {}): Promise<void> {
151
174
  const root = options.root ?? process.cwd();
152
175
  const instance = statusWorld(name, root);
153
- const known = Object.keys(instance.services);
176
+ // a link (an opened seal) journals its requests as a twin does, under its own name
177
+ const links = linkNames(root);
178
+ const known = [...new Set([...Object.keys(instance.services), ...links])];
154
179
  for (const service of options.services ?? []) {
155
- if (!instance.services[service]) {
180
+ if (!known.includes(service)) {
156
181
  throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
157
182
  }
158
183
  }
@@ -189,6 +214,7 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
189
214
 
190
215
  emit(collect());
191
216
  for (const service of services) {
217
+ if (links.includes(service) && !instance.services[service]) continue; // a link records requests, never actions
192
218
  if (serviceLedgers(instance.dirs.data, service).length === 0) {
193
219
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
194
220
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'