@volter/world-runtime 2.0.39 → 2.0.41
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/branch.js +5 -3
- package/dist/src/origin.js +15 -0
- package/dist/src/runtime.js +26 -0
- package/dist/src/served-world.d.ts +12 -0
- package/dist/src/served-world.js +94 -7
- package/package.json +4 -4
- package/src/branch.ts +3 -1
- package/src/origin.ts +11 -0
- package/src/runtime.ts +21 -0
- package/src/served-world.ts +74 -7
package/dist/src/branch.js
CHANGED
|
@@ -84,10 +84,12 @@ export async function branchWorld(base, name, options = {}) {
|
|
|
84
84
|
}
|
|
85
85
|
const forked = {};
|
|
86
86
|
try {
|
|
87
|
-
|
|
87
|
+
// the branch's origin is its base's, or none: never one a stopped World of the same name left
|
|
88
|
+
if (from.origin)
|
|
88
89
|
instance.origin = { ...from.origin };
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
else
|
|
91
|
+
delete instance.origin;
|
|
92
|
+
saveWorldInstance(instance);
|
|
91
93
|
for (const { service, fromRoot, states } of source) {
|
|
92
94
|
const toRoot = join(instance.dirs.data, service);
|
|
93
95
|
for (const { state, generation, view, position: pos } of states) {
|
package/dist/src/origin.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join, resolve } from 'node:path';
|
|
|
8
8
|
import { getActiveWorldStore, readBranchMeta, withAncestryLock, writeBranchMeta } from '@volter/world-core';
|
|
9
9
|
import { saveWorldInstance, statusWorld } from "./runtime.js";
|
|
10
10
|
import { fetchReady, isPathRemote, localWorld, TOKEN_HEADER } from "./served-world.js";
|
|
11
|
+
import { loadWorldConfig, writeWorldConfig } from "./configs.js";
|
|
11
12
|
function originOf(instance, options) {
|
|
12
13
|
const url = options.url ?? instance.origin?.url;
|
|
13
14
|
const namespace = options.namespace ?? instance.origin?.namespace;
|
|
@@ -132,6 +133,20 @@ export async function fetchFromOrigin(name, options) {
|
|
|
132
133
|
});
|
|
133
134
|
}
|
|
134
135
|
saveWorldInstance({ ...instance, origin: { ...origin, ...(options.full ? { clonedAt: at_ } : instance.origin?.clonedAt ? { clonedAt: instance.origin.clonedAt } : {}), fetchedAt: at_ } });
|
|
136
|
+
// a clone names its remote where `volter remote add` does (world.json, as git config holds a remote): one record of
|
|
137
|
+
// it, which every restart carries (runtime's namesOrigin) and `remote remove` ends. Only the World the config is
|
|
138
|
+
// for writes it: a runtime branch boots from its base's config, whose remote stays the base's
|
|
139
|
+
if (options.full && instance.name === instance.config) {
|
|
140
|
+
try {
|
|
141
|
+
const { path: configPath, config } = loadWorldConfig(instance.configPath, root);
|
|
142
|
+
const named = isPathRemote(origin.url) ? origin.url : `${origin.url}/${origin.namespace}`;
|
|
143
|
+
if (config.remotes?.origin !== named)
|
|
144
|
+
writeWorldConfig(configPath, { ...config, remotes: { ...(config.remotes ?? {}), origin: named } });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.error(`[origin] ${name}: the remote was not named in its config: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
135
150
|
return { world: name, origin, full: options.full ?? false, appended, behind, snapshots };
|
|
136
151
|
}
|
|
137
152
|
export function worldOrigin(name, root) {
|
package/dist/src/runtime.js
CHANGED
|
@@ -728,6 +728,22 @@ function writePidsFromInstance(instance, fresh = []) {
|
|
|
728
728
|
].filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
729
729
|
writeOwnedPids(instance.root, instance.name, pids, fresh);
|
|
730
730
|
}
|
|
731
|
+
/** Whether a world.json remote (`https://<host>/<org>/<world>`, or a world's directory, relative to the World's root) is
|
|
732
|
+
* the recorded origin, read as the CLI's parseOriginUrl reads it (the URL's origin, then its two path parts). */
|
|
733
|
+
function namesOrigin(remote, origin, worldRoot) {
|
|
734
|
+
if (!remote)
|
|
735
|
+
return false;
|
|
736
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(remote))
|
|
737
|
+
return resolve(worldRoot, remote) === resolve(worldRoot, origin.url);
|
|
738
|
+
try {
|
|
739
|
+
const parsed = new URL(remote);
|
|
740
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
741
|
+
return parts.length === 2 && parsed.origin === new URL(origin.url).origin && parts.join('/') === origin.namespace;
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
731
747
|
export function saveWorldInstance(instance) {
|
|
732
748
|
writeTextAtomic(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
|
|
733
749
|
}
|
|
@@ -1483,6 +1499,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1483
1499
|
const pidsFile = join(instance, 'pids');
|
|
1484
1500
|
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
1485
1501
|
const instanceFile = join(instance, 'instance.json');
|
|
1502
|
+
let carriedOrigin;
|
|
1486
1503
|
const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) : undefined;
|
|
1487
1504
|
const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
|
|
1488
1505
|
? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
|
|
@@ -1536,6 +1553,14 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1536
1553
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
1537
1554
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
1538
1555
|
}
|
|
1556
|
+
// the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
|
|
1557
|
+
// same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
|
|
1558
|
+
// ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
|
|
1559
|
+
// only when its state (the mirror they count) is kept, as reset drops them
|
|
1560
|
+
if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
|
|
1561
|
+
carriedOrigin = options.keepState ? prior.origin
|
|
1562
|
+
: { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
|
|
1563
|
+
}
|
|
1539
1564
|
}
|
|
1540
1565
|
assertLifecycleStartable(root, name);
|
|
1541
1566
|
beginWorldLifecycle(root, name, createdAt, options.owner);
|
|
@@ -1643,6 +1668,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1643
1668
|
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
1644
1669
|
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
1645
1670
|
lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
|
|
1671
|
+
...(carriedOrigin ? { origin: carriedOrigin } : {}),
|
|
1646
1672
|
};
|
|
1647
1673
|
saveWorldInstance(worldInstance);
|
|
1648
1674
|
const cancelled = new AbortController();
|
|
@@ -268,6 +268,14 @@ export declare class WorldDoors {
|
|
|
268
268
|
/** What this World issued, for world-access to decide a grant against: its two tokens, its named keys, its sessions. */
|
|
269
269
|
private issued;
|
|
270
270
|
private credential;
|
|
271
|
+
/** Each changeset's actions' receipts, from the landed copies on each twin's log: a deploy appends one per entry it
|
|
272
|
+
* performed, and a push one per entry it landed at the remote (`landed` while the remote waits, beside the
|
|
273
|
+
* changeset's own `applied`). */
|
|
274
|
+
private deployedOf;
|
|
275
|
+
/** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
|
|
276
|
+
* (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
|
|
277
|
+
* this World issued. */
|
|
278
|
+
private callerOf;
|
|
271
279
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
272
280
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
273
281
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -352,6 +360,10 @@ export declare class WorldDoors {
|
|
|
352
360
|
* twin, then position. The World keeps no order across twins finer than its clock, and this claims
|
|
353
361
|
* none. `before` is the cursor the previous page answered; `twin` and `trace` narrow it. */
|
|
354
362
|
private timeline;
|
|
363
|
+
/** Each twin's callers by the World's span, from its request journal: its newest CALLER_SEGMENTS once, then from
|
|
364
|
+
* where the last read stopped (readTwinRequestJournalFrom follows a rotation). The newest CALLERS_KEPT are kept. */
|
|
365
|
+
private readonly callersByTwin;
|
|
366
|
+
private callersOf;
|
|
355
367
|
/** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
|
|
356
368
|
* (a twin is read again only when the size or time of its parent log, branch log or branch record
|
|
357
369
|
* moved), and the merged order is sorted again only when a twin was. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
29
|
-
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
|
|
29
|
+
import { CALLER_HEADER, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from "./init.js";
|
|
31
31
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
@@ -369,6 +369,11 @@ function traceIdOf(traceparent) {
|
|
|
369
369
|
const m = typeof traceparent === 'string' ? /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/.exec(traceparent) : null;
|
|
370
370
|
return m && !/^0+$/.test(m[1]) ? m[1] : null;
|
|
371
371
|
}
|
|
372
|
+
const CALLERS_KEPT = 50_000;
|
|
373
|
+
/** The closed journal segments a World reads for callers when it first answers a timeline (~1 MB each): older entries show no caller. */
|
|
374
|
+
const CALLER_SEGMENTS = 8;
|
|
375
|
+
/** A W3C traceparent's parent-id (the span the World minted for the request), or null. */
|
|
376
|
+
function spanOf(traceparent) { return parseTraceparent(traceparent)?.parentId ?? null; }
|
|
372
377
|
/** Timeline order, newest first: by the entry's instant, then twin, then position. */
|
|
373
378
|
function newerFirst(a, b) {
|
|
374
379
|
return b.at.localeCompare(a.at) || b.twin.localeCompare(a.twin) || b.position - a.position;
|
|
@@ -653,6 +658,49 @@ export class WorldDoors {
|
|
|
653
658
|
return { scope: grant.scope, via: 'session', token: null, session: grant.session, ...(grant.who ? { who: grant.who } : {}) };
|
|
654
659
|
return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
|
|
655
660
|
}
|
|
661
|
+
/** Each changeset's actions' receipts, from the landed copies on each twin's log: a deploy appends one per entry it
|
|
662
|
+
* performed, and a push one per entry it landed at the remote (`landed` while the remote waits, beside the
|
|
663
|
+
* changeset's own `applied`). */
|
|
664
|
+
deployedOf(changesets) {
|
|
665
|
+
// by the state each action was written in (a twin may hold several), and a settled receipt wins over a provisional
|
|
666
|
+
// `landed` one whichever comes later in the log (a push's local copy, then the remote's deploy fetched in)
|
|
667
|
+
const landed = new Map();
|
|
668
|
+
const of = (service, state) => {
|
|
669
|
+
const at = `${service}\u0000${state}`;
|
|
670
|
+
let held = landed.get(at);
|
|
671
|
+
if (!held) {
|
|
672
|
+
held = new Map();
|
|
673
|
+
if (this.up().services[service]) {
|
|
674
|
+
for (const e of parentEntries(state, this.controlRoot(service))) {
|
|
675
|
+
if (!e.landsId || !e.receipt)
|
|
676
|
+
continue;
|
|
677
|
+
const before = held.get(e.landsId);
|
|
678
|
+
if (!before || before.status === 'landed' || e.receipt.status !== 'landed')
|
|
679
|
+
held.set(e.landsId, e.receipt);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
landed.set(at, held);
|
|
683
|
+
}
|
|
684
|
+
return held;
|
|
685
|
+
};
|
|
686
|
+
return changesets.map((c) => c.actions.flatMap((a) => { const r = of(a.service, a.stateService ?? this.stateOf(a.service)).get(a.action.id); return r ? [{ service: a.service, actionId: a.action.id, ...r }] : []; }));
|
|
687
|
+
}
|
|
688
|
+
/** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
|
|
689
|
+
* (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
|
|
690
|
+
* this World issued. */
|
|
691
|
+
callerOf(request) {
|
|
692
|
+
const cred = this.credential(request);
|
|
693
|
+
if (cred.scope === null)
|
|
694
|
+
return null;
|
|
695
|
+
if (cred.via === 'session')
|
|
696
|
+
return cred.who ? `person:${cred.who}` : 'person';
|
|
697
|
+
if (cred.via === 'key') {
|
|
698
|
+
const token = cred.token;
|
|
699
|
+
const held = token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined;
|
|
700
|
+
return held ? `key:${held.name}` : 'key';
|
|
701
|
+
}
|
|
702
|
+
return cred.scope === 'read' ? 'token:read' : 'token';
|
|
703
|
+
}
|
|
656
704
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
657
705
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
658
706
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -872,6 +920,14 @@ export class WorldDoors {
|
|
|
872
920
|
headers.delete(TOKEN_HEADER);
|
|
873
921
|
headers.delete(TWINS_KEY_HEADER);
|
|
874
922
|
headers.delete('host');
|
|
923
|
+
// who made it is the World's word, never the caller's: the twin's request journal records it. The World is a hop of
|
|
924
|
+
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
925
|
+
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
926
|
+
headers.delete(CALLER_HEADER);
|
|
927
|
+
const who = this.callerOf(request);
|
|
928
|
+
if (who)
|
|
929
|
+
headers.set(CALLER_HEADER, who);
|
|
930
|
+
headers.set('traceparent', traceparentForDelivery(headers.get('traceparent')) ?? newTraceparent());
|
|
875
931
|
// at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
|
|
876
932
|
// that would write for its caller is refused too); at any other, its GET passes as it always has
|
|
877
933
|
if (enforced)
|
|
@@ -942,7 +998,7 @@ export class WorldDoors {
|
|
|
942
998
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
943
999
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
944
1000
|
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'])
|
|
1001
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
946
1002
|
caller.delete(h);
|
|
947
1003
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
948
1004
|
const authorization = caller.get('authorization');
|
|
@@ -953,7 +1009,10 @@ export class WorldDoors {
|
|
|
953
1009
|
// name, with the credential shape of what the caller sent on. As an audit device does, the request is
|
|
954
1010
|
// written before the real credential is used, and a request whose audit cannot be written is refused.
|
|
955
1011
|
const credentials = twinRequestCredentials(caller, url);
|
|
956
|
-
|
|
1012
|
+
const who = this.callerOf(request);
|
|
1013
|
+
const trace = parseTraceparent(request.headers.get('traceparent'))?.traceId;
|
|
1014
|
+
const by = { ...(who ? { caller: who } : {}), ...(trace ? { trace } : {}) };
|
|
1015
|
+
if (!journalTwinRequest(name, { phase: 'request', method: request.method, path: `/${rest}`, status: 0, credentials, ...by }, this.controlRoot(name))) {
|
|
957
1016
|
return Response.json({ error: `the ${name} link's audit could not be written: nothing was sent` }, { status: 503 });
|
|
958
1017
|
}
|
|
959
1018
|
const startedAt = Date.now();
|
|
@@ -964,7 +1023,7 @@ export class WorldDoors {
|
|
|
964
1023
|
return answer;
|
|
965
1024
|
}
|
|
966
1025
|
finally {
|
|
967
|
-
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials }, this.controlRoot(name));
|
|
1026
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials, ...by }, this.controlRoot(name));
|
|
968
1027
|
}
|
|
969
1028
|
}
|
|
970
1029
|
async forwardSealed(request, caller, url, rest, name, origin) {
|
|
@@ -1166,9 +1225,11 @@ export class WorldDoors {
|
|
|
1166
1225
|
// with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
|
|
1167
1226
|
if (name) {
|
|
1168
1227
|
const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name });
|
|
1169
|
-
return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset) });
|
|
1228
|
+
return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] });
|
|
1170
1229
|
}
|
|
1171
|
-
|
|
1230
|
+
const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
|
|
1231
|
+
const deployed = this.deployedOf(all);
|
|
1232
|
+
return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i] })) });
|
|
1172
1233
|
}
|
|
1173
1234
|
if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
|
|
1174
1235
|
const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
|
|
@@ -1705,7 +1766,33 @@ export class WorldDoors {
|
|
|
1705
1766
|
const shown = page.slice(0, limit);
|
|
1706
1767
|
const last = shown[shown.length - 1];
|
|
1707
1768
|
const next = more && last ? Buffer.from(JSON.stringify([last.entry.occurredAt, last.twin, last.position])).toString('base64url') : null;
|
|
1708
|
-
|
|
1769
|
+
// who made each entry: the request it was written in (the World's span on it) as the twin's journal recorded it
|
|
1770
|
+
const entries = shown.map((r) => { const by = this.callersOf(r.twin).get(spanOf(r.entry.traceparent) ?? ''); return by ? { ...r, caller: by } : r; });
|
|
1771
|
+
return Response.json({ world: this.served, entries, next });
|
|
1772
|
+
}
|
|
1773
|
+
/** Each twin's callers by the World's span, from its request journal: its newest CALLER_SEGMENTS once, then from
|
|
1774
|
+
* where the last read stopped (readTwinRequestJournalFrom follows a rotation). The newest CALLERS_KEPT are kept. */
|
|
1775
|
+
callersByTwin = new Map();
|
|
1776
|
+
callersOf(twin) {
|
|
1777
|
+
const root = this.controlRoot(twin);
|
|
1778
|
+
let held = this.callersByTwin.get(twin);
|
|
1779
|
+
if (!held || held.root !== root) {
|
|
1780
|
+
held = { root, cursor: null, callers: new Map() };
|
|
1781
|
+
this.callersByTwin.set(twin, held);
|
|
1782
|
+
}
|
|
1783
|
+
const read = readTwinRequestJournalFrom(twin, root, held.cursor, { segments: CALLER_SEGMENTS });
|
|
1784
|
+
for (const e of read.entries)
|
|
1785
|
+
if (e.span && e.caller) {
|
|
1786
|
+
held.callers.delete(e.span);
|
|
1787
|
+
held.callers.set(e.span, e.caller);
|
|
1788
|
+
}
|
|
1789
|
+
held.cursor = read.cursor;
|
|
1790
|
+
for (const span of held.callers.keys()) {
|
|
1791
|
+
if (held.callers.size <= CALLERS_KEPT)
|
|
1792
|
+
break;
|
|
1793
|
+
held.callers.delete(span);
|
|
1794
|
+
}
|
|
1795
|
+
return held.callers;
|
|
1709
1796
|
}
|
|
1710
1797
|
/** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
|
|
1711
1798
|
* (a twin is read again only when the size or time of its parent log, branch log or branch record
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.41",
|
|
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.23",
|
|
71
71
|
"@volter/world-console": "2.0.13"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
76
|
"@volter/world-access": "2.0.2",
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
77
|
+
"@volter/world-core": "2.0.23",
|
|
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.23"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/branch.ts
CHANGED
|
@@ -69,7 +69,9 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
|
|
|
69
69
|
}
|
|
70
70
|
const forked: Record<string, string[]> = {};
|
|
71
71
|
try {
|
|
72
|
-
|
|
72
|
+
// the branch's origin is its base's, or none: never one a stopped World of the same name left
|
|
73
|
+
if (from.origin) instance.origin = { ...from.origin }; else delete instance.origin;
|
|
74
|
+
saveWorldInstance(instance);
|
|
73
75
|
for (const { service, fromRoot, states } of source) {
|
|
74
76
|
const toRoot = join(instance.dirs.data, service);
|
|
75
77
|
for (const { state, generation, view, position: pos } of states) {
|
package/src/origin.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { appendOriginEntry, appendParentEntry, branchEntries, getActiveWorldStor
|
|
|
9
9
|
import { saveWorldInstance, statusWorld } from './runtime.ts';
|
|
10
10
|
import type { WorldInstance } from './schema.ts';
|
|
11
11
|
import { fetchReady, isPathRemote, localWorld, TOKEN_HEADER } from './served-world.ts';
|
|
12
|
+
import { loadWorldConfig, writeWorldConfig } from './configs.ts';
|
|
12
13
|
|
|
13
14
|
export type FetchOptions = { root?: string; url?: string; namespace?: string; key: string; full?: boolean; services?: string[]; views?: Record<string, HistoryReference>; now?: Date };
|
|
14
15
|
export type FetchOutcome = {
|
|
@@ -126,6 +127,16 @@ export async function fetchFromOrigin(name: string, options: FetchOptions): Prom
|
|
|
126
127
|
|
|
127
128
|
}
|
|
128
129
|
saveWorldInstance({ ...instance, origin: { ...origin, ...(options.full ? { clonedAt: at_ } : instance.origin?.clonedAt ? { clonedAt: instance.origin.clonedAt } : {}), fetchedAt: at_ } });
|
|
130
|
+
// a clone names its remote where `volter remote add` does (world.json, as git config holds a remote): one record of
|
|
131
|
+
// it, which every restart carries (runtime's namesOrigin) and `remote remove` ends. Only the World the config is
|
|
132
|
+
// for writes it: a runtime branch boots from its base's config, whose remote stays the base's
|
|
133
|
+
if (options.full && instance.name === instance.config) {
|
|
134
|
+
try {
|
|
135
|
+
const { path: configPath, config } = loadWorldConfig(instance.configPath, root);
|
|
136
|
+
const named = isPathRemote(origin.url) ? origin.url : `${origin.url}/${origin.namespace}`;
|
|
137
|
+
if (config.remotes?.origin !== named) writeWorldConfig(configPath, { ...config, remotes: { ...(config.remotes ?? {}), origin: named } });
|
|
138
|
+
} catch (error) { console.error(`[origin] ${name}: the remote was not named in its config: ${(error as Error).message}`); }
|
|
139
|
+
}
|
|
129
140
|
return { world: name, origin, full: options.full ?? false, appended, behind, snapshots };
|
|
130
141
|
}
|
|
131
142
|
|
package/src/runtime.ts
CHANGED
|
@@ -757,6 +757,17 @@ function writePidsFromInstance(instance: WorldInstance, fresh: number[] = []): v
|
|
|
757
757
|
writeOwnedPids(instance.root, instance.name, pids, fresh);
|
|
758
758
|
}
|
|
759
759
|
|
|
760
|
+
/** Whether a world.json remote (`https://<host>/<org>/<world>`, or a world's directory, relative to the World's root) is
|
|
761
|
+
* the recorded origin, read as the CLI's parseOriginUrl reads it (the URL's origin, then its two path parts). */
|
|
762
|
+
function namesOrigin(remote: string | undefined, origin: { url: string; namespace: string }, worldRoot: string): boolean {
|
|
763
|
+
if (!remote) return false;
|
|
764
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(remote)) return resolve(worldRoot, remote) === resolve(worldRoot, origin.url);
|
|
765
|
+
try {
|
|
766
|
+
const parsed = new URL(remote); const parts = parsed.pathname.split('/').filter(Boolean);
|
|
767
|
+
return parts.length === 2 && parsed.origin === new URL(origin.url).origin && parts.join('/') === origin.namespace;
|
|
768
|
+
} catch { return false; }
|
|
769
|
+
}
|
|
770
|
+
|
|
760
771
|
export function saveWorldInstance(instance: WorldInstance): void {
|
|
761
772
|
writeTextAtomic(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
|
|
762
773
|
}
|
|
@@ -1483,6 +1494,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1483
1494
|
const pidsFile = join(instance, 'pids');
|
|
1484
1495
|
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
1485
1496
|
const instanceFile = join(instance, 'instance.json');
|
|
1497
|
+
let carriedOrigin: WorldInstance['origin'] | undefined;
|
|
1486
1498
|
const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) as WorldInstance : undefined;
|
|
1487
1499
|
const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
|
|
1488
1500
|
? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
|
|
@@ -1537,6 +1549,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1537
1549
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
1538
1550
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
1539
1551
|
}
|
|
1552
|
+
// the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
|
|
1553
|
+
// same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
|
|
1554
|
+
// ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
|
|
1555
|
+
// only when its state (the mirror they count) is kept, as reset drops them
|
|
1556
|
+
if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
|
|
1557
|
+
carriedOrigin = options.keepState ? prior.origin
|
|
1558
|
+
: { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
|
|
1559
|
+
}
|
|
1540
1560
|
}
|
|
1541
1561
|
assertLifecycleStartable(root, name);
|
|
1542
1562
|
beginWorldLifecycle(root, name, createdAt, options.owner);
|
|
@@ -1622,6 +1642,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1622
1642
|
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
1623
1643
|
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
1624
1644
|
lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
|
|
1645
|
+
...(carriedOrigin ? { origin: carriedOrigin } : {}),
|
|
1625
1646
|
};
|
|
1626
1647
|
saveWorldInstance(worldInstance);
|
|
1627
1648
|
const cancelled = new AbortController();
|
package/src/served-world.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
29
|
-
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
|
|
29
|
+
import { CALLER_HEADER, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
31
31
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
@@ -380,6 +380,11 @@ function traceIdOf(traceparent: unknown): string | null {
|
|
|
380
380
|
return m && !/^0+$/.test(m[1]!) ? m[1]! : null;
|
|
381
381
|
}
|
|
382
382
|
type TimelineRow = { twin: string; position: number; entry: Entry };
|
|
383
|
+
const CALLERS_KEPT = 50_000;
|
|
384
|
+
/** The closed journal segments a World reads for callers when it first answers a timeline (~1 MB each): older entries show no caller. */
|
|
385
|
+
const CALLER_SEGMENTS = 8;
|
|
386
|
+
/** A W3C traceparent's parent-id (the span the World minted for the request), or null. */
|
|
387
|
+
function spanOf(traceparent: unknown): string | null { return parseTraceparent(traceparent)?.parentId ?? null; }
|
|
383
388
|
/** Timeline order, newest first: by the entry's instant, then twin, then position. */
|
|
384
389
|
function newerFirst(a: { at: string; twin: string; position: number }, b: { at: string; twin: string; position: number }): number {
|
|
385
390
|
return b.at.localeCompare(a.at) || b.twin.localeCompare(a.twin) || b.position - a.position;
|
|
@@ -590,6 +595,42 @@ export class WorldDoors {
|
|
|
590
595
|
if (grant.via === 'session') return { scope: grant.scope, via: 'session', token: null, session: grant.session!, ...(grant.who ? { who: grant.who } : {}) };
|
|
591
596
|
return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
|
|
592
597
|
}
|
|
598
|
+
/** Each changeset's actions' receipts, from the landed copies on each twin's log: a deploy appends one per entry it
|
|
599
|
+
* performed, and a push one per entry it landed at the remote (`landed` while the remote waits, beside the
|
|
600
|
+
* changeset's own `applied`). */
|
|
601
|
+
private deployedOf(changesets: Changeset[]): Array<Array<{ service: string; actionId: string } & Receipt>> {
|
|
602
|
+
// by the state each action was written in (a twin may hold several), and a settled receipt wins over a provisional
|
|
603
|
+
// `landed` one whichever comes later in the log (a push's local copy, then the remote's deploy fetched in)
|
|
604
|
+
const landed = new Map<string, Map<string, Receipt>>();
|
|
605
|
+
const of = (service: string, state: string): Map<string, Receipt> => {
|
|
606
|
+
const at = `${service}\u0000${state}`;
|
|
607
|
+
let held = landed.get(at);
|
|
608
|
+
if (!held) {
|
|
609
|
+
held = new Map();
|
|
610
|
+
if (this.up().services[service]) {
|
|
611
|
+
for (const e of parentEntries(state, this.controlRoot(service))) {
|
|
612
|
+
if (!e.landsId || !e.receipt) continue;
|
|
613
|
+
const before = held.get(e.landsId);
|
|
614
|
+
if (!before || before.status === 'landed' || e.receipt.status !== 'landed') held.set(e.landsId, e.receipt);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
landed.set(at, held);
|
|
618
|
+
}
|
|
619
|
+
return held;
|
|
620
|
+
};
|
|
621
|
+
return changesets.map((c) => c.actions.flatMap((a) => { const r = of(a.service, a.stateService ?? this.stateOf(a.service)).get(a.action.id); return r ? [{ service: a.service, actionId: a.action.id, ...r }] : []; }));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
|
|
625
|
+
* (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
|
|
626
|
+
* this World issued. */
|
|
627
|
+
private callerOf(request: Request): string | null {
|
|
628
|
+
const cred = this.credential(request);
|
|
629
|
+
if (cred.scope === null) return null;
|
|
630
|
+
if (cred.via === 'session') return cred.who ? `person:${cred.who}` : 'person';
|
|
631
|
+
if (cred.via === 'key') { const token = cred.token; const held = token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined; return held ? `key:${held.name}` : 'key'; }
|
|
632
|
+
return cred.scope === 'read' ? 'token:read' : 'token';
|
|
633
|
+
}
|
|
593
634
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
594
635
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
595
636
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -781,6 +822,12 @@ export class WorldDoors {
|
|
|
781
822
|
const enforced = scope === 'read' && (await this.enforcesRead(vendor!, twin, url.origin));
|
|
782
823
|
if (scope === 'read' && !reads && !enforced) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
783
824
|
const headers = new Headers(request.headers); headers.delete(TOKEN_HEADER); headers.delete(TWINS_KEY_HEADER); headers.delete('host');
|
|
825
|
+
// who made it is the World's word, never the caller's: the twin's request journal records it. The World is a hop of
|
|
826
|
+
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
827
|
+
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
828
|
+
headers.delete(CALLER_HEADER);
|
|
829
|
+
const who = this.callerOf(request); if (who) headers.set(CALLER_HEADER, who);
|
|
830
|
+
headers.set('traceparent', traceparentForDelivery(headers.get('traceparent')) ?? newTraceparent());
|
|
784
831
|
// at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
|
|
785
832
|
// that would write for its caller is refused too); at any other, its GET passes as it always has
|
|
786
833
|
if (enforced) headers.set(READ_ONLY_HEADER, '1');
|
|
@@ -843,7 +890,7 @@ export class WorldDoors {
|
|
|
843
890
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
844
891
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
845
892
|
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);
|
|
893
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
|
|
847
894
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
848
895
|
const authorization = caller.get('authorization');
|
|
849
896
|
const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
|
|
@@ -852,7 +899,9 @@ export class WorldDoors {
|
|
|
852
899
|
// name, with the credential shape of what the caller sent on. As an audit device does, the request is
|
|
853
900
|
// written before the real credential is used, and a request whose audit cannot be written is refused.
|
|
854
901
|
const credentials = twinRequestCredentials(caller, url);
|
|
855
|
-
|
|
902
|
+
const who = this.callerOf(request); const trace = parseTraceparent(request.headers.get('traceparent'))?.traceId;
|
|
903
|
+
const by = { ...(who ? { caller: who } : {}), ...(trace ? { trace } : {}) };
|
|
904
|
+
if (!journalTwinRequest(name, { phase: 'request', method: request.method, path: `/${rest}`, status: 0, credentials, ...by }, this.controlRoot(name))) {
|
|
856
905
|
return Response.json({ error: `the ${name} link's audit could not be written: nothing was sent` }, { status: 503 });
|
|
857
906
|
}
|
|
858
907
|
const startedAt = Date.now(); let status = 500;
|
|
@@ -861,7 +910,7 @@ export class WorldDoors {
|
|
|
861
910
|
status = answer.status;
|
|
862
911
|
return answer;
|
|
863
912
|
} finally {
|
|
864
|
-
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials }, this.controlRoot(name));
|
|
913
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials, ...by }, this.controlRoot(name));
|
|
865
914
|
}
|
|
866
915
|
}
|
|
867
916
|
|
|
@@ -1017,8 +1066,10 @@ export class WorldDoors {
|
|
|
1017
1066
|
}
|
|
1018
1067
|
if (kind === 'changesets' && request.method === 'GET') {
|
|
1019
1068
|
// with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
|
|
1020
|
-
if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset) }); }
|
|
1021
|
-
|
|
1069
|
+
if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] }); }
|
|
1070
|
+
const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
|
|
1071
|
+
const deployed = this.deployedOf(all);
|
|
1072
|
+
return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i] })) });
|
|
1022
1073
|
}
|
|
1023
1074
|
if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
|
|
1024
1075
|
const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
|
|
@@ -1442,7 +1493,23 @@ export class WorldDoors {
|
|
|
1442
1493
|
const more = page.length > limit; const shown = page.slice(0, limit);
|
|
1443
1494
|
const last = shown[shown.length - 1];
|
|
1444
1495
|
const next = more && last ? Buffer.from(JSON.stringify([last.entry.occurredAt, last.twin, last.position])).toString('base64url') : null;
|
|
1445
|
-
|
|
1496
|
+
// who made each entry: the request it was written in (the World's span on it) as the twin's journal recorded it
|
|
1497
|
+
const entries = shown.map((r) => { const by = this.callersOf(r.twin).get(spanOf(r.entry.traceparent) ?? ''); return by ? { ...r, caller: by } : r; });
|
|
1498
|
+
return Response.json({ world: this.served, entries, next });
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** Each twin's callers by the World's span, from its request journal: its newest CALLER_SEGMENTS once, then from
|
|
1502
|
+
* where the last read stopped (readTwinRequestJournalFrom follows a rotation). The newest CALLERS_KEPT are kept. */
|
|
1503
|
+
private readonly callersByTwin = new Map<string, { root: string; cursor: TwinRequestJournalCursor | null; callers: Map<string, string> }>();
|
|
1504
|
+
private callersOf(twin: string): Map<string, string> {
|
|
1505
|
+
const root = this.controlRoot(twin);
|
|
1506
|
+
let held = this.callersByTwin.get(twin);
|
|
1507
|
+
if (!held || held.root !== root) { held = { root, cursor: null, callers: new Map() }; this.callersByTwin.set(twin, held); }
|
|
1508
|
+
const read = readTwinRequestJournalFrom(twin, root, held.cursor, { segments: CALLER_SEGMENTS });
|
|
1509
|
+
for (const e of read.entries) if (e.span && e.caller) { held.callers.delete(e.span); held.callers.set(e.span, e.caller); }
|
|
1510
|
+
held.cursor = read.cursor;
|
|
1511
|
+
for (const span of held.callers.keys()) { if (held.callers.size <= CALLERS_KEPT) break; held.callers.delete(span); }
|
|
1512
|
+
return held.callers;
|
|
1446
1513
|
}
|
|
1447
1514
|
|
|
1448
1515
|
/** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
|