@volter/world-core 2.0.20 → 2.0.22

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/inject.cjs CHANGED
@@ -728,8 +728,9 @@ function socketTargetHosts(args, isTls) {
728
728
  return hosts;
729
729
  }
730
730
  if (typeof first === 'string' && !/^\d+$/.test(first)) return hosts; // a unix socket path
731
- hosts.push(typeof second === 'string' ? second : 'localhost');
732
731
  const options = typeof second === 'object' && second !== null ? second : typeof third === 'object' && third !== null ? third : null;
732
+ // connect(port, host) names the host; connect(port, { host }) puts it in the options, which Node merges
733
+ hosts.push(typeof second === 'string' ? second : typeof options?.host === 'string' ? options.host : 'localhost');
733
734
  if (isTls && options && options.servername) hosts.push(options.servername);
734
735
  return hosts;
735
736
  }
@@ -811,7 +812,15 @@ function patchedPromisesLookup(original) {
811
812
  };
812
813
  }
813
814
 
814
- function socketRefusal(hostname) {
815
+ /** The port a net/tls connect names (`connect(port, host)`, `connect({ port, host })`), or undefined. */
816
+ function socketTargetPort(args) {
817
+ const [first] = args;
818
+ const raw = first !== null && typeof first === 'object' ? first.port : first;
819
+ const port = Number(raw);
820
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
821
+ }
822
+
823
+ function socketRefusal(hostname, port, isTls) {
815
824
  const host = String(hostname || '').toLowerCase();
816
825
  if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
817
826
  return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
@@ -831,8 +840,11 @@ function socketRefusal(hostname) {
831
840
  return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
832
841
  + 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
833
842
  }
834
- const url = (() => { try { return new URL(`https://${isIP(host) === 6 ? `[${host}]` : host}/`); } catch { return null; } })();
835
- if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${host}`;
843
+ // the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
844
+ // host:443, never another port or a plaintext connection to it
845
+ const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
846
+ const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
847
+ if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${authority}`;
836
848
  return null;
837
849
  }
838
850
 
@@ -844,8 +856,9 @@ function refusedSocket(message) {
844
856
 
845
857
  function patchedConnect(originalConnect, isTls) {
846
858
  return function patchedSocketConnect(...args) {
859
+ const port = socketTargetPort(args);
847
860
  for (const host of socketTargetHosts(args, isTls)) {
848
- const refusal = socketRefusal(host);
861
+ const refusal = socketRefusal(host, port, isTls);
849
862
  if (refusal) return refusedSocket(refusal);
850
863
  }
851
864
  return originalConnect.apply(this, args);
@@ -66,9 +66,11 @@ function isWorldInternalHost(hostname) {
66
66
  const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
67
67
  if (v4) {
68
68
  const a = Number(v4[1]); const b = Number(v4[2]);
69
- return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
69
+ // not link-local 169.254/16: it holds a cloud's instance metadata (169.254.169.254), the box's credentials
70
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
70
71
  }
71
- return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:'));
72
+ // nor fe80::/10, nor a cloud's IPv6 metadata address (fd00:ec2::254)
73
+ return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd')) && h !== 'fd00:ec2::254';
72
74
  }
73
75
 
74
76
  /**
@@ -19,9 +19,9 @@ import { canonicalJson, subjectKey } from "./hash.js";
19
19
  import { appendDurable, eventsLockPath, ownerStoreRoots, projectionLockPath, twinLog, withFileLock, withoutIdentityClaim, worldPaths } from "./storage.js";
20
20
  import { landAsPlaceholder, placeholderPullActive } from "./placeholder-remote.js";
21
21
  import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp } from "./log.js";
22
- import { refuseReadOnlyWrite } from "./request-scope.js";
22
+ import { inVendorMove, refuseReadOnlyWrite } from "./request-scope.js";
23
23
  import { getActiveWorldStore } from "./world-store.js";
24
- import { currentTraceparent } from "./trace-context.js";
24
+ import { currentTraceparent, traceparentForDelivery } from "./trace-context.js";
25
25
  export class TwinActionPreconditionError extends Error {
26
26
  actionId;
27
27
  failed;
@@ -46,8 +46,11 @@ function withCorrelationId(action) {
46
46
  // into the handler's async context) is the correlation when the caller supplies none — the
47
47
  // join from a request on the wire to the action rows it caused, with no pack involved.
48
48
  const correlated = action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
49
- // the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context
50
- const traceparent = correlated.traceparent === undefined ? currentTraceparent() : undefined;
49
+ // the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context. What the
50
+ // vendor makes on its own while a request is served (its actor is the system's, or a vendor move) is a span of its own
51
+ // in that trace, so it is never joined to the request's caller (served-world's timeline), who did not make it
52
+ const vendors = action.actor?.kind === 'system' || inVendorMove();
53
+ const traceparent = correlated.traceparent === undefined ? (vendors ? traceparentForDelivery() : currentTraceparent()) : undefined;
51
54
  return traceparent ? { ...correlated, traceparent } : correlated;
52
55
  }
53
56
  /** Subjects a `set` action touches: its own subject, every projection resource, and every
@@ -6,7 +6,7 @@ export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.js';
6
6
  export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.js';
7
7
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.js';
8
8
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.js';
9
- export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
9
+ export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
10
10
  export type { Traceparent } from './trace-context.js';
11
11
  export type { DerivedCall, DerivedCoreOutcome, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface } from './derived.js';
12
12
  export { bindSemantics, coreFor, crossCutting, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.js';
@@ -33,7 +33,7 @@ export { SqlWorldStore } from './world-store-sql.js';
33
33
  export type { SqlExec } from './world-store-sql.js';
34
34
  export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from './blob-store.js';
35
35
  export type { BlobStore } from './blob-store.js';
36
- export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from './serve.js';
36
+ export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, CALLER_HEADER, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, readTwinRequestJournalFrom, type TwinRequestJournalCursor, twinResources, } from './serve.js';
37
37
  export type { AtomicTwinWriteDecision, TwinRequestJournalEntry, TwinResource, TwinWriteInput, TwinWriteResult, } from './serve.js';
38
38
  export { createTwinProxy } from './proxy.js';
39
39
  export type { TwinProxy, TwinProxyOptions, VendorRoute } from './proxy.js';
package/dist/src/index.js CHANGED
@@ -17,7 +17,7 @@ export { WORLD_ENV_NAMES_ENV, worldEnvValue } from "./world-env.js";
17
17
  export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from "./twin-fetch.js";
18
18
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
19
19
  export { compileSurface, createDerivedFetch, matchOperation } from "./derived.js";
20
- export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
20
+ export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
21
21
  export { bindSemantics, coreFor, crossCutting, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from "./derived-core.js";
22
22
  export { emitTwinEvent, eventSubscriptionMatches, listEmittable, runEmitCli } from "./emit.js";
23
23
  // The vendor-agnostic CLIENT-SIDE RATE BUDGET — the fail-closed backstop a pack's guarded client
@@ -55,7 +55,7 @@ export { SqlWorldStore } from "./world-store-sql.js";
55
55
  // The blob seam (runtime contract R11): byte storage behind byte-carrying handlers, so a
56
56
  // serverless namespace puts bytes in object storage while local worlds keep today's layout.
57
57
  export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from "./blob-store.js";
58
- export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from "./serve.js";
58
+ export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, CALLER_HEADER, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, readTwinRequestJournalFrom, twinResources, } from "./serve.js";
59
59
  export { createTwinProxy } from "./proxy.js";
60
60
  export { forkTwin, isFork, readForkMeta, } from "./fork.js";
61
61
  export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from "./actions.js";
@@ -21,6 +21,8 @@ export declare function runAsReadOnlyRequest<T>(fn: () => T | Promise<T>): Promi
21
21
  /** Run `fn` as the VENDOR's own move (a pack's catch-up): its writes land even under a read-only
22
22
  * request. Outside one it changes nothing. */
23
23
  export declare function runAsVendorMove<T>(fn: () => T): T;
24
+ /** Whether this runs as the vendor's own move (runAsVendorMove). */
25
+ export declare function inVendorMove(): boolean;
24
26
  /** Whether a write attempted here would be refused. */
25
27
  export declare function writesRefused(): boolean;
26
28
  /** The write seam's check: throws (and records) the refusal when a write attempted here is a
@@ -14,6 +14,7 @@
14
14
  // Created on first use, never at import: a browser bundle of a mirror client carries the kernel and
15
15
  // has no AsyncLocalStorage (see serve.ts identitySlot).
16
16
  import { AsyncLocalStorage } from 'node:async_hooks';
17
+ import { runWithTraceparent, traceparentForDelivery } from "./trace-context.js";
17
18
  /** The request header that marks a request read-only. Its one value is `1`. */
18
19
  export const READ_ONLY_REQUEST_HEADER = 'x-volter-read-only';
19
20
  /** Whether a request carries the read-only marker. */
@@ -52,8 +53,14 @@ export async function runAsReadOnlyRequest(fn) {
52
53
  * request. Outside one it changes nothing. */
53
54
  export function runAsVendorMove(fn) {
54
55
  const current = writeScope().getStore();
55
- return current ? writeScope().run({ ...current, vendorMove: true }, fn) : fn();
56
+ // the vendor's move is its own span of the request's trace (a delivery's shape): what it writes is joined to the
57
+ // trace, never to the request's caller, who did not make it
58
+ const own = traceparentForDelivery();
59
+ const run = own ? () => runWithTraceparent(own, fn) : fn;
60
+ return current ? writeScope().run({ ...current, vendorMove: true }, run) : run();
56
61
  }
62
+ /** Whether this runs as the vendor's own move (runAsVendorMove). */
63
+ export function inVendorMove() { return writeScopeStore?.getStore()?.vendorMove === true; }
57
64
  /** Whether a write attempted here would be refused. */
58
65
  export function writesRefused() {
59
66
  const scope = writeScopeStore?.getStore();
@@ -15,6 +15,10 @@ export type TwinCredentialShape = {
15
15
  /** the name arrived with an EMPTY value — the "the SDK sent the header but no key" case. */
16
16
  empty?: true;
17
17
  };
18
+ /** The header a World puts on a request it forwards to a twin: who made it (`key:<name>`, `person:<who>`, `token`,
19
+ * `token:read`). The World replaces any a caller sent; the serve seam records it in the twin's request journal and
20
+ * removes it before the twin's handler. A twin reached on its own port, not through a World, records what it was sent. */
21
+ export declare const CALLER_HEADER = "x-volter-caller";
18
22
  export type TwinRequestJournalEntry = {
19
23
  at?: string;
20
24
  method: string;
@@ -24,6 +28,14 @@ export type TwinRequestJournalEntry = {
24
28
  ms?: number;
25
29
  /** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
26
30
  credentials?: TwinCredentialShape[];
31
+ /** who made it, as the World named its caller (CALLER_HEADER: `key:<name>`, `person:<who>`, `token`); absent for a
32
+ * request that did not come through a World */
33
+ caller?: string;
34
+ /** the request's trace-id and parent-id (its `traceparent`). The World mints the parent-id of every request it
35
+ * forwards, and the request's log entries carry it, so an entry is joined to its caller by `span`, which no caller
36
+ * chooses (a trace-id is the caller's and spans requests) */
37
+ trace?: string;
38
+ span?: string;
27
39
  /** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
28
40
  * audit cannot be written is refused); its outcome is the entry without a phase written after. */
29
41
  phase?: 'request';
@@ -36,6 +48,23 @@ export declare function readTwinRequestJournal(service: string, root?: string, o
36
48
  /** Journal lines this process failed to keep, since it started, for one service's journal under `root`
37
49
  * (a World's request report sums its own twins and links). */
38
50
  export declare function twinRequestJournalFailures(service: string, root?: string): number;
51
+ /** Where a reader of a journal stopped: the newest closed segment it had seen and the bytes of the live file it read. */
52
+ export type TwinRequestJournalCursor = {
53
+ segment: number;
54
+ offset: number;
55
+ };
56
+ /** The journal's lines after `cursor` (all of them without one), and the cursor to read on from. A rotation closes
57
+ * the live file into the next segment as it stood, so a reader that stopped in the live file goes on in that
58
+ * segment at its offset, then any later segments, and the next read takes the live file from its start; a live file shorter than where
59
+ * the reader stopped (read between a rotation's two writes) is read again from its start, its lines repeated rather
60
+ * than lost. Only whole lines are read (a line being appended is read next time). Without a cursor, the newest
61
+ * `segments` closed segments are read (all of them when omitted), then the live file. */
62
+ export declare function readTwinRequestJournalFrom(service: string, root: string | undefined, cursor: TwinRequestJournalCursor | null, options?: {
63
+ segments?: number;
64
+ }): {
65
+ entries: TwinRequestJournalEntry[];
66
+ cursor: TwinRequestJournalCursor;
67
+ };
39
68
  /** Non-reversible, stable fingerprint. 64 bits of sha256 — enough that two distinct credentials
40
69
  * never collide in a journal, far too little to walk back to a real key. */
41
70
  export declare function credentialFingerprint(value: string): string;
package/dist/src/serve.js CHANGED
@@ -18,6 +18,11 @@ import { hashFieldValue } from "./hash.js";
18
18
  import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, projectResources } from "./actions.js";
19
19
  import { observeWorldPaths, worldPaths } from "./storage.js";
20
20
  import { getActiveWorldStore } from "./world-store.js";
21
+ import { parseTraceparent } from "./trace-context.js";
22
+ /** The header a World puts on a request it forwards to a twin: who made it (`key:<name>`, `person:<who>`, `token`,
23
+ * `token:read`). The World replaces any a caller sent; the serve seam records it in the twin's request journal and
24
+ * removes it before the twin's handler. A twin reached on its own port, not through a World, records what it was sent. */
25
+ export const CALLER_HEADER = 'x-volter-caller';
21
26
  /** The journal's closed segments, oldest first: `requests.jsonl.<n>` beside the live file. */
22
27
  function journalSegments(path) {
23
28
  const base = path.slice(path.lastIndexOf('/') + 1);
@@ -70,6 +75,49 @@ export function twinRequestJournalFailures(service, root) {
70
75
  return 0;
71
76
  }
72
77
  }
78
+ /** The journal's lines after `cursor` (all of them without one), and the cursor to read on from. A rotation closes
79
+ * the live file into the next segment as it stood, so a reader that stopped in the live file goes on in that
80
+ * segment at its offset, then any later segments, and the next read takes the live file from its start; a live file shorter than where
81
+ * the reader stopped (read between a rotation's two writes) is read again from its start, its lines repeated rather
82
+ * than lost. Only whole lines are read (a line being appended is read next time). Without a cursor, the newest
83
+ * `segments` closed segments are read (all of them when omitted), then the live file. */
84
+ export function readTwinRequestJournalFrom(service, root, cursor, options = {}) {
85
+ const path = twinRequestJournalPath(service, root);
86
+ const store = getActiveWorldStore();
87
+ const segments = journalSegments(path).map((file) => Number(file.slice(path.length + 1)));
88
+ const newest = segments[segments.length - 1] ?? 0;
89
+ const entries = [];
90
+ const take = (file, start, whole) => {
91
+ const text = store.readRange ? store.readRange(file, start) : ((t) => (t === null ? null : Buffer.from(t, 'utf8').subarray(start).toString('utf8')))(store.read(file));
92
+ if (!text)
93
+ return start;
94
+ const end = whole ? text.length : text.lastIndexOf('\n') + 1;
95
+ for (const line of text.slice(0, end).split('\n')) {
96
+ if (!line)
97
+ continue;
98
+ try {
99
+ entries.push(JSON.parse(line));
100
+ }
101
+ catch { /* a torn line */ }
102
+ }
103
+ return start + Buffer.byteLength(text.slice(0, end), 'utf8');
104
+ };
105
+ if (cursor === null) {
106
+ for (const n of options.segments === undefined ? segments : segments.slice(-options.segments))
107
+ take(`${path}.${n}`, 0, true);
108
+ return { entries, cursor: { segment: newest, offset: take(path, 0, false) } };
109
+ }
110
+ if (newest === cursor.segment) {
111
+ const from = (store.stat(path)?.size ?? 0) < cursor.offset ? 0 : cursor.offset;
112
+ return { entries, cursor: { segment: newest, offset: take(path, from, false) } };
113
+ }
114
+ for (const n of segments)
115
+ if (n > cursor.segment)
116
+ take(`${path}.${n}`, n === cursor.segment + 1 ? cursor.offset : 0, true);
117
+ // the live file is read next time, from its start: read now, it may be the one the rotation has not emptied yet,
118
+ // and an offset into it would land inside the new one
119
+ return { entries, cursor: { segment: newest, offset: 0 } };
120
+ }
73
121
  /** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
74
122
  * recent period reads only the segments that reach into it. */
75
123
  const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
@@ -155,6 +203,9 @@ export function journalTwinRequest(service, entry, root) {
155
203
  const line = JSON.stringify({
156
204
  at: entry.at ?? new Date().toISOString(),
157
205
  ...(entry.phase ? { phase: entry.phase } : {}),
206
+ ...(entry.caller ? { caller: entry.caller.slice(0, 120) } : {}),
207
+ ...(entry.trace && /^[0-9a-f]{32}$/.test(entry.trace) ? { trace: entry.trace } : {}),
208
+ ...(entry.span && /^[0-9a-f]{16}$/.test(entry.span) ? { span: entry.span } : {}),
158
209
  method: entry.method.toUpperCase(),
159
210
  path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
160
211
  status: entry.status,
@@ -286,6 +337,13 @@ function journalingFetch(config, callSiteVendor) {
286
337
  let identity = declared;
287
338
  const inner = config.fetch;
288
339
  const journaling = async function (request, server) {
340
+ // who made it is read here and goes no further: no twin's handler (a tunnel's, a proxy's) hands it on
341
+ const caller = request.headers.get(CALLER_HEADER) ?? undefined;
342
+ if (caller !== undefined) {
343
+ const headers = new Headers(request.headers);
344
+ headers.delete(CALLER_HEADER);
345
+ request = new Request(request, { headers });
346
+ }
289
347
  // The World's own boot probe (serve-http.ts, WORLD_BOOT_PATH) is not the app's traffic: never journaled.
290
348
  if (!twinRequestJournalEnabled() || new URL(request.url).pathname === WORLD_BOOT_PATH)
291
349
  return inner.call(this, request, server);
@@ -308,12 +366,15 @@ function journalingFetch(config, callSiteVendor) {
308
366
  const resolved = identity ?? fallbackIdentity(callSiteVendor);
309
367
  if (resolved) {
310
368
  const url = new URL(request.url);
369
+ const parent = parseTraceparent(request.headers.get('traceparent'));
311
370
  journalTwinRequest(resolved.service, {
312
371
  method: request.method,
313
372
  path: url.pathname,
314
373
  status,
315
374
  ms: Date.now() - startedAt,
316
375
  credentials: twinRequestCredentials(request.headers, url),
376
+ ...(caller ? { caller } : {}),
377
+ ...(parent ? { trace: parent.traceId, span: parent.parentId } : {}),
317
378
  }, resolved.root);
318
379
  }
319
380
  }
@@ -16,6 +16,9 @@ export declare function runWithTraceparent<T>(traceparent: string | undefined, f
16
16
  export declare function currentTraceparent(): string | undefined;
17
17
  /** Run `fn` inside the trace context `request` carries (its `traceparent` header), if any. */
18
18
  export declare function runWithRequestTrace<T>(request: Request, fn: () => T): T;
19
+ /** A new trace's `traceparent` (version 00, a fresh trace-id and parent-id, sampled): for a request that carried none,
20
+ * so what it causes can be told apart and joined to it. */
21
+ export declare function newTraceparent(): string;
19
22
  /**
20
23
  * The `traceparent` an outbound delivery carries: a CHILD of its cause — the same trace-id and
21
24
  * flags, a new parent-id — so the receiving handler continues the cause's trace. The cause is the
@@ -51,6 +51,17 @@ export function currentTraceparent() {
51
51
  export function runWithRequestTrace(request, fn) {
52
52
  return runWithTraceparent(request.headers.get(TRACEPARENT_HEADER) ?? undefined, fn);
53
53
  }
54
+ /** A new trace's `traceparent` (version 00, a fresh trace-id and parent-id, sampled): for a request that carried none,
55
+ * so what it causes can be told apart and joined to it. */
56
+ export function newTraceparent() {
57
+ const bytes = new Uint8Array(16);
58
+ let trace = '';
59
+ do {
60
+ globalThis.crypto.getRandomValues(bytes);
61
+ trace = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
62
+ } while (/^0+$/.test(trace));
63
+ return `00-${trace}-${newSpanId()}-01`;
64
+ }
54
65
  function newSpanId() {
55
66
  const bytes = new Uint8Array(8);
56
67
  for (;;) {
package/inject.cjs CHANGED
@@ -728,8 +728,9 @@ function socketTargetHosts(args, isTls) {
728
728
  return hosts;
729
729
  }
730
730
  if (typeof first === 'string' && !/^\d+$/.test(first)) return hosts; // a unix socket path
731
- hosts.push(typeof second === 'string' ? second : 'localhost');
732
731
  const options = typeof second === 'object' && second !== null ? second : typeof third === 'object' && third !== null ? third : null;
732
+ // connect(port, host) names the host; connect(port, { host }) puts it in the options, which Node merges
733
+ hosts.push(typeof second === 'string' ? second : typeof options?.host === 'string' ? options.host : 'localhost');
733
734
  if (isTls && options && options.servername) hosts.push(options.servername);
734
735
  return hosts;
735
736
  }
@@ -811,7 +812,15 @@ function patchedPromisesLookup(original) {
811
812
  };
812
813
  }
813
814
 
814
- function socketRefusal(hostname) {
815
+ /** The port a net/tls connect names (`connect(port, host)`, `connect({ port, host })`), or undefined. */
816
+ function socketTargetPort(args) {
817
+ const [first] = args;
818
+ const raw = first !== null && typeof first === 'object' ? first.port : first;
819
+ const port = Number(raw);
820
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
821
+ }
822
+
823
+ function socketRefusal(hostname, port, isTls) {
815
824
  const host = String(hostname || '').toLowerCase();
816
825
  if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
817
826
  return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
@@ -831,8 +840,11 @@ function socketRefusal(hostname) {
831
840
  return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
832
841
  + 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
833
842
  }
834
- const url = (() => { try { return new URL(`https://${isIP(host) === 6 ? `[${host}]` : host}/`); } catch { return null; } })();
835
- if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${host}`;
843
+ // the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
844
+ // host:443, never another port or a plaintext connection to it
845
+ const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
846
+ const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
847
+ if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${authority}`;
836
848
  return null;
837
849
  }
838
850
 
@@ -844,8 +856,9 @@ function refusedSocket(message) {
844
856
 
845
857
  function patchedConnect(originalConnect, isTls) {
846
858
  return function patchedSocketConnect(...args) {
859
+ const port = socketTargetPort(args);
847
860
  for (const host of socketTargetHosts(args, isTls)) {
848
- const refusal = socketRefusal(host);
861
+ const refusal = socketRefusal(host, port, isTls);
849
862
  if (refusal) return refusedSocket(refusal);
850
863
  }
851
864
  return originalConnect.apply(this, args);
@@ -66,9 +66,11 @@ function isWorldInternalHost(hostname) {
66
66
  const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
67
67
  if (v4) {
68
68
  const a = Number(v4[1]); const b = Number(v4[2]);
69
- return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
69
+ // not link-local 169.254/16: it holds a cloud's instance metadata (169.254.169.254), the box's credentials
70
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
70
71
  }
71
- return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:'));
72
+ // nor fe80::/10, nor a cloud's IPv6 metadata address (fd00:ec2::254)
73
+ return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd')) && h !== 'fd00:ec2::254';
72
74
  }
73
75
 
74
76
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "2.0.20",
3
+ "version": "2.0.22",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
package/src/actions.ts CHANGED
@@ -21,9 +21,9 @@ import { appendDurable, eventsLockPath, ownerStoreRoots, projectionLockPath, twi
21
21
  import { landAsPlaceholder, placeholderPullActive } from './placeholder-remote.ts';
22
22
  import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp, type Receipt } from './log.ts';
23
23
  import { WorldServiceEventSchema } from './schemas.ts';
24
- import { refuseReadOnlyWrite } from './request-scope.ts';
24
+ import { inVendorMove, refuseReadOnlyWrite } from './request-scope.ts';
25
25
  import { getActiveWorldStore } from './world-store.ts';
26
- import { currentTraceparent } from './trace-context.ts';
26
+ import { currentTraceparent, traceparentForDelivery } from './trace-context.ts';
27
27
  import type { WorldServiceEvent } from './types.ts';
28
28
  import type { TwinResource } from './serve.ts';
29
29
 
@@ -135,8 +135,11 @@ function withCorrelationId(action: TwinAction): TwinAction {
135
135
  // into the handler's async context) is the correlation when the caller supplies none — the
136
136
  // join from a request on the wire to the action rows it caused, with no pack involved.
137
137
  const correlated = action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
138
- // the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context
139
- const traceparent = correlated.traceparent === undefined ? currentTraceparent() : undefined;
138
+ // the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context. What the
139
+ // vendor makes on its own while a request is served (its actor is the system's, or a vendor move) is a span of its own
140
+ // in that trace, so it is never joined to the request's caller (served-world's timeline), who did not make it
141
+ const vendors = action.actor?.kind === 'system' || inVendorMove();
142
+ const traceparent = correlated.traceparent === undefined ? (vendors ? traceparentForDelivery() : currentTraceparent()) : undefined;
140
143
  return traceparent ? { ...correlated, traceparent } : correlated;
141
144
  }
142
145
 
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
18
18
  export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.ts';
19
19
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
20
20
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.ts';
21
- export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
21
+ export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
22
22
  export type { Traceparent } from './trace-context.ts';
23
23
  export type { DerivedCall, DerivedCoreOutcome, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface } from './derived.ts';
24
24
  export { bindSemantics, coreFor, crossCutting, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.ts';
@@ -161,6 +161,7 @@ export {
161
161
  applyTwinWrite,
162
162
  applyTwinWriteAtomic,
163
163
  createTwinServer,
164
+ CALLER_HEADER,
164
165
  journalTwinRequest,
165
166
  twinRequestJournalFailures,
166
167
  twinRequestCredentials,
@@ -168,6 +169,8 @@ export {
168
169
  resolveTwinRead,
169
170
  twinRequestJournalEnabled,
170
171
  twinRequestJournalPath,
172
+ readTwinRequestJournalFrom,
173
+ type TwinRequestJournalCursor,
171
174
  twinResources,
172
175
  } from './serve.ts';
173
176
  export type {
@@ -14,6 +14,7 @@
14
14
  // Created on first use, never at import: a browser bundle of a mirror client carries the kernel and
15
15
  // has no AsyncLocalStorage (see serve.ts identitySlot).
16
16
  import { AsyncLocalStorage } from 'node:async_hooks';
17
+ import { runWithTraceparent, traceparentForDelivery } from './trace-context.ts';
17
18
 
18
19
  /** The request header that marks a request read-only. Its one value is `1`. */
19
20
  export const READ_ONLY_REQUEST_HEADER = 'x-volter-read-only';
@@ -55,9 +56,16 @@ export async function runAsReadOnlyRequest<T>(fn: () => T | Promise<T>): Promise
55
56
  * request. Outside one it changes nothing. */
56
57
  export function runAsVendorMove<T>(fn: () => T): T {
57
58
  const current = writeScope().getStore();
58
- return current ? writeScope().run({ ...current, vendorMove: true }, fn) : fn();
59
+ // the vendor's move is its own span of the request's trace (a delivery's shape): what it writes is joined to the
60
+ // trace, never to the request's caller, who did not make it
61
+ const own = traceparentForDelivery();
62
+ const run = own ? () => runWithTraceparent(own, fn) : fn;
63
+ return current ? writeScope().run({ ...current, vendorMove: true }, run) : run();
59
64
  }
60
65
 
66
+ /** Whether this runs as the vendor's own move (runAsVendorMove). */
67
+ export function inVendorMove(): boolean { return writeScopeStore?.getStore()?.vendorMove === true; }
68
+
61
69
  /** Whether a write attempted here would be refused. */
62
70
  export function writesRefused(): boolean {
63
71
  const scope = writeScopeStore?.getStore();
package/src/serve.ts CHANGED
@@ -20,6 +20,7 @@ import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, pr
20
20
  import type { ActionProjection, TwinAction, TwinActionPrecondition } from './actions.ts';
21
21
  import { observeWorldPaths, worldPaths } from './storage.ts';
22
22
  import { getActiveWorldStore } from './world-store.ts';
23
+ import { parseTraceparent } from './trace-context.ts';
23
24
 
24
25
  // ── the request journal: the INSPECT half of the read path ─────────────────────────────────────
25
26
  // `actions.jsonl` records MUTATIONS only, so a twin's READS are invisible to `volter-world tail`
@@ -75,6 +76,11 @@ export type TwinCredentialShape = {
75
76
  empty?: true;
76
77
  };
77
78
 
79
+ /** The header a World puts on a request it forwards to a twin: who made it (`key:<name>`, `person:<who>`, `token`,
80
+ * `token:read`). The World replaces any a caller sent; the serve seam records it in the twin's request journal and
81
+ * removes it before the twin's handler. A twin reached on its own port, not through a World, records what it was sent. */
82
+ export const CALLER_HEADER = 'x-volter-caller';
83
+
78
84
  export type TwinRequestJournalEntry = {
79
85
  at?: string;
80
86
  method: string;
@@ -84,6 +90,14 @@ export type TwinRequestJournalEntry = {
84
90
  ms?: number;
85
91
  /** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
86
92
  credentials?: TwinCredentialShape[];
93
+ /** who made it, as the World named its caller (CALLER_HEADER: `key:<name>`, `person:<who>`, `token`); absent for a
94
+ * request that did not come through a World */
95
+ caller?: string;
96
+ /** the request's trace-id and parent-id (its `traceparent`). The World mints the parent-id of every request it
97
+ * forwards, and the request's log entries carry it, so an entry is joined to its caller by `span`, which no caller
98
+ * chooses (a trace-id is the caller's and spans requests) */
99
+ trace?: string;
100
+ span?: string;
87
101
  /** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
88
102
  * audit cannot be written is refused); its outcome is the entry without a phase written after. */
89
103
  phase?: 'request';
@@ -130,6 +144,41 @@ export function twinRequestJournalFailures(service: string, root?: string): numb
130
144
  }
131
145
 
132
146
 
147
+ /** Where a reader of a journal stopped: the newest closed segment it had seen and the bytes of the live file it read. */
148
+ export type TwinRequestJournalCursor = { segment: number; offset: number };
149
+
150
+ /** The journal's lines after `cursor` (all of them without one), and the cursor to read on from. A rotation closes
151
+ * the live file into the next segment as it stood, so a reader that stopped in the live file goes on in that
152
+ * segment at its offset, then any later segments, and the next read takes the live file from its start; a live file shorter than where
153
+ * the reader stopped (read between a rotation's two writes) is read again from its start, its lines repeated rather
154
+ * than lost. Only whole lines are read (a line being appended is read next time). Without a cursor, the newest
155
+ * `segments` closed segments are read (all of them when omitted), then the live file. */
156
+ export function readTwinRequestJournalFrom(service: string, root: string | undefined, cursor: TwinRequestJournalCursor | null, options: { segments?: number } = {}): { entries: TwinRequestJournalEntry[]; cursor: TwinRequestJournalCursor } {
157
+ const path = twinRequestJournalPath(service, root); const store = getActiveWorldStore();
158
+ const segments = journalSegments(path).map((file) => Number(file.slice(path.length + 1)));
159
+ const newest = segments[segments.length - 1] ?? 0;
160
+ const entries: TwinRequestJournalEntry[] = [];
161
+ const take = (file: string, start: number, whole: boolean): number => {
162
+ const text = store.readRange ? store.readRange(file, start) : ((t) => (t === null ? null : Buffer.from(t, 'utf8').subarray(start).toString('utf8')))(store.read(file));
163
+ if (!text) return start;
164
+ const end = whole ? text.length : text.lastIndexOf('\n') + 1;
165
+ for (const line of text.slice(0, end).split('\n')) { if (!line) continue; try { entries.push(JSON.parse(line) as TwinRequestJournalEntry); } catch { /* a torn line */ } }
166
+ return start + Buffer.byteLength(text.slice(0, end), 'utf8');
167
+ };
168
+ if (cursor === null) {
169
+ for (const n of options.segments === undefined ? segments : segments.slice(-options.segments)) take(`${path}.${n}`, 0, true);
170
+ return { entries, cursor: { segment: newest, offset: take(path, 0, false) } };
171
+ }
172
+ if (newest === cursor.segment) {
173
+ const from = (store.stat(path)?.size ?? 0) < cursor.offset ? 0 : cursor.offset;
174
+ return { entries, cursor: { segment: newest, offset: take(path, from, false) } };
175
+ }
176
+ for (const n of segments) if (n > cursor.segment) take(`${path}.${n}`, n === cursor.segment + 1 ? cursor.offset : 0, true);
177
+ // the live file is read next time, from its start: read now, it may be the one the rotation has not emptied yet,
178
+ // and an offset into it would land inside the new one
179
+ return { entries, cursor: { segment: newest, offset: 0 } };
180
+ }
181
+
133
182
  /** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
134
183
  * recent period reads only the segments that reach into it. */
135
184
  const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
@@ -212,6 +261,9 @@ export function journalTwinRequest(service: string, entry: TwinRequestJournalEnt
212
261
  const line = JSON.stringify({
213
262
  at: entry.at ?? new Date().toISOString(),
214
263
  ...(entry.phase ? { phase: entry.phase } : {}),
264
+ ...(entry.caller ? { caller: entry.caller.slice(0, 120) } : {}),
265
+ ...(entry.trace && /^[0-9a-f]{32}$/.test(entry.trace) ? { trace: entry.trace } : {}),
266
+ ...(entry.span && /^[0-9a-f]{16}$/.test(entry.span) ? { span: entry.span } : {}),
215
267
  method: entry.method.toUpperCase(),
216
268
  path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
217
269
  status: entry.status,
@@ -349,6 +401,9 @@ function journalingFetch(config: Record<string | symbol, unknown> & { fetch: (..
349
401
  let identity: TwinJournalIdentity | undefined = declared;
350
402
  const inner = config.fetch as (this: unknown, request: Request, server: unknown) => unknown;
351
403
  const journaling = async function (this: unknown, request: Request, server: unknown) {
404
+ // who made it is read here and goes no further: no twin's handler (a tunnel's, a proxy's) hands it on
405
+ const caller = request.headers.get(CALLER_HEADER) ?? undefined;
406
+ if (caller !== undefined) { const headers = new Headers(request.headers); headers.delete(CALLER_HEADER); request = new Request(request, { headers }); }
352
407
  // The World's own boot probe (serve-http.ts, WORLD_BOOT_PATH) is not the app's traffic: never journaled.
353
408
  if (!twinRequestJournalEnabled() || new URL(request.url).pathname === WORLD_BOOT_PATH) return inner.call(this, request, server);
354
409
  const slot: { identity?: TwinJournalIdentity } = {};
@@ -366,12 +421,15 @@ function journalingFetch(config: Record<string | symbol, unknown> & { fetch: (..
366
421
  const resolved = identity ?? fallbackIdentity(callSiteVendor);
367
422
  if (resolved) {
368
423
  const url = new URL(request.url);
424
+ const parent = parseTraceparent(request.headers.get('traceparent'));
369
425
  journalTwinRequest(resolved.service, {
370
426
  method: request.method,
371
427
  path: url.pathname,
372
428
  status,
373
429
  ms: Date.now() - startedAt,
374
430
  credentials: twinRequestCredentials(request.headers, url),
431
+ ...(caller ? { caller } : {}),
432
+ ...(parent ? { trace: parent.traceId, span: parent.parentId } : {}),
375
433
  }, resolved.root);
376
434
  }
377
435
  }
@@ -59,6 +59,15 @@ export function runWithRequestTrace<T>(request: Request, fn: () => T): T {
59
59
  return runWithTraceparent(request.headers.get(TRACEPARENT_HEADER) ?? undefined, fn);
60
60
  }
61
61
 
62
+ /** A new trace's `traceparent` (version 00, a fresh trace-id and parent-id, sampled): for a request that carried none,
63
+ * so what it causes can be told apart and joined to it. */
64
+ export function newTraceparent(): string {
65
+ const bytes = new Uint8Array(16);
66
+ let trace = '';
67
+ do { globalThis.crypto.getRandomValues(bytes); trace = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } while (/^0+$/.test(trace));
68
+ return `00-${trace}-${newSpanId()}-01`;
69
+ }
70
+
62
71
  function newSpanId(): string {
63
72
  const bytes = new Uint8Array(8);
64
73
  for (;;) {