@volter/world-runtime 2.0.30 → 2.0.31
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/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 +15 -0
- package/dist/src/served-world.js +44 -6
- package/package.json +4 -4
- package/src/index.ts +1 -1
- package/src/root.ts +109 -5
- package/src/served-world.ts +34 -6
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). */
|
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, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, 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) {
|
|
@@ -589,6 +598,23 @@ export class WorldDoors {
|
|
|
589
598
|
/** The world's twins, by vendor, once booted. */
|
|
590
599
|
twins() { return Object.keys(this.up().services); }
|
|
591
600
|
up() { return this.host.layout(); }
|
|
601
|
+
/** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
|
|
602
|
+
* World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
|
|
603
|
+
* keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
|
|
604
|
+
* where it went. */
|
|
605
|
+
async bindLegacyCredentials(layout = this.up()) {
|
|
606
|
+
return bindLegacyCredentials(this.worldRoot, () => {
|
|
607
|
+
const destinations = {};
|
|
608
|
+
for (const [name, link] of Object.entries(this.links()))
|
|
609
|
+
destinations[name] = link.origin;
|
|
610
|
+
for (const vendor of Object.keys(layout.services)) {
|
|
611
|
+
const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
|
|
612
|
+
if (url)
|
|
613
|
+
destinations[vendor] = url;
|
|
614
|
+
}
|
|
615
|
+
return destinations;
|
|
616
|
+
});
|
|
617
|
+
}
|
|
592
618
|
/** The world's token, presented the way the app's SDK presents a credential: the world's own header,
|
|
593
619
|
* the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
|
|
594
620
|
* GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
|
|
@@ -912,10 +938,17 @@ export class WorldDoors {
|
|
|
912
938
|
* token removed and the sealed credential's headers replacing any of the same name. The vendor's
|
|
913
939
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
914
940
|
async forward(request, url, rest, name, origin) {
|
|
915
|
-
const
|
|
941
|
+
const base = validateRemoteOrigin(origin);
|
|
942
|
+
// opened for this link's origin only: a credential sealed for another destination, or for none, refuses
|
|
943
|
+
let credential;
|
|
944
|
+
try {
|
|
945
|
+
credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin));
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
return Response.json({ error: error.message }, { status: 409 });
|
|
949
|
+
}
|
|
916
950
|
if (credential === null)
|
|
917
951
|
return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
918
|
-
const base = validateRemoteOrigin(origin);
|
|
919
952
|
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
920
953
|
if (target.origin !== base.origin)
|
|
921
954
|
return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
@@ -1314,7 +1347,12 @@ export class WorldDoors {
|
|
|
1314
1347
|
const text = await request.text();
|
|
1315
1348
|
if (text.length > MAX_BODY)
|
|
1316
1349
|
return Response.json({ error: 'too large' }, { status: 413 });
|
|
1317
|
-
|
|
1350
|
+
// sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
|
|
1351
|
+
// nowhere it may go, so nothing is sealed (set the root or the link first)
|
|
1352
|
+
const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
|
|
1353
|
+
if (!destination)
|
|
1354
|
+
return Response.json({ error: `the ${name} twin has no root and there is no ${name} link: set where its credential goes first` }, { status: 409 });
|
|
1355
|
+
const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
|
|
1318
1356
|
return Response.json({ twin: name, sealed: true, ...info });
|
|
1319
1357
|
}
|
|
1320
1358
|
if (verb === 'credential' && request.method === 'GET')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.31",
|
|
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.
|
|
70
|
+
"@volter/world-core": "2.0.18",
|
|
71
71
|
"@volter/world-console": "2.0.10"
|
|
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.18",
|
|
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.18"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/index.ts
CHANGED
|
@@ -161,7 +161,7 @@ export type { FetchOptions, FetchOutcome } from './origin.ts';
|
|
|
161
161
|
|
|
162
162
|
export { branchWorld, checkoutWorld } from './branch.ts';
|
|
163
163
|
export type { BranchOptions } from './branch.ts';
|
|
164
|
-
export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, 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, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, 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);
|
|
@@ -534,6 +540,22 @@ export class WorldDoors {
|
|
|
534
540
|
twins(): string[] { return Object.keys(this.up().services); }
|
|
535
541
|
private up(): WorldLayout { return this.host.layout(); }
|
|
536
542
|
|
|
543
|
+
/** Bind each credential sealed before bindings to where it goes now (its twin's root, else its link): run as the
|
|
544
|
+
* World wakes on code that binds, before it serves or refreshes anything (decision 0034; architecture, "Data and
|
|
545
|
+
* keys"). Nothing has changed a root or link since the code that did not bind stopped, so where it goes now is
|
|
546
|
+
* where it went. */
|
|
547
|
+
async bindLegacyCredentials(layout: { services: Record<string, unknown>; dirs: { data: string } } = this.up()): Promise<{ bound: string[]; failed: string[]; refused: string[]; ready: boolean }> {
|
|
548
|
+
return bindLegacyCredentials(this.worldRoot, () => {
|
|
549
|
+
const destinations: Record<string, string> = {};
|
|
550
|
+
for (const [name, link] of Object.entries(this.links())) destinations[name] = link.origin;
|
|
551
|
+
for (const vendor of Object.keys(layout.services)) {
|
|
552
|
+
const url = rootForControlRoot(join(layout.dirs.data, vendor), vendor)?.url;
|
|
553
|
+
if (url) destinations[vendor] = url;
|
|
554
|
+
}
|
|
555
|
+
return destinations;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
537
559
|
/** The world's token, presented the way the app's SDK presents a credential: the world's own header,
|
|
538
560
|
* the injector's `x-twins-key` (a zero-edit app keeps its SDK's own Authorization), `Bearer <token>`,
|
|
539
561
|
* GitHub's `token <token>`, or the password half of `Basic` (Jira's email:token). */
|
|
@@ -817,9 +839,11 @@ export class WorldDoors {
|
|
|
817
839
|
* token removed and the sealed credential's headers replacing any of the same name. The vendor's
|
|
818
840
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
819
841
|
private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
|
|
820
|
-
const credential = await openSealedAt(credentialPath(this.worldRoot, name), name);
|
|
821
|
-
if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
822
842
|
const base = validateRemoteOrigin(origin);
|
|
843
|
+
// opened for this link's origin only: a credential sealed for another destination, or for none, refuses
|
|
844
|
+
let credential: Awaited<ReturnType<typeof openSealedAt>>;
|
|
845
|
+
try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 409 }); }
|
|
846
|
+
if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
823
847
|
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
824
848
|
if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
825
849
|
const headers = new Headers(request.headers);
|
|
@@ -1129,7 +1153,11 @@ export class WorldDoors {
|
|
|
1129
1153
|
if (verb === 'credential' && request.method === 'PUT') {
|
|
1130
1154
|
const text = await request.text();
|
|
1131
1155
|
if (text.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
|
|
1132
|
-
|
|
1156
|
+
// sealed for the one destination it serves: the twin's root, else the link of that name; with neither there is
|
|
1157
|
+
// nowhere it may go, so nothing is sealed (set the root or the link first)
|
|
1158
|
+
const destination = rootForControlRoot(this.controlRoot(name), name)?.url ?? this.link(name)?.origin;
|
|
1159
|
+
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 });
|
|
1160
|
+
const info = await sealTwinCredential(this.worldRoot, name, credentialPayloadFrom(text, name), undefined, destinationOf(destination));
|
|
1133
1161
|
return Response.json({ twin: name, sealed: true, ...info });
|
|
1134
1162
|
}
|
|
1135
1163
|
if (verb === 'credential' && request.method === 'GET') return Response.json({ twin: name, credential: sealedCredentialInfo(this.worldRoot, name) });
|