@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 +3 -3
- package/dist/src/served-world.d.ts +3 -2
- package/dist/src/served-world.js +41 -12
- package/dist/src/tail.d.ts +3 -3
- package/dist/src/tail.js +60 -11
- package/package.json +5 -5
- package/src/cli.ts +3 -3
- package/src/served-world.ts +38 -11
- package/src/tail.ts +37 -11
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
|
|
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 —
|
|
117
|
-
# VOLTER_TWIN_REQUEST_JOURNAL=
|
|
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
|
|
318
|
-
*
|
|
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>/`. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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
|
|
938
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
package/dist/src/tail.d.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
* status}` rows;
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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.
|
|
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.
|
|
71
|
-
"@volter/world-console": "2.0.
|
|
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.
|
|
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.
|
|
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
|
|
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 —
|
|
118
|
-
# VOLTER_TWIN_REQUEST_JOURNAL=
|
|
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)
|
package/src/served-world.ts
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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
|
|
839
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
44
|
-
*
|
|
45
|
-
* status}` rows;
|
|
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
|
-
|
|
83
|
-
if (size
|
|
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
|
-
|
|
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 (!
|
|
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'
|