@volter/world-core 2.0.1 → 2.0.2

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.
@@ -87,6 +87,9 @@ export type TwinAction = {
87
87
  * alone, with no dependence on actionId/pushId naming conventions.
88
88
  */
89
89
  correlationId?: string;
90
+ /** The W3C `traceparent` of the request that caused this action, when it carried a valid one
91
+ * (trace-context.ts). Authoring metadata like correlationId: excluded from replay identity. */
92
+ traceparent?: string;
90
93
  };
91
94
  export type TwinTransactionCommit = TwinAction;
92
95
  export type TwinTransactionCommitOp = TwinActionOp;
@@ -19,7 +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
23
  import { getActiveWorldStore } from "./world-store.js";
24
+ import { currentTraceparent } from "./trace-context.js";
23
25
  export class TwinActionPreconditionError extends Error {
24
26
  actionId;
25
27
  failed;
@@ -43,7 +45,10 @@ function withCorrelationId(action) {
43
45
  // The wire's request id (the addressed service stamps every response with one and threads it
44
46
  // into the handler's async context) is the correlation when the caller supplies none — the
45
47
  // join from a request on the wire to the action rows it caused, with no pack involved.
46
- return action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
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;
51
+ return traceparent ? { ...correlated, traceparent } : correlated;
47
52
  }
48
53
  /** Subjects a `set` action touches: its own subject, every projection resource, and every
49
54
  * precondition subject — a precondition established the action's validity against that
@@ -114,7 +119,7 @@ function landIfPlaceholderPull(action, root) {
114
119
  return { ...action, id: events.at(-1)?.id ?? action.id };
115
120
  }
116
121
  function replayBody(action) {
117
- const { correlationId: _correlationId, ...stampedBody } = action;
122
+ const { correlationId: _correlationId, traceparent: _traceparent, ...stampedBody } = action;
118
123
  // A confirmation is content-addressed by the observed event ids. Its timestamp is observation
119
124
  // metadata, just like the occurredAt/observedAt values that appendEvent ignores when replaying
120
125
  // one content-addressed event. Two reconcilers confirming the same bytes at different wall-clock
@@ -145,6 +150,7 @@ const correlationScope = () => (correlationStore ??= new AsyncLocalStorage());
145
150
  export function runWithCorrelationId(id, fn) { return correlationScope().run(id, fn); }
146
151
  export function currentCorrelationId() { return correlationScope().getStore(); }
147
152
  export function appendAction(action, root) {
153
+ refuseReadOnlyWrite(action.service); // a read-only request writes nothing (request-scope.ts)
148
154
  // Evaluate and append under the service projection and actions locks. A precondition is a
149
155
  // compare-and-set, not an advisory validation: checking outside either lock would let another
150
156
  // writer invalidate it before this action lands. The shadow basis is stamped under the same
@@ -164,6 +170,7 @@ export function appendAction(action, root) {
164
170
  * atomic across processes (two concurrent identical writes converge to ONE action; distinct
165
171
  * writes both land). A reused id with different content fails loudly. */
166
172
  export function appendActionIfAbsent(action, root) {
173
+ refuseReadOnlyWrite(action.service);
167
174
  return withFileLock(projectionLock(action.service, root), () => withFileLock(actionsLock(action.service, root), () => {
168
175
  const identified = withCorrelationId(action);
169
176
  // An exact retry is already committed. Resolve it before re-evaluating author-time
@@ -198,6 +205,7 @@ export function appendActionIfAbsent(action, root) {
198
205
  * the same ordinals, which is what serve-path determinism requires.
199
206
  */
200
207
  export function appendActionOccurrence(base, root) {
208
+ refuseReadOnlyWrite(base.service);
201
209
  return withFileLock(projectionLock(base.service, root), () => withFileLock(actionsLock(base.service, root), () => {
202
210
  const stamped = occurrenceOf(base, actionIds(base.service, root), root);
203
211
  assertPreconditions(stamped, root);
@@ -241,6 +249,7 @@ export function decideAndAppendAction(service, decide, root) {
241
249
  const decision = decide(resources);
242
250
  if (decision.kind === 'skip')
243
251
  return { value: decision.value, appended: false };
252
+ refuseReadOnlyWrite(service); // a decision to write is the write a read-only request may not make
244
253
  if (decision.action.service !== service) {
245
254
  throw new Error(`Atomic action service mismatch: expected ${service}, got ${decision.action.service}`);
246
255
  }
@@ -401,6 +410,7 @@ export function resolveSubjectId(service, type, id, root) {
401
410
  * the change is counted exactly once. Returns the observed event id.
402
411
  */
403
412
  export function confirmAction(opts) {
413
+ refuseReadOnlyWrite(opts.service);
404
414
  // LANDING (log.ts): the entry is copied to the parent log with its receipt — no confirm row, no
405
415
  // suppression; the fold skips a branch entry the parent holds.
406
416
  const paths = worldPaths(opts.service, opts.root);
@@ -431,6 +441,7 @@ export function confirmAction(opts) {
431
441
  * Only `set` rows are revertable — reverting a revert or a confirm is a category error.
432
442
  */
433
443
  export function revertAction(opts) {
444
+ refuseReadOnlyWrite(opts.service);
434
445
  return withFileLock(projectionLock(opts.service, opts.root), () => withFileLock(actionsLock(opts.service, opts.root), () => {
435
446
  const all = listActions(opts.service, opts.root);
436
447
  const target = all.find((a) => a.id === opts.actionId);
@@ -50,6 +50,9 @@ export declare class MemoryBlobStore implements BlobStore {
50
50
  }
51
51
  /** Content-address helper shared by byte-carrying packs: sha256 hex of the bytes. */
52
52
  export declare function blobDigest(bytes: Uint8Array): string;
53
+ /** The active store; under a read-only request's refusing scope (request-scope.ts), one that refuses
54
+ * to store or remove bytes before any reach it, as the log's appenders refuse (a git push's objects,
55
+ * an upload's body), while reads pass. */
53
56
  export declare function getActiveBlobStore(): BlobStore;
54
57
  export declare function setActiveBlobStore(store: BlobStore): BlobStore;
55
58
  export declare function withBlobStore<T>(store: BlobStore, fn: () => Promise<T> | T): Promise<T>;
@@ -23,6 +23,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync,
23
23
  import { dirname, join } from 'node:path';
24
24
  import { volterHome } from "./volter-home.js";
25
25
  import { sharedBlobIndex } from "./shared-blob-index.js";
26
+ import { refuseReadOnlyWrite, writesRefused } from "./request-scope.js";
26
27
  /** A range of the blob at `key` through the active store: native when the store has it, else sliced. */
27
28
  export async function readBlobRange(key, start, endInclusive) {
28
29
  // one clamping for every backend: a negative or non-finite start reads from 0, a range that ends
@@ -167,8 +168,21 @@ export function blobDigest(bytes) {
167
168
  // Active-store scoping, the world-store.ts pattern exactly: default fs; a serverless entry
168
169
  // (or a test) swaps in its adapter for the scope of a request.
169
170
  let activeBlobStore = new FsBlobStore();
171
+ /** The active store; under a read-only request's refusing scope (request-scope.ts), one that refuses
172
+ * to store or remove bytes before any reach it, as the log's appenders refuse (a git push's objects,
173
+ * an upload's body), while reads pass. */
170
174
  export function getActiveBlobStore() {
171
- return activeBlobStore;
175
+ return writesRefused() ? readOnlyBlobs(activeBlobStore) : activeBlobStore;
176
+ }
177
+ function readOnlyBlobs(store) {
178
+ return {
179
+ get: (key) => store.get(key),
180
+ exists: (key) => store.exists(key),
181
+ list: (prefix) => store.list(prefix),
182
+ size: (key) => store.size(key),
183
+ put: async () => { refuseReadOnlyWrite('blobs'); },
184
+ remove: async () => { refuseReadOnlyWrite('blobs'); },
185
+ };
172
186
  }
173
187
  export function setActiveBlobStore(store) {
174
188
  const previous = activeBlobStore;
@@ -1 +1,5 @@
1
+ /** A file URL's path on this machine, without `node:url` (the modules that call it ride into browser
2
+ * bundles): percent-escapes decoded, and without the slash `URL.pathname` puts before a Windows drive
3
+ * (`/C:/…`), which no file API opens. `new URL('../client/x.css', import.meta.url).pathname` is this. */
4
+ export declare function filePathOf(url: URL): string;
1
5
  export declare function bundleClient(entry: string): Promise<string>;
@@ -4,7 +4,18 @@
4
4
  // by scripts/publish/build.mjs into dist/client/. Built once per process and kept; a failed build
5
5
  // is retried on the next call. Nothing here runs at module scope.
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
+ /** A file URL's path on this machine, without `node:url` (the modules that call it ride into browser
8
+ * bundles): percent-escapes decoded, and without the slash `URL.pathname` puts before a Windows drive
9
+ * (`/C:/…`), which no file API opens. `new URL('../client/x.css', import.meta.url).pathname` is this. */
10
+ export function filePathOf(url) {
11
+ const path = decodeURIComponent(url.pathname);
12
+ return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
13
+ }
7
14
  const bundles = new Map();
15
+ /** A browser has no `process`. A client that imports shared helpers from its pack's server-side module can pull Node
16
+ * code into its bundle (util.deprecate, crypto shims) that reads it; without a shim the page throws before it renders
17
+ * (newer Bun no longer adds one to a browser build). A minimal one, the page's own, before the bundle. */
18
+ const PROCESS_SHIM = 'globalThis.process=globalThis.process||{env:{NODE_ENV:"production"},browser:true,argv:[],versions:{},platform:"browser",emit:function(){return false},on:function(){},nextTick:function(f){var a=[].slice.call(arguments,1);queueMicrotask(function(){f.apply(null,a)})}};\n';
8
19
  export function bundleClient(entry) {
9
20
  let pending = bundles.get(entry);
10
21
  if (!pending) {
@@ -13,13 +24,13 @@ export function bundleClient(entry) {
13
24
  ? bun.build({ entrypoints: [entry], target: 'browser', minify: true }).then(async (result) => {
14
25
  if (!result.success)
15
26
  throw new Error(result.logs.map((l) => l.message).join('\n') || `client build failed: ${entry}`);
16
- return result.outputs[0].text();
27
+ return PROCESS_SHIM + await result.outputs[0].text();
17
28
  })
18
29
  : Promise.resolve().then(() => {
19
30
  const prebuilt = entry.replace(/\.tsx?$/, '.bundle.js');
20
31
  if (!existsSync(prebuilt))
21
32
  throw new Error(`client bundle missing at ${prebuilt} — the package's \`build\` writes it (scripts/publish/build.mjs); Node serves the prebuilt client`);
22
- return readFileSync(prebuilt, 'utf8');
33
+ return PROCESS_SHIM + readFileSync(prebuilt, 'utf8');
23
34
  }))
24
35
  .catch((error) => { bundles.delete(entry); throw error; });
25
36
  bundles.set(entry, pending);
@@ -447,6 +447,12 @@ export declare function coreFor(m: DerivedManifest, scope?: CoreScope): {
447
447
  export declare function crossCutting(m: DerivedManifest, opts?: CoreScope & {
448
448
  readOnly?: boolean;
449
449
  }): (call: DerivedCall, next: () => Promise<Response>) => Promise<Response>;
450
+ /** A derived pack's whole fetch behind the kernel's read scope (twin-fetch.ts withRequestScopes): a
451
+ * read-only request's writes are refused at the write seam — the ones `crossCutting` cannot see
452
+ * coming (another wire's mutation, a door, a screen) — and answered with the vendor's own read-only
453
+ * error; `GET /twin` advertises `requestScopes`. The pack's catch-up runs under `runAsVendorMove`
454
+ * (request-scope.ts), so time's moves still land when a read-only request is what arrives. */
455
+ export declare function derivedRequestScopes<F extends (request: Request) => Promise<Response>>(m: DerivedManifest, fetch: F): F;
450
456
  /** A semantics context for a request no surface operation names: another wire's (GraphQL) resolvers
451
457
  * get the same interface as a handler, named by the operation id the wire gives. */
452
458
  export declare function semanticsContext(m: DerivedManifest, request: Request, operation: DerivedOperation, scope?: CoreScope): Promise<SemanticsContext>;
@@ -12,7 +12,8 @@ import { createHash } from 'node:crypto';
12
12
  import { hashFieldValue } from "./hash.js";
13
13
  import { resolveSubjectId, subjectAliases } from "./actions.js";
14
14
  import { packReferences } from "./references.js";
15
- import { twinPublicBase } from "./twin-fetch.js";
15
+ import { twinPublicBase, withRequestScopes } from "./twin-fetch.js";
16
+ import { isReadOnlyRequest } from "./request-scope.js";
16
17
  /** What a move to `to` stores in the field: a boolean field's value is a boolean, and a derived field's
17
18
  * value is written by the move's effects (a state name is not a timestamp), so it stores nothing itself. */
18
19
  export function storedState(decl, to) {
@@ -732,8 +733,10 @@ export function crossCutting(m, opts = {}) {
732
733
  }
733
734
  }
734
735
  // a read-only twin refuses writes: what the operation does, not the HTTP verb it came by (an RPC
735
- // wire POSTs its reads)
736
- if (opts.readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id))
736
+ // wire POSTs its reads). A read-only REQUEST (x-volter-read-only, request-scope.ts) is refused the
737
+ // same way, up front, on a writable twin.
738
+ const readOnly = opts.readOnly || isReadOnlyRequest(request);
739
+ if (readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id))
737
740
  return vendorError(m, m.readOnly);
738
741
  // a body labelled JSON that does not parse is the vendor's refusal, never a crash of the twin
739
742
  if ((request.headers.get('content-type') ?? '').includes('json') || m.body.json === 'always') {
@@ -766,6 +769,9 @@ export function crossCutting(m, opts = {}) {
766
769
  // a streamed answer replays as the stream it was; a record kept before text was kept replays its JSON
767
770
  return answer.text !== undefined ? new Response(answer.text, { status: answer.status, headers: { 'content-type': answer.contentType ?? 'application/json' } }) : Response.json(answer.body, { status: answer.status });
768
771
  }
772
+ // a read-only request replays a stored answer but records none: recording is a write
773
+ if (readOnly)
774
+ return next();
769
775
  const response = await next();
770
776
  if (m.idempotency.onlySuccess && (response.status < 200 || response.status >= 300))
771
777
  return response;
@@ -775,6 +781,14 @@ export function crossCutting(m, opts = {}) {
775
781
  return response;
776
782
  }
777
783
  }
784
+ /** A derived pack's whole fetch behind the kernel's read scope (twin-fetch.ts withRequestScopes): a
785
+ * read-only request's writes are refused at the write seam — the ones `crossCutting` cannot see
786
+ * coming (another wire's mutation, a door, a screen) — and answered with the vendor's own read-only
787
+ * error; `GET /twin` advertises `requestScopes`. The pack's catch-up runs under `runAsVendorMove`
788
+ * (request-scope.ts), so time's moves still land when a read-only request is what arrives. */
789
+ export function derivedRequestScopes(m, fetch) {
790
+ return withRequestScopes(fetch, { refuse: () => vendorError(m, m.readOnly) });
791
+ }
778
792
  /** A semantics context for a request no surface operation names: another wire's (GraphQL) resolvers
779
793
  * get the same interface as a handler, named by the operation id the wire gives. */
780
794
  export function semanticsContext(m, request, operation, scope = {}) {
@@ -5,6 +5,7 @@
5
5
  // existing fetch, which keeps serving everything not yet moved (and every route outside the spec, such
6
6
  // as the `/twin` door). `owners` says, per operation, which of the two serves it: the count a pack's
7
7
  // move is measured by. Workerd-clean: no fs, no clock, nothing at import.
8
+ import { runWithRequestTrace } from "./trace-context.js";
8
9
  class Unmodeled extends Error {
9
10
  }
10
11
  function compile(operation, basePath, spanning) {
@@ -85,7 +86,9 @@ export function createDerivedFetch(options) {
85
86
  throw new Error(`derived dispatch: handlers name operations the surface does not have: ${unknown.sort().join(', ')}`);
86
87
  const routes = compileSurface(options.surface);
87
88
  const fallback = (request, operation, reason) => options.legacy ? options.legacy(request) : Promise.resolve(options.gap(request, operation, reason));
88
- const fetch = (async (request) => {
89
+ // a valid incoming W3C traceparent scopes the dispatch, so every entry it appends records it (trace-context.ts)
90
+ const fetch = ((request) => runWithRequestTrace(request, () => dispatch(request)));
91
+ const dispatch = async (request) => {
89
92
  const url = new URL(request.url);
90
93
  const matched = options.anyMethod
91
94
  ? [...routes.keys()].map((m) => matchOperation(routes, m, url.pathname, url.searchParams, request.headers)).find(Boolean)
@@ -116,7 +119,7 @@ export function createDerivedFetch(options) {
116
119
  }
117
120
  }
118
121
  return fallback(request, matched.operation, 'no handler');
119
- });
122
+ };
120
123
  fetch.owners = () => Object.fromEntries(options.surface.operations.map((o) => [o.id, handlers[o.id] ? 'handler' : options.core?.owns(o) ? 'core' : options.legacy ? 'legacy' : 'gap']));
121
124
  return fetch;
122
125
  }
@@ -1,3 +1,4 @@
1
+ import { refuseReadOnlyWrite } from "../request-scope.js";
1
2
  export class GitRefs {
2
3
  store;
3
4
  prefix;
@@ -7,11 +8,12 @@ export class GitRefs {
7
8
  }
8
9
  path(name) { return `${this.prefix}/${name}`; }
9
10
  get(name) { const v = this.store.read(this.path(name)); return v === null ? null : v.trim(); }
10
- set(name, sha) { this.store.write(this.path(name), `${sha}\n`); }
11
- delete(name) { if (this.store.exists(this.path(name)))
11
+ // a read-only request moves no ref (request-scope.ts): refused before the store is touched
12
+ set(name, sha) { refuseReadOnlyWrite('git'); this.store.write(this.path(name), `${sha}\n`); }
13
+ delete(name) { refuseReadOnlyWrite('git'); if (this.store.exists(this.path(name)))
12
14
  this.store.remove(this.path(name)); }
13
15
  head() { const v = this.store.read(this.path('HEAD')); return v === null ? 'refs/heads/main' : v.trim().replace(/^ref: /, ''); }
14
- setHead(target) { this.store.write(this.path('HEAD'), `ref: ${target}\n`); }
16
+ setHead(target) { refuseReadOnlyWrite('git'); this.store.write(this.path('HEAD'), `ref: ${target}\n`); }
15
17
  /** Run `fn` holding the repository's ref lock (the world store's exclusive lock). */
16
18
  withLock(fn) { return this.store.withLock(this.path('refs.lock'), fn); }
17
19
  /** Every `refs/...` name with its sha, sorted by name. */
package/dist/src/head.js CHANGED
@@ -17,7 +17,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
17
17
  // record: a `deployed` copy is never performed again, a `failed` one is retried.
18
18
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { homedir } from 'node:os';
20
- import { basename, dirname, join, resolve } from 'node:path';
20
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
21
21
  import { confirmAction, isTwinBookkeeping, resolveSubjectId, revertAction } from "./actions.js";
22
22
  import { openSealedCredential, sealCredential } from "./credential.js";
23
23
  import { buildRemoteExecute, validateRemoteOrigin } from "./executor.js";
@@ -101,12 +101,22 @@ export async function loadChecks(worldRoot) {
101
101
  return out;
102
102
  }
103
103
  const importCheck = async (path, name) => {
104
- const mod = (await import(__rewriteRelativeImportExtension(path)));
104
+ // Node's ESM loader takes an absolute path only as a file URL (on Windows `C:\…` reads as the scheme `c:`); Bun takes either
105
+ const mod = (await import(__rewriteRelativeImportExtension(isAbsolute(path) ? fileUrlOf(path) : path)));
105
106
  const check = mod.check ?? mod.default;
106
107
  if (!check || typeof check.run !== 'function')
107
108
  throw new Error(`${path} must export a check { name, run }`);
108
109
  return { name: check.name ?? name, run: check.run };
109
110
  };
111
+ /** An absolute path's file URL, built here: `node:url` is not in the browser bundles that also reach this module. */
112
+ const fileUrlOf = (path) => {
113
+ // a long path (`\\?\C:\…`) is the drive path it names; a UNC path (`\\server\share\…`) puts the server in the URL's host
114
+ const long = /^\\\\\?\\(?:UNC\\)?/i.exec(path);
115
+ const unc = /^\\\\\?\\UNC\\/i.test(path) || (!long && /^\\\\[^\\?]/.test(path));
116
+ const rest = (long ? path.slice(long[0].length) : unc ? path.slice(2) : path).replace(/\\/g, '/');
117
+ const encoded = rest.split('/').map((seg) => encodeURIComponent(seg).replace(/%3A/g, ':')).join('/');
118
+ return unc ? `file://${encoded}` : `file://${rest.startsWith('/') ? '' : '/'}${encoded}`;
119
+ };
110
120
  let checkLoader = importCheck;
111
121
  export function setCheckLoader(loader) { checkLoader = loader ?? importCheck; }
112
122
  /** One check file through the host's loader: what placing a check validates with. */
@@ -3,10 +3,13 @@ export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrit
3
3
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.js';
4
4
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.js';
5
5
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.js';
6
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, twinPublicBase } from './twin-fetch.js';
6
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.js';
7
+ export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.js';
7
8
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.js';
9
+ export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
10
+ export type { Traceparent } from './trace-context.js';
8
11
  export type { DerivedCall, DerivedCoreOutcome, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface } from './derived.js';
9
- export { bindSemantics, coreFor, crossCutting, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.js';
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';
10
13
  export type { Actor, CoreScope, DerivedManifest, ErrorSpec, FieldRule, ResourceDecl, ScreenDecl, Semantics, SemanticsContext, StateField, Transition, TransitionObserver } from './derived-core.js';
11
14
  export type { RemoteExecute, RemoteExecuteRequest, RemoteExecuteResponse } from './remote-execute.js';
12
15
  export type { TwinFetchAdapterConfig, TwinFetchHandlerRequest, TwinFetchHandlerResult, TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.js';
@@ -62,7 +65,7 @@ export * from './v1-removed.js';
62
65
  export { referenceField, registerReferences, packReferences, resolveReferences, type ReferenceDeclaration } from './references.js';
63
66
  export { serveHttp, nodeBuiltin, WORLD_BOOT_PATH, type HttpServer, type ServeHttpOptions, type HttpHandler } from './serve-http.js';
64
67
  export { fileResponse, contentTypeOf } from './file-response.js';
65
- export { bundleClient } from './client-bundle.js';
68
+ export { bundleClient, filePathOf } from './client-bundle.js';
66
69
  export { brandTokensResponse } from './brand-tokens.js';
67
70
  export { readResourceBlob, readResourceBlobRange, resourceBlobSize, resourceChain } from './resource-blob.js';
68
71
  export { mirrorShellUnder } from './mirror-shell.js';
package/dist/src/index.js CHANGED
@@ -14,9 +14,11 @@ export { clearRegistry, getPack, hasPack, listPacks, registerPack, pullPosture,
14
14
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from "./scenario.js";
15
15
  export { WORLD_CLOCK_ENV, worldNow } from "./world-clock.js";
16
16
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from "./world-env.js";
17
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, twinPublicBase } from "./twin-fetch.js";
17
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from "./twin-fetch.js";
18
+ export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
18
19
  export { compileSurface, createDerivedFetch, matchOperation } from "./derived.js";
19
- export { bindSemantics, coreFor, crossCutting, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from "./derived-core.js";
20
+ export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
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";
20
22
  export { emitTwinEvent, eventSubscriptionMatches, listEmittable, runEmitCli } from "./emit.js";
21
23
  // The vendor-agnostic CLIENT-SIDE RATE BUDGET — the fail-closed backstop a pack's guarded client
22
24
  // routes every live vendor call through. The MECHANISM is here; the per-vendor ceiling/window/
@@ -89,7 +91,7 @@ export { referenceField, registerReferences, packReferences, resolveReferences }
89
91
  export { serveHttp, nodeBuiltin, WORLD_BOOT_PATH } from "./serve-http.js";
90
92
  // two runtime-neutral helpers for a pack's serve path: a file as a Response, a mirror's client bundle
91
93
  export { fileResponse, contentTypeOf } from "./file-response.js";
92
- export { bundleClient } from "./client-bundle.js";
94
+ export { bundleClient, filePathOf } from "./client-bundle.js";
93
95
  // the brand's tokens and faces for a Volter page (the console, the site, the UI kit), fetched at build
94
96
  export { brandTokensResponse } from "./brand-tokens.js";
95
97
  export { readResourceBlob, readResourceBlobRange, resourceBlobSize, resourceChain } from "./resource-blob.js";
package/dist/src/log.d.ts CHANGED
@@ -64,6 +64,8 @@ export type Entry = {
64
64
  /** a landed copy under the vendor's id: the local subject id it stands for */
65
65
  aliasOf?: string;
66
66
  receipt?: Receipt;
67
+ /** the W3C traceparent of the request that wrote this entry (trace-context.ts); never part of its identity */
68
+ traceparent?: string;
67
69
  /** a v1 observed row kept verbatim for `listEvents`; folds nothing unless `fields` was derived */
68
70
  event?: WorldServiceEvent;
69
71
  [extra: string]: unknown;
@@ -0,0 +1,28 @@
1
+ /** The request header that marks a request read-only. Its one value is `1`. */
2
+ export declare const READ_ONLY_REQUEST_HEADER = "x-volter-read-only";
3
+ /** Whether a request carries the read-only marker. */
4
+ export declare function isReadOnlyRequest(request: Request | {
5
+ headers: Headers;
6
+ }): boolean;
7
+ /** A write a read-only request attempted: refused before anything was written (rule D3's 405). */
8
+ export declare class ReadOnlyRequestError extends Error {
9
+ readonly service: string;
10
+ constructor(service: string);
11
+ }
12
+ /** Run `fn` as a read-only request: every write it attempts outside a vendor move is refused. The
13
+ * answer says whether one was — also when the handler caught the refusal and answered something
14
+ * else, so a caller can answer the refusal whatever the handler made of it. */
15
+ export declare function runAsReadOnlyRequest<T>(fn: () => T | Promise<T>): Promise<{
16
+ value: T;
17
+ refused: undefined;
18
+ } | {
19
+ refused: ReadOnlyRequestError;
20
+ }>;
21
+ /** Run `fn` as the VENDOR's own move (a pack's catch-up): its writes land even under a read-only
22
+ * request. Outside one it changes nothing. */
23
+ export declare function runAsVendorMove<T>(fn: () => T): T;
24
+ /** Whether a write attempted here would be refused. */
25
+ export declare function writesRefused(): boolean;
26
+ /** The write seam's check: throws (and records) the refusal when a write attempted here is a
27
+ * read-only request's. Called by every appender before it writes anything. */
28
+ export declare function refuseReadOnlyWrite(service: string): void;
@@ -0,0 +1,70 @@
1
+ // THE READ-ONLY REQUEST (docs/contributing/architecture.md, "Viewing a World"): a request that
2
+ // carries `x-volter-read-only: 1` is served with the twin's writes refused — whatever operation it
3
+ // names and whatever HTTP method it came by (an RPC wire POSTs its reads). The marker only ever
4
+ // RESTRICTS, so the twin trusts it without auth: a caller who sends it only limits itself. The
5
+ // World's doors set it on every read-scope request and never classify operations themselves.
6
+ //
7
+ // Enforcement lives at the kernel's write seam: every appender (actions.ts, storage.ts appendEvent)
8
+ // asks `refuseReadOnlyWrite` before it writes anything, so the first write a read-only request
9
+ // attempts is refused and nothing is partially applied. The VENDOR's own moves are not the caller's
10
+ // writes: a pack's catch-up (a renewal, a payout, a file's expiry falling due by the World clock)
11
+ // runs under `runAsVendorMove` and still lands under a read-only request, as a real vendor renews a
12
+ // subscription whoever is looking.
13
+ //
14
+ // Created on first use, never at import: a browser bundle of a mirror client carries the kernel and
15
+ // has no AsyncLocalStorage (see serve.ts identitySlot).
16
+ import { AsyncLocalStorage } from 'node:async_hooks';
17
+ /** The request header that marks a request read-only. Its one value is `1`. */
18
+ export const READ_ONLY_REQUEST_HEADER = 'x-volter-read-only';
19
+ /** Whether a request carries the read-only marker. */
20
+ export function isReadOnlyRequest(request) {
21
+ return request.headers.get(READ_ONLY_REQUEST_HEADER)?.trim() === '1';
22
+ }
23
+ /** A write a read-only request attempted: refused before anything was written (rule D3's 405). */
24
+ export class ReadOnlyRequestError extends Error {
25
+ service;
26
+ constructor(service) {
27
+ super(`${service}: this request is read-only (${READ_ONLY_REQUEST_HEADER}: 1); it cannot write`);
28
+ this.service = service;
29
+ this.name = 'ReadOnlyRequestError';
30
+ }
31
+ }
32
+ let writeScopeStore;
33
+ const writeScope = () => (writeScopeStore ??= new AsyncLocalStorage());
34
+ /** Run `fn` as a read-only request: every write it attempts outside a vendor move is refused. The
35
+ * answer says whether one was — also when the handler caught the refusal and answered something
36
+ * else, so a caller can answer the refusal whatever the handler made of it. */
37
+ export async function runAsReadOnlyRequest(fn) {
38
+ const scope = { readOnly: true, vendorMove: false, refusals: [] };
39
+ try {
40
+ const value = await writeScope().run(scope, fn);
41
+ const refused = scope.refusals[0];
42
+ return refused ? { refused } : { value, refused: undefined };
43
+ }
44
+ catch (error) {
45
+ const refused = scope.refusals[0] ?? (error instanceof ReadOnlyRequestError ? error : undefined);
46
+ if (refused)
47
+ return { refused };
48
+ throw error;
49
+ }
50
+ }
51
+ /** Run `fn` as the VENDOR's own move (a pack's catch-up): its writes land even under a read-only
52
+ * request. Outside one it changes nothing. */
53
+ export function runAsVendorMove(fn) {
54
+ const current = writeScope().getStore();
55
+ return current ? writeScope().run({ ...current, vendorMove: true }, fn) : fn();
56
+ }
57
+ /** Whether a write attempted here would be refused. */
58
+ export function writesRefused() {
59
+ const scope = writeScopeStore?.getStore();
60
+ return scope !== undefined && scope.readOnly && !scope.vendorMove;
61
+ }
62
+ /** The write seam's check: throws (and records) the refusal when a write attempted here is a
63
+ * read-only request's. Called by every appender before it writes anything. */
64
+ export function refuseReadOnlyWrite(service) {
65
+ if (!writesRefused())
66
+ return;
67
+ const error = new ReadOnlyRequestError(service);
68
+ writeScopeStore.getStore().refusals.push(error);
69
+ throw error;
70
+ }
@@ -3,6 +3,7 @@ import { assertNotBeingRemoved, withAncestryLock, withStateRemoval } from "./anc
3
3
  import { appendParentEntry, toEntry } from "./log.js";
4
4
  import { GenericWorldStateSchema, WorldServiceEventSchema, } from "./schemas.js";
5
5
  import { getActiveWorldStore } from "./world-store.js";
6
+ import { refuseReadOnlyWrite } from "./request-scope.js";
6
7
  import { parentEntries, toEvent } from "./log.js";
7
8
  function nowIso() {
8
9
  return new Date().toISOString();
@@ -226,6 +227,7 @@ export function projectionLockPath(paths) {
226
227
  */
227
228
  export function appendEvent(event, root) {
228
229
  const parsed = WorldServiceEventSchema.parse(event);
230
+ refuseReadOnlyWrite(parsed.service); // a read-only request writes nothing (request-scope.ts)
229
231
  const paths = worldPaths(parsed.service, root);
230
232
  ensureEventDirs(paths);
231
233
  const { appended } = appendParentEntry(toEntry(parsed), root);
@@ -0,0 +1,31 @@
1
+ export declare const TRACEPARENT_HEADER = "traceparent";
2
+ export type Traceparent = {
3
+ version: string;
4
+ traceId: string;
5
+ parentId: string;
6
+ flags: string;
7
+ };
8
+ /** A `traceparent` header value parsed strictly, or null: malformed, version `ff`, or an all-zero
9
+ * trace-id or parent-id is no trace at all (the spec: such a header is ignored). */
10
+ export declare function parseTraceparent(value: unknown): Traceparent | null;
11
+ /** The value itself when it is a valid traceparent (normalized: trimmed), else undefined. */
12
+ export declare function validTraceparent(value: unknown): string | undefined;
13
+ /** Run `fn` with `traceparent` as the request's trace context; an invalid value runs `fn` outside any. */
14
+ export declare function runWithTraceparent<T>(traceparent: string | undefined, fn: () => T): T;
15
+ /** The trace context of the request being handled, when it carried a valid traceparent. */
16
+ export declare function currentTraceparent(): string | undefined;
17
+ /** Run `fn` inside the trace context `request` carries (its `traceparent` header), if any. */
18
+ export declare function runWithRequestTrace<T>(request: Request, fn: () => T): T;
19
+ /**
20
+ * The `traceparent` an outbound delivery carries: a CHILD of its cause — the same trace-id and
21
+ * flags, a new parent-id — so the receiving handler continues the cause's trace. The cause is the
22
+ * given traceparent, or an entry's recorded one, or (omitted) the trace context of the request
23
+ * being handled. No valid cause, no traceparent: a delivery never invents a trace.
24
+ */
25
+ export declare function traceparentForDelivery(cause?: string | {
26
+ traceparent?: unknown;
27
+ } | null): string | undefined;
28
+ /** `traceparentForDelivery` as headers to spread into a delivery's own: `{ traceparent }` or `{}`. */
29
+ export declare function deliveryTraceHeaders(cause?: string | {
30
+ traceparent?: unknown;
31
+ } | null): Record<string, string>;
@@ -0,0 +1,78 @@
1
+ // W3C TRACE CONTEXT across a World (https://www.w3.org/TR/trace-context/): one cause followed across
2
+ // vendors by the tracing standard, never an id of our own. An app instrumented with OpenTelemetry
3
+ // sends `traceparent` on its outgoing calls; a real vendor ignores it, so the vendor wire is
4
+ // unchanged. The kernel serve seams (twin-fetch.ts, derived.ts) run a handler inside the request's
5
+ // valid traceparent; every entry appended while handling it records that value (actions.ts
6
+ // `traceparent`, authoring metadata excluded from replay identity like `correlationId`); and a
7
+ // pack's outbound delivery the write caused (a webhook to the app) carries a CHILD of it — the
8
+ // same trace-id, a new parent-id — so the app's handler continues the same trace.
9
+ //
10
+ // Nothing here is vendor knowledge, and nothing runs at import: the async-context store is made on
11
+ // first use (a browser bundle of a mirror client carries the kernel and has no AsyncLocalStorage).
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+ export const TRACEPARENT_HEADER = 'traceparent';
14
+ // version-traceid-parentid-flags, lowercase hex only (the spec's HEXDIGLC). Version ff is invalid;
15
+ // a future version is read by its version-00 prefix only when nothing else follows, which keeps the
16
+ // accepted form exactly the one this module emits.
17
+ const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
18
+ const ZERO_TRACE = '0'.repeat(32);
19
+ const ZERO_SPAN = '0'.repeat(16);
20
+ /** A `traceparent` header value parsed strictly, or null: malformed, version `ff`, or an all-zero
21
+ * trace-id or parent-id is no trace at all (the spec: such a header is ignored). */
22
+ export function parseTraceparent(value) {
23
+ if (typeof value !== 'string')
24
+ return null;
25
+ const m = TRACEPARENT.exec(value.trim());
26
+ if (!m)
27
+ return null;
28
+ const [, version, traceId, parentId, flags] = m;
29
+ if (version === 'ff' || traceId === ZERO_TRACE || parentId === ZERO_SPAN)
30
+ return null;
31
+ return { version, traceId, parentId, flags };
32
+ }
33
+ /** The value itself when it is a valid traceparent (normalized: trimmed), else undefined. */
34
+ export function validTraceparent(value) {
35
+ const parsed = parseTraceparent(value);
36
+ return parsed ? `${parsed.version}-${parsed.traceId}-${parsed.parentId}-${parsed.flags}` : undefined;
37
+ }
38
+ // created on first use, never at import (see the header)
39
+ let traceStore;
40
+ const traceScope = () => (traceStore ??= new AsyncLocalStorage());
41
+ /** Run `fn` with `traceparent` as the request's trace context; an invalid value runs `fn` outside any. */
42
+ export function runWithTraceparent(traceparent, fn) {
43
+ const valid = validTraceparent(traceparent);
44
+ return valid ? traceScope().run(valid, fn) : fn();
45
+ }
46
+ /** The trace context of the request being handled, when it carried a valid traceparent. */
47
+ export function currentTraceparent() {
48
+ return traceStore?.getStore();
49
+ }
50
+ /** Run `fn` inside the trace context `request` carries (its `traceparent` header), if any. */
51
+ export function runWithRequestTrace(request, fn) {
52
+ return runWithTraceparent(request.headers.get(TRACEPARENT_HEADER) ?? undefined, fn);
53
+ }
54
+ function newSpanId() {
55
+ const bytes = new Uint8Array(8);
56
+ for (;;) {
57
+ globalThis.crypto.getRandomValues(bytes);
58
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
59
+ if (hex !== ZERO_SPAN)
60
+ return hex;
61
+ }
62
+ }
63
+ /**
64
+ * The `traceparent` an outbound delivery carries: a CHILD of its cause — the same trace-id and
65
+ * flags, a new parent-id — so the receiving handler continues the cause's trace. The cause is the
66
+ * given traceparent, or an entry's recorded one, or (omitted) the trace context of the request
67
+ * being handled. No valid cause, no traceparent: a delivery never invents a trace.
68
+ */
69
+ export function traceparentForDelivery(cause) {
70
+ const source = cause === undefined ? currentTraceparent() : typeof cause === 'string' ? cause : cause?.traceparent;
71
+ const parent = parseTraceparent(source);
72
+ return parent ? `00-${parent.traceId}-${newSpanId()}-${parent.flags}` : undefined;
73
+ }
74
+ /** `traceparentForDelivery` as headers to spread into a delivery's own: `{ traceparent }` or `{}`. */
75
+ export function deliveryTraceHeaders(cause) {
76
+ const traceparent = traceparentForDelivery(cause);
77
+ return traceparent ? { [TRACEPARENT_HEADER]: traceparent } : {};
78
+ }