@volter/world-runtime 2.0.30 → 2.0.32
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/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/root.d.ts +18 -1
- package/dist/src/root.js +135 -4
- package/dist/src/served-world.d.ts +18 -2
- package/dist/src/served-world.js +76 -15
- package/dist/src/tail.d.ts +3 -3
- package/dist/src/tail.js +16 -3
- package/package.json +5 -5
- package/src/cli.ts +3 -3
- package/src/index.ts +1 -1
- package/src/root.ts +109 -5
- package/src/served-world.ts +63 -14
- package/src/tail.ts +14 -6
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)
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.js';
|
|
42
|
+
export { deployWorld, refreshTwin, adaptersFor, credentialPath, 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, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
|
|
32
|
+
export { deployWorld, refreshTwin, adaptersFor, credentialPath, 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";
|
package/dist/src/root.d.ts
CHANGED
|
@@ -5,14 +5,31 @@ 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
|
-
|
|
8
|
+
/** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
|
|
9
|
+
* for that destination only, so pointing the root or link elsewhere never sends it there. */
|
|
10
|
+
export declare function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at: string | undefined, bound: string): Promise<{
|
|
9
11
|
placedAt: string;
|
|
10
12
|
fingerprint: string;
|
|
11
13
|
}>;
|
|
14
|
+
/** Bind each credential sealed before bindings to its destination, keeping its placedAt, fingerprint and rotatedAt,
|
|
15
|
+
* and only over the record that was read; and move every record still under the host's own key (v1) to the vault,
|
|
16
|
+
* keeping its binding. Run by the World's host as it wakes, before it serves or refreshes
|
|
17
|
+
* (WorldDoors.bindLegacyCredentials). At its first run on this code it takes the inventory: every credential file
|
|
18
|
+
* beside the World, each one sealed before bindings with the destination `current()` names for it (the root's or
|
|
19
|
+
* link's URL, as no request has changed it since) or none. Every later run binds from that sealed inventory, never
|
|
20
|
+
* from a root or link changed since. `ready` is false when the inventory could not be kept (the vault down): the host
|
|
21
|
+
* admits nothing until it is. */
|
|
22
|
+
export declare function bindLegacyCredentials(worldRoot: string, current: () => Record<string, string>): Promise<{
|
|
23
|
+
bound: string[];
|
|
24
|
+
failed: string[];
|
|
25
|
+
refused: string[];
|
|
26
|
+
ready: boolean;
|
|
27
|
+
}>;
|
|
12
28
|
export declare function sealedCredentialInfo(worldRoot: string, vendor: string): {
|
|
13
29
|
placedAt: string;
|
|
14
30
|
fingerprint: string;
|
|
15
31
|
sealedBy: 'vault' | 'key';
|
|
32
|
+
boundTo?: string;
|
|
16
33
|
} | null;
|
|
17
34
|
/** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
|
|
18
35
|
export declare function twinSigningSecret(worldRoot: string, vendor: string): Promise<string | null>;
|
package/dist/src/root.js
CHANGED
|
@@ -11,7 +11,7 @@ import { packEntry } from "./catalog.js";
|
|
|
11
11
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
14
|
-
import { authStrategyFor, currentSealer, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, readRoot, registerStateSystem, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
|
|
14
|
+
import { authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, readRoot, registerStateSystem, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
|
|
15
15
|
import { loadWorldConfig, writeWorldConfig } from "./configs.js";
|
|
16
16
|
import { changesetReadiness, collectObservations, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding } from '@volter/world-core';
|
|
17
17
|
import { findWorldChangeset, listWorldChangesets } from "./changeset.js";
|
|
@@ -30,18 +30,149 @@ 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
|
-
|
|
34
|
-
|
|
33
|
+
/** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
|
|
34
|
+
* for that destination only, so pointing the root or link elsewhere never sends it there. */
|
|
35
|
+
export async function sealTwinCredential(worldRoot, vendor, payload, at, bound) {
|
|
36
|
+
if (!/^origin=https?:\/\/[^/]+$/.test(bound))
|
|
37
|
+
throw new Error(`a credential is sealed for a destination (destinationOf its root's or link's origin), not ${JSON.stringify(bound)}`);
|
|
38
|
+
const sealed = await currentSealer().seal(payload, at ?? new Date().toISOString(), vendor, bound);
|
|
35
39
|
getActiveWorldStore().write(credentialPath(worldRoot, vendor), `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
|
|
36
40
|
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint };
|
|
37
41
|
}
|
|
42
|
+
/** The inventory of credentials sealed before bindings, kept sealed beside them (`.legacy-inventory.json`, its own
|
|
43
|
+
* slot): `pending` maps each such credential to the destination it is bound to, or null when it had none and so is
|
|
44
|
+
* refused until set again. Sealed, so no one who can write the World's files can plant one; kept once made, as the
|
|
45
|
+
* record that the first inventory happened. */
|
|
46
|
+
const INVENTORY_SLOT = 'credential-inventory';
|
|
47
|
+
/** Bind each credential sealed before bindings to its destination, keeping its placedAt, fingerprint and rotatedAt,
|
|
48
|
+
* and only over the record that was read; and move every record still under the host's own key (v1) to the vault,
|
|
49
|
+
* keeping its binding. Run by the World's host as it wakes, before it serves or refreshes
|
|
50
|
+
* (WorldDoors.bindLegacyCredentials). At its first run on this code it takes the inventory: every credential file
|
|
51
|
+
* beside the World, each one sealed before bindings with the destination `current()` names for it (the root's or
|
|
52
|
+
* link's URL, as no request has changed it since) or none. Every later run binds from that sealed inventory, never
|
|
53
|
+
* from a root or link changed since. `ready` is false when the inventory could not be kept (the vault down): the host
|
|
54
|
+
* admits nothing until it is. */
|
|
55
|
+
export async function bindLegacyCredentials(worldRoot, current) {
|
|
56
|
+
const store = getActiveWorldStore();
|
|
57
|
+
const dir = dirname(credentialPath(worldRoot, 'x'));
|
|
58
|
+
const inventoryPath = join(dir, '.legacy-inventory.json');
|
|
59
|
+
const names = store.list(dir).filter((f) => /^[a-z0-9][a-z0-9_-]*\.json$/.test(f)).map((f) => f.replace(/\.json$/, ''));
|
|
60
|
+
if (names.length === 0)
|
|
61
|
+
return { bound: [], failed: [], refused: [], ready: true };
|
|
62
|
+
const sealedAt = (name) => { const text = store.read(credentialPath(worldRoot, name)); return text === null ? null : JSON.parse(text); };
|
|
63
|
+
// a snapshot from an earlier design names destinations no current code trusts: nothing is admitted until an operator
|
|
64
|
+
// has bound those credentials (or set them again) and removed it
|
|
65
|
+
if (store.read(join(dir, '.legacy-destinations.json')) !== null) {
|
|
66
|
+
console.error('[credential] an earlier destination snapshot is beside the World: an operator resolves it before it serves');
|
|
67
|
+
return { bound: [], failed: [], refused: [], ready: false };
|
|
68
|
+
}
|
|
69
|
+
let inventory = null;
|
|
70
|
+
const heldInventory = store.read(inventoryPath);
|
|
71
|
+
if (heldInventory !== null) {
|
|
72
|
+
// once taken, the inventory is the only authority: one that does not open (the vault down, or not this host's)
|
|
73
|
+
// admits nothing until it opens again, and is never taken again from roots a request may have changed
|
|
74
|
+
try {
|
|
75
|
+
inventory = (await currentSealer().open(JSON.parse(heldInventory), INVENTORY_SLOT));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error(`[credential] the inventory beside the World does not open (${error.message}): nothing is admitted until it does`);
|
|
79
|
+
return { bound: [], failed: [], refused: [], ready: false };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const keep = async (next) => {
|
|
83
|
+
try {
|
|
84
|
+
const sealed = await currentSealer().seal({ headers: {}, ...next }, new Date().toISOString(), INVENTORY_SLOT);
|
|
85
|
+
store.write(inventoryPath, `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error(`[credential] the inventory could not be kept (${error.message})`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
if (inventory === null) {
|
|
94
|
+
const destinations = current();
|
|
95
|
+
const pending = {};
|
|
96
|
+
for (const name of names)
|
|
97
|
+
if (sealedAt(name)?.bound === undefined)
|
|
98
|
+
pending[name] = destinations[name] ?? null;
|
|
99
|
+
inventory = { pending };
|
|
100
|
+
if (!(await keep(inventory)))
|
|
101
|
+
return { bound: [], failed: Object.keys(pending), refused: [], ready: false };
|
|
102
|
+
}
|
|
103
|
+
const bound = [];
|
|
104
|
+
const failed = [];
|
|
105
|
+
const refused = [];
|
|
106
|
+
let changed = false;
|
|
107
|
+
for (const [name, url] of Object.entries(inventory.pending)) {
|
|
108
|
+
const path = credentialPath(worldRoot, name);
|
|
109
|
+
const text = store.read(path);
|
|
110
|
+
const sealed = text === null ? null : JSON.parse(text);
|
|
111
|
+
// set again since (bound by its door), or removed: nothing left to bind
|
|
112
|
+
if (sealed === null || sealed.bound !== undefined) {
|
|
113
|
+
delete inventory.pending[name];
|
|
114
|
+
changed = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (url === null) {
|
|
118
|
+
refused.push(name);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const payload = await currentSealer().open(sealed, name);
|
|
123
|
+
const resealed = await currentSealer().seal(payload, sealed.placedAt, name, destinationOf(url));
|
|
124
|
+
if (store.read(path) !== text) {
|
|
125
|
+
failed.push(name);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
store.write(path, `${JSON.stringify({ ...resealed, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
|
|
129
|
+
delete inventory.pending[name];
|
|
130
|
+
changed = true;
|
|
131
|
+
bound.push(name);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.warn(`[credential] ${name}: not bound to its destination (${error.message})`);
|
|
135
|
+
failed.push(name);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (changed)
|
|
139
|
+
await keep(inventory);
|
|
140
|
+
// a record already bound but still under the host's own key (v1) moves to the vault, keeping its binding: after a
|
|
141
|
+
// wake every bound record is the vault's, and the deployment's key opens nothing more
|
|
142
|
+
if (sealsThroughVault()) {
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
const path = credentialPath(worldRoot, name);
|
|
145
|
+
const text = store.read(path);
|
|
146
|
+
if (text === null)
|
|
147
|
+
continue;
|
|
148
|
+
const sealed = JSON.parse(text);
|
|
149
|
+
if (sealed.v !== 1 || sealed.bound === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
const payload = await currentSealer().open(sealed, name);
|
|
153
|
+
const moved = await currentSealer().seal(payload, sealed.placedAt, name, sealed.bound);
|
|
154
|
+
if (store.read(path) !== text) {
|
|
155
|
+
failed.push(name);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
store.write(path, `${JSON.stringify({ ...moved, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
|
|
159
|
+
bound.push(name);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
console.warn(`[credential] ${name}: not moved to the vault (${error.message})`);
|
|
163
|
+
failed.push(name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { bound, failed, refused, ready: true };
|
|
168
|
+
}
|
|
38
169
|
export function sealedCredentialInfo(worldRoot, vendor) {
|
|
39
170
|
const text = getActiveWorldStore().read(credentialPath(worldRoot, vendor));
|
|
40
171
|
if (text === null)
|
|
41
172
|
return null;
|
|
42
173
|
const sealed = JSON.parse(text);
|
|
43
174
|
// what wraps it: a vault's transit key (v: 2) or a key the host holds (v: 1), so an operator sees what still needs one
|
|
44
|
-
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key' };
|
|
175
|
+
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key', ...(sealed.bound ? { boundTo: sealed.bound.replace(/^origin=/, '') } : {}) };
|
|
45
176
|
}
|
|
46
177
|
/** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
|
|
47
178
|
export async function twinSigningSecret(worldRoot, vendor) {
|
|
@@ -240,6 +240,21 @@ export declare class WorldDoors {
|
|
|
240
240
|
/** The world's twins, by vendor, once booted. */
|
|
241
241
|
twins(): string[];
|
|
242
242
|
private up;
|
|
243
|
+
/** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
|
|
244
|
+
* World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
|
|
245
|
+
* keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
|
|
246
|
+
* where it went. */
|
|
247
|
+
bindLegacyCredentials(layout?: {
|
|
248
|
+
services: Record<string, unknown>;
|
|
249
|
+
dirs: {
|
|
250
|
+
data: string;
|
|
251
|
+
};
|
|
252
|
+
}): Promise<{
|
|
253
|
+
bound: string[];
|
|
254
|
+
failed: string[];
|
|
255
|
+
refused: string[];
|
|
256
|
+
ready: boolean;
|
|
257
|
+
}>;
|
|
243
258
|
/** The world's token, presented the way the app's SDK presents a credential: the world's own header,
|
|
244
259
|
* the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
|
|
245
260
|
* GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
|
|
@@ -299,10 +314,11 @@ export declare class WorldDoors {
|
|
|
299
314
|
origin: string;
|
|
300
315
|
}>;
|
|
301
316
|
private link;
|
|
302
|
-
/** Forward to the link's origin: the path under the link, the caller's headers with
|
|
303
|
-
*
|
|
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
|
|
304
319
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
305
320
|
private forward;
|
|
321
|
+
private forwardSealed;
|
|
306
322
|
/** The instant of the twin's last completed refresh (its marker's `observedAt`), when it has a root. */
|
|
307
323
|
private observedAt;
|
|
308
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, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
|
|
29
|
+
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, 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';
|
|
@@ -36,7 +36,7 @@ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChange
|
|
|
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, setTwinRoot, spanMs } from "./root.js";
|
|
39
|
+
import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, 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). */
|
|
@@ -172,9 +172,18 @@ export async function mountWorld(name, opts = {}) {
|
|
|
172
172
|
let booted = null;
|
|
173
173
|
try {
|
|
174
174
|
booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
|
|
175
|
-
instance = booted;
|
|
176
175
|
// a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
|
|
177
176
|
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
177
|
+
// credentials sealed before bindings are bound to where they go before any request is let in (instance unset,
|
|
178
|
+
// the doors answer "booting") or any refresh is scheduled
|
|
179
|
+
const legacy = await world.bindLegacyCredentials(booted);
|
|
180
|
+
if (!legacy.ready)
|
|
181
|
+
throw new Error('the credentials beside this World could not be inventoried (is its vault reachable?)');
|
|
182
|
+
if (legacy.failed.length)
|
|
183
|
+
process.stderr.write(`credentials not bound to a destination yet (refused until bound): ${legacy.failed.join(', ')}\n`);
|
|
184
|
+
if (legacy.refused.length)
|
|
185
|
+
process.stderr.write(`credentials sealed before bindings with no root or link (refused until set again): ${legacy.refused.join(', ')}\n`);
|
|
186
|
+
instance = booted;
|
|
178
187
|
timers = await scheduleRefreshes(name, worldRoot, booted);
|
|
179
188
|
}
|
|
180
189
|
catch (error) {
|
|
@@ -269,7 +278,8 @@ export async function serveWorld(name, opts = {}) {
|
|
|
269
278
|
// doors answer 503.
|
|
270
279
|
let consoleOrigin = null;
|
|
271
280
|
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
|
|
272
|
-
|
|
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) });
|
|
273
283
|
const url = `http://${host}:${server.port}`;
|
|
274
284
|
try {
|
|
275
285
|
await mounted.boot(url);
|
|
@@ -589,6 +599,23 @@ export class WorldDoors {
|
|
|
589
599
|
/** The world's twins, by vendor, once booted. */
|
|
590
600
|
twins() { return Object.keys(this.up().services); }
|
|
591
601
|
up() { return this.host.layout(); }
|
|
602
|
+
/** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
|
|
603
|
+
* World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
|
|
604
|
+
* keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
|
|
605
|
+
* where it went. */
|
|
606
|
+
async bindLegacyCredentials(layout = this.up()) {
|
|
607
|
+
return bindLegacyCredentials(this.worldRoot, () => {
|
|
608
|
+
const destinations = {};
|
|
609
|
+
for (const [name, link] of Object.entries(this.links()))
|
|
610
|
+
destinations[name] = link.origin;
|
|
611
|
+
for (const vendor of Object.keys(layout.services)) {
|
|
612
|
+
const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
|
|
613
|
+
if (url)
|
|
614
|
+
destinations[vendor] = url;
|
|
615
|
+
}
|
|
616
|
+
return destinations;
|
|
617
|
+
});
|
|
618
|
+
}
|
|
592
619
|
/** The world's token, presented the way the app's SDK presents a credential: the world's own header,
|
|
593
620
|
* the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
|
|
594
621
|
* GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
|
|
@@ -824,7 +851,7 @@ export class WorldDoors {
|
|
|
824
851
|
return foreign;
|
|
825
852
|
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
826
853
|
// A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
|
|
827
|
-
// 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.
|
|
828
855
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
829
856
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
830
857
|
const link = this.link(vendor);
|
|
@@ -908,20 +935,49 @@ export class WorldDoors {
|
|
|
908
935
|
return held === null ? {} : JSON.parse(held);
|
|
909
936
|
}
|
|
910
937
|
link(name) { return this.links()[name] ?? null; }
|
|
911
|
-
/** Forward to the link's origin: the path under the link, the caller's headers with
|
|
912
|
-
*
|
|
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
|
|
913
940
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
914
941
|
async forward(request, url, rest, name, origin) {
|
|
915
|
-
|
|
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
|
|
954
|
+
const startedAt = Date.now();
|
|
955
|
+
let status = 500;
|
|
956
|
+
try {
|
|
957
|
+
const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
|
|
958
|
+
status = answer.status;
|
|
959
|
+
return answer;
|
|
960
|
+
}
|
|
961
|
+
finally {
|
|
962
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async forwardSealed(request, caller, url, rest, name, origin) {
|
|
966
|
+
const base = validateRemoteOrigin(origin);
|
|
967
|
+
// opened for this link's origin only: a credential sealed for another destination, or for none, refuses
|
|
968
|
+
let credential;
|
|
969
|
+
try {
|
|
970
|
+
credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin));
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
return Response.json({ error: error.message }, { status: 409 });
|
|
974
|
+
}
|
|
916
975
|
if (credential === null)
|
|
917
976
|
return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
918
|
-
const base = validateRemoteOrigin(origin);
|
|
919
977
|
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
920
978
|
if (target.origin !== base.origin)
|
|
921
979
|
return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
922
|
-
const headers = new Headers(
|
|
923
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
924
|
-
headers.delete(h);
|
|
980
|
+
const headers = new Headers(caller);
|
|
925
981
|
for (const [k, v] of Object.entries(credential.headers))
|
|
926
982
|
headers.set(k, v);
|
|
927
983
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
@@ -1267,11 +1323,11 @@ export class WorldDoors {
|
|
|
1267
1323
|
}
|
|
1268
1324
|
if (kind === 'requests' && !name && request.method === 'GET') {
|
|
1269
1325
|
// the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
|
|
1270
|
-
// requests by twin and status class, the response time's p50/p95, the top routes
|
|
1326
|
+
// requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
|
|
1271
1327
|
const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000;
|
|
1272
1328
|
const since = Date.now() - sinceMs;
|
|
1273
1329
|
const twins = [];
|
|
1274
|
-
for (const vendor of Object.keys(this.up().services)) {
|
|
1330
|
+
for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
|
|
1275
1331
|
const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
|
|
1276
1332
|
const byStatus = {};
|
|
1277
1333
|
const routes = new Map();
|
|
@@ -1314,7 +1370,12 @@ export class WorldDoors {
|
|
|
1314
1370
|
const text = await request.text();
|
|
1315
1371
|
if (text.length > MAX_BODY)
|
|
1316
1372
|
return Response.json({ error: 'too large' }, { status: 413 });
|
|
1317
|
-
|
|
1373
|
+
// sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
|
|
1374
|
+
// nowhere it may go, so nothing is sealed (set the root or the link first)
|
|
1375
|
+
const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
|
|
1376
|
+
if (!destination)
|
|
1377
|
+
return Response.json({ error: `the ${name} twin has no root and there is no ${name} link: set where its credential goes first` }, { status: 409 });
|
|
1378
|
+
const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
|
|
1318
1379
|
return Response.json({ twin: name, sealed: true, ...info });
|
|
1319
1380
|
}
|
|
1320
1381
|
if (verb === 'credential' && request.method === 'GET')
|
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,7 +6,7 @@
|
|
|
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';
|
|
9
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { stateDirName } from '@volter/world-core';
|
|
12
12
|
import { statusWorld } from "./runtime.js";
|
|
@@ -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. */
|
|
@@ -105,9 +114,11 @@ function sleep(ms, signal) {
|
|
|
105
114
|
export async function tailWorldActions(name, options = {}) {
|
|
106
115
|
const root = options.root ?? process.cwd();
|
|
107
116
|
const instance = statusWorld(name, root);
|
|
108
|
-
|
|
117
|
+
// a link (an opened seal) journals its requests as a twin does, under its own name
|
|
118
|
+
const links = linkNames(root);
|
|
119
|
+
const known = [...new Set([...Object.keys(instance.services), ...links])];
|
|
109
120
|
for (const service of options.services ?? []) {
|
|
110
|
-
if (!
|
|
121
|
+
if (!known.includes(service)) {
|
|
111
122
|
throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
|
|
112
123
|
}
|
|
113
124
|
}
|
|
@@ -143,6 +154,8 @@ export async function tailWorldActions(name, options = {}) {
|
|
|
143
154
|
};
|
|
144
155
|
emit(collect());
|
|
145
156
|
for (const service of services) {
|
|
157
|
+
if (links.includes(service) && !instance.services[service])
|
|
158
|
+
continue; // a link records requests, never actions
|
|
146
159
|
if (serviceLedgers(instance.dirs.data, service).length === 0) {
|
|
147
160
|
notice(`World ${name}: service "${service}" has no action ledger yet${follow
|
|
148
161
|
? ' — 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.32",
|
|
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.19",
|
|
71
|
+
"@volter/world-console": "2.0.11"
|
|
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.19",
|
|
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.19"
|
|
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/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, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
|
|
164
|
+
export { deployWorld, refreshTwin, adaptersFor, credentialPath, 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
|
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
14
14
|
import {
|
|
15
|
-
NO_SECRETS_CHECK, authStrategyFor, currentSealer, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, observeAppends, readRoot, registerStateSystem, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
|
|
15
|
+
NO_SECRETS_CHECK, authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, observeAppends, readRoot, registerStateSystem, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
|
|
16
16
|
} from '@volter/world-core';
|
|
17
17
|
import { loadWorldConfig, writeWorldConfig } from './configs.ts';
|
|
18
18
|
import { changesetReadiness, collectObservations, type Observation, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding, userKek, type DeployReport, type ObserveReport } from '@volter/world-core';
|
|
@@ -37,17 +37,121 @@ 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
|
-
|
|
41
|
-
|
|
40
|
+
/** Seal a vendor's credential beside the World, bound to `bound` (destinationOf the root's or link's origin): it opens
|
|
41
|
+
* for that destination only, so pointing the root or link elsewhere never sends it there. */
|
|
42
|
+
export async function sealTwinCredential(worldRoot: string, vendor: string, payload: CredentialPayload, at: string | undefined, bound: string): Promise<{ placedAt: string; fingerprint: string }> {
|
|
43
|
+
if (!/^origin=https?:\/\/[^/]+$/.test(bound)) throw new Error(`a credential is sealed for a destination (destinationOf its root's or link's origin), not ${JSON.stringify(bound)}`);
|
|
44
|
+
const sealed = await currentSealer().seal(payload, at ?? new Date().toISOString(), vendor, bound);
|
|
42
45
|
getActiveWorldStore().write(credentialPath(worldRoot, vendor), `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
|
|
43
46
|
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint };
|
|
44
47
|
}
|
|
45
|
-
|
|
48
|
+
/** The inventory of credentials sealed before bindings, kept sealed beside them (`.legacy-inventory.json`, its own
|
|
49
|
+
* slot): `pending` maps each such credential to the destination it is bound to, or null when it had none and so is
|
|
50
|
+
* refused until set again. Sealed, so no one who can write the World's files can plant one; kept once made, as the
|
|
51
|
+
* record that the first inventory happened. */
|
|
52
|
+
const INVENTORY_SLOT = 'credential-inventory';
|
|
53
|
+
type Inventory = { pending: Record<string, string | null> };
|
|
54
|
+
|
|
55
|
+
/** Bind each credential sealed before bindings to its destination, keeping its placedAt, fingerprint and rotatedAt,
|
|
56
|
+
* and only over the record that was read; and move every record still under the host's own key (v1) to the vault,
|
|
57
|
+
* keeping its binding. Run by the World's host as it wakes, before it serves or refreshes
|
|
58
|
+
* (WorldDoors.bindLegacyCredentials). At its first run on this code it takes the inventory: every credential file
|
|
59
|
+
* beside the World, each one sealed before bindings with the destination `current()` names for it (the root's or
|
|
60
|
+
* link's URL, as no request has changed it since) or none. Every later run binds from that sealed inventory, never
|
|
61
|
+
* from a root or link changed since. `ready` is false when the inventory could not be kept (the vault down): the host
|
|
62
|
+
* admits nothing until it is. */
|
|
63
|
+
export async function bindLegacyCredentials(worldRoot: string, current: () => Record<string, string>): Promise<{ bound: string[]; failed: string[]; refused: string[]; ready: boolean }> {
|
|
64
|
+
const store = getActiveWorldStore();
|
|
65
|
+
const dir = dirname(credentialPath(worldRoot, 'x'));
|
|
66
|
+
const inventoryPath = join(dir, '.legacy-inventory.json');
|
|
67
|
+
const names = store.list(dir).filter((f) => /^[a-z0-9][a-z0-9_-]*\.json$/.test(f)).map((f) => f.replace(/\.json$/, ''));
|
|
68
|
+
if (names.length === 0) return { bound: [], failed: [], refused: [], ready: true };
|
|
69
|
+
const sealedAt = (name: string): SealedCredential | null => { const text = store.read(credentialPath(worldRoot, name)); return text === null ? null : (JSON.parse(text) as SealedCredential); };
|
|
70
|
+
// a snapshot from an earlier design names destinations no current code trusts: nothing is admitted until an operator
|
|
71
|
+
// has bound those credentials (or set them again) and removed it
|
|
72
|
+
if (store.read(join(dir, '.legacy-destinations.json')) !== null) {
|
|
73
|
+
console.error('[credential] an earlier destination snapshot is beside the World: an operator resolves it before it serves');
|
|
74
|
+
return { bound: [], failed: [], refused: [], ready: false };
|
|
75
|
+
}
|
|
76
|
+
let inventory: Inventory | null = null;
|
|
77
|
+
const heldInventory = store.read(inventoryPath);
|
|
78
|
+
if (heldInventory !== null) {
|
|
79
|
+
// once taken, the inventory is the only authority: one that does not open (the vault down, or not this host's)
|
|
80
|
+
// admits nothing until it opens again, and is never taken again from roots a request may have changed
|
|
81
|
+
try { inventory = (await currentSealer().open(JSON.parse(heldInventory) as SealedCredential, INVENTORY_SLOT)) as unknown as Inventory; }
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.error(`[credential] the inventory beside the World does not open (${(error as Error).message}): nothing is admitted until it does`);
|
|
84
|
+
return { bound: [], failed: [], refused: [], ready: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const keep = async (next: Inventory): Promise<boolean> => {
|
|
88
|
+
try {
|
|
89
|
+
const sealed = await currentSealer().seal({ headers: {}, ...next } as unknown as CredentialPayload, new Date().toISOString(), INVENTORY_SLOT);
|
|
90
|
+
store.write(inventoryPath, `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(`[credential] the inventory could not be kept (${(error as Error).message})`);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
if (inventory === null) {
|
|
98
|
+
const destinations = current();
|
|
99
|
+
const pending: Record<string, string | null> = {};
|
|
100
|
+
for (const name of names) if (sealedAt(name)?.bound === undefined) pending[name] = destinations[name] ?? null;
|
|
101
|
+
inventory = { pending };
|
|
102
|
+
if (!(await keep(inventory))) return { bound: [], failed: Object.keys(pending), refused: [], ready: false };
|
|
103
|
+
}
|
|
104
|
+
const bound: string[] = []; const failed: string[] = []; const refused: string[] = [];
|
|
105
|
+
let changed = false;
|
|
106
|
+
for (const [name, url] of Object.entries(inventory.pending)) {
|
|
107
|
+
const path = credentialPath(worldRoot, name);
|
|
108
|
+
const text = store.read(path);
|
|
109
|
+
const sealed = text === null ? null : (JSON.parse(text) as SealedCredential);
|
|
110
|
+
// set again since (bound by its door), or removed: nothing left to bind
|
|
111
|
+
if (sealed === null || sealed.bound !== undefined) { delete inventory.pending[name]; changed = true; continue; }
|
|
112
|
+
if (url === null) { refused.push(name); continue; }
|
|
113
|
+
try {
|
|
114
|
+
const payload = await currentSealer().open(sealed, name);
|
|
115
|
+
const resealed = await currentSealer().seal(payload, sealed.placedAt, name, destinationOf(url));
|
|
116
|
+
if (store.read(path) !== text) { failed.push(name); continue; }
|
|
117
|
+
store.write(path, `${JSON.stringify({ ...resealed, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
|
|
118
|
+
delete inventory.pending[name]; changed = true;
|
|
119
|
+
bound.push(name);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.warn(`[credential] ${name}: not bound to its destination (${(error as Error).message})`);
|
|
122
|
+
failed.push(name);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (changed) await keep(inventory);
|
|
126
|
+
// a record already bound but still under the host's own key (v1) moves to the vault, keeping its binding: after a
|
|
127
|
+
// wake every bound record is the vault's, and the deployment's key opens nothing more
|
|
128
|
+
if (sealsThroughVault()) {
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
const path = credentialPath(worldRoot, name);
|
|
131
|
+
const text = store.read(path);
|
|
132
|
+
if (text === null) continue;
|
|
133
|
+
const sealed = JSON.parse(text) as SealedCredential;
|
|
134
|
+
if (sealed.v !== 1 || sealed.bound === undefined) continue;
|
|
135
|
+
try {
|
|
136
|
+
const payload = await currentSealer().open(sealed, name);
|
|
137
|
+
const moved = await currentSealer().seal(payload, sealed.placedAt, name, sealed.bound);
|
|
138
|
+
if (store.read(path) !== text) { failed.push(name); continue; }
|
|
139
|
+
store.write(path, `${JSON.stringify({ ...moved, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
|
|
140
|
+
bound.push(name);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.warn(`[credential] ${name}: not moved to the vault (${(error as Error).message})`);
|
|
143
|
+
failed.push(name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { bound, failed, refused, ready: true };
|
|
148
|
+
}
|
|
149
|
+
export function sealedCredentialInfo(worldRoot: string, vendor: string): { placedAt: string; fingerprint: string; sealedBy: 'vault' | 'key'; boundTo?: string } | null {
|
|
46
150
|
const text = getActiveWorldStore().read(credentialPath(worldRoot, vendor));
|
|
47
151
|
if (text === null) return null;
|
|
48
152
|
const sealed = JSON.parse(text) as SealedCredential;
|
|
49
153
|
// what wraps it: a vault's transit key (v: 2) or a key the host holds (v: 1), so an operator sees what still needs one
|
|
50
|
-
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key' };
|
|
154
|
+
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint, sealedBy: sealed.v === 2 ? 'vault' : 'key', ...(sealed.bound ? { boundTo: sealed.bound.replace(/^origin=/, '') } : {}) };
|
|
51
155
|
}
|
|
52
156
|
/** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
|
|
53
157
|
export async function twinSigningSecret(worldRoot: string, vendor: string): Promise<string | null> {
|
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, 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, 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';
|
|
@@ -39,7 +39,7 @@ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChange
|
|
|
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, setTwinRoot, spanMs } from './root.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';
|
|
43
43
|
import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from './runtime.ts';
|
|
44
44
|
import type { WorldInstance } from './schema.ts';
|
|
45
45
|
|
|
@@ -177,9 +177,15 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
177
177
|
let booted: WorldInstance | null = null;
|
|
178
178
|
try {
|
|
179
179
|
booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
|
|
180
|
-
instance = booted;
|
|
181
180
|
// a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
|
|
182
181
|
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
182
|
+
// credentials sealed before bindings are bound to where they go before any request is let in (instance unset,
|
|
183
|
+
// the doors answer "booting") or any refresh is scheduled
|
|
184
|
+
const legacy = await world.bindLegacyCredentials(booted);
|
|
185
|
+
if (!legacy.ready) throw new Error('the credentials beside this World could not be inventoried (is its vault reachable?)');
|
|
186
|
+
if (legacy.failed.length) process.stderr.write(`credentials not bound to a destination yet (refused until bound): ${legacy.failed.join(', ')}\n`);
|
|
187
|
+
if (legacy.refused.length) process.stderr.write(`credentials sealed before bindings with no root or link (refused until set again): ${legacy.refused.join(', ')}\n`);
|
|
188
|
+
instance = booted;
|
|
183
189
|
timers = await scheduleRefreshes(name, worldRoot, booted);
|
|
184
190
|
} catch (error) {
|
|
185
191
|
for (const t of timers) clearInterval(t);
|
|
@@ -246,7 +252,8 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
|
|
|
246
252
|
// doors answer 503.
|
|
247
253
|
let consoleOrigin: string | null = null;
|
|
248
254
|
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
|
|
249
|
-
|
|
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) });
|
|
250
257
|
const url = `http://${host}:${server.port}`;
|
|
251
258
|
try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
|
|
252
259
|
// the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
|
|
@@ -534,6 +541,22 @@ export class WorldDoors {
|
|
|
534
541
|
twins(): string[] { return Object.keys(this.up().services); }
|
|
535
542
|
private up(): WorldLayout { return this.host.layout(); }
|
|
536
543
|
|
|
544
|
+
/** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
|
|
545
|
+
* World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
|
|
546
|
+
* keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
|
|
547
|
+
* where it went. */
|
|
548
|
+
async bindLegacyCredentials(layout: { services: Record<string, unknown>; dirs: { data: string } } = this.up()): Promise<{ bound: string[]; failed: string[]; refused: string[]; ready: boolean }> {
|
|
549
|
+
return bindLegacyCredentials(this.worldRoot, () => {
|
|
550
|
+
const destinations: Record<string, string> = {};
|
|
551
|
+
for (const [name, link] of Object.entries(this.links())) destinations[name] = link.origin;
|
|
552
|
+
for (const vendor of Object.keys(layout.services)) {
|
|
553
|
+
const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
|
|
554
|
+
if (url) destinations[vendor] = url;
|
|
555
|
+
}
|
|
556
|
+
return destinations;
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
537
560
|
/** The world's token, presented the way the app's SDK presents a credential: the world's own header,
|
|
538
561
|
* the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
|
|
539
562
|
* GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
|
|
@@ -743,7 +766,7 @@ export class WorldDoors {
|
|
|
743
766
|
if (foreign) return foreign;
|
|
744
767
|
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
745
768
|
// A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
|
|
746
|
-
// 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.
|
|
747
770
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
748
771
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
749
772
|
const link = this.link(vendor!);
|
|
@@ -813,17 +836,39 @@ export class WorldDoors {
|
|
|
813
836
|
}
|
|
814
837
|
private link(name: string): { origin: string } | null { return this.links()[name] ?? null; }
|
|
815
838
|
|
|
816
|
-
/** Forward to the link's origin: the path under the link, the caller's headers with
|
|
817
|
-
*
|
|
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
|
|
818
841
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
819
842
|
private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
|
|
820
|
-
|
|
821
|
-
|
|
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
|
|
853
|
+
const startedAt = Date.now(); let status = 500;
|
|
854
|
+
try {
|
|
855
|
+
const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
|
|
856
|
+
status = answer.status;
|
|
857
|
+
return answer;
|
|
858
|
+
} finally {
|
|
859
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private async forwardSealed(request: Request, caller: Headers, url: URL, rest: string, name: string, origin: string): Promise<Response> {
|
|
822
864
|
const base = validateRemoteOrigin(origin);
|
|
865
|
+
// opened for this link's origin only: a credential sealed for another destination, or for none, refuses
|
|
866
|
+
let credential: Awaited<ReturnType<typeof openSealedAt>>;
|
|
867
|
+
try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 409 }); }
|
|
868
|
+
if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
823
869
|
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
824
870
|
if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
825
|
-
const headers = new Headers(
|
|
826
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) headers.delete(h);
|
|
871
|
+
const headers = new Headers(caller);
|
|
827
872
|
for (const [k, v] of Object.entries(credential.headers)) headers.set(k, v);
|
|
828
873
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
829
874
|
const controller = new AbortController();
|
|
@@ -1096,10 +1141,10 @@ export class WorldDoors {
|
|
|
1096
1141
|
}
|
|
1097
1142
|
if (kind === 'requests' && !name && request.method === 'GET') {
|
|
1098
1143
|
// the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
|
|
1099
|
-
// requests by twin and status class, the response time's p50/p95, the top routes
|
|
1144
|
+
// requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
|
|
1100
1145
|
const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000; const since = Date.now() - sinceMs;
|
|
1101
1146
|
const twins: Array<{ twin: string; requests: number; byStatus: Record<string, number>; p50Ms: number | null; p95Ms: number | null; topRoutes: Array<{ route: string; requests: number }> }> = [];
|
|
1102
|
-
for (const vendor of Object.keys(this.up().services)) {
|
|
1147
|
+
for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
|
|
1103
1148
|
const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
|
|
1104
1149
|
const byStatus: Record<string, number> = {}; const routes = new Map<string, number>(); const times: number[] = [];
|
|
1105
1150
|
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); }
|
|
@@ -1129,7 +1174,11 @@ export class WorldDoors {
|
|
|
1129
1174
|
if (verb === 'credential' && request.method === 'PUT') {
|
|
1130
1175
|
const text = await request.text();
|
|
1131
1176
|
if (text.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
|
|
1132
|
-
|
|
1177
|
+
// sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
|
|
1178
|
+
// nowhere it may go, so nothing is sealed (set the root or the link first)
|
|
1179
|
+
const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
|
|
1180
|
+
if (!destination) return Response.json({ error: `the ${name} twin has no root and there is no ${name} link: set where its credential goes first` }, { status: 409 });
|
|
1181
|
+
const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
|
|
1133
1182
|
return Response.json({ twin: name, sealed: true, ...info });
|
|
1134
1183
|
}
|
|
1135
1184
|
if (verb === 'credential' && request.method === 'GET') return Response.json({ twin: name, credential: sealedCredentialInfo(this.worldRoot, name) });
|
package/src/tail.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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';
|
|
9
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { stateDirName } from '@volter/world-core';
|
|
12
12
|
import { statusWorld } from './runtime.ts';
|
|
@@ -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. */
|
|
@@ -150,9 +155,11 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
150
155
|
export async function tailWorldActions(name: string, options: TailWorldOptions = {}): Promise<void> {
|
|
151
156
|
const root = options.root ?? process.cwd();
|
|
152
157
|
const instance = statusWorld(name, root);
|
|
153
|
-
|
|
158
|
+
// a link (an opened seal) journals its requests as a twin does, under its own name
|
|
159
|
+
const links = linkNames(root);
|
|
160
|
+
const known = [...new Set([...Object.keys(instance.services), ...links])];
|
|
154
161
|
for (const service of options.services ?? []) {
|
|
155
|
-
if (!
|
|
162
|
+
if (!known.includes(service)) {
|
|
156
163
|
throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
|
|
157
164
|
}
|
|
158
165
|
}
|
|
@@ -189,6 +196,7 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
|
|
|
189
196
|
|
|
190
197
|
emit(collect());
|
|
191
198
|
for (const service of services) {
|
|
199
|
+
if (links.includes(service) && !instance.services[service]) continue; // a link records requests, never actions
|
|
192
200
|
if (serviceLedgers(instance.dirs.data, service).length === 0) {
|
|
193
201
|
notice(`World ${name}: service "${service}" has no action ledger yet${follow
|
|
194
202
|
? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
|