@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.
@@ -1,3 +1,4 @@
1
+ import { type ReadOnlyRequestError } from './request-scope.js';
1
2
  /** The header a host sets when it mounts a twin under a path (a served World's wire:
2
3
  * `/<org>/<world>/<vendor>`), the reverse-proxy convention; with it the host sets
3
4
  * `x-forwarded-host` and `x-forwarded-proto` to where the World is reached. */
@@ -21,6 +22,21 @@ export type TwinStreamConnection = {
21
22
  close(): void;
22
23
  };
23
24
  export type TwinStream = (sink: TwinStreamSink, peer: string) => TwinStreamConnection;
25
+ /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
26
+ * `requestScopes`: `read` — a request carrying `x-volter-read-only: 1` (request-scope.ts) has every
27
+ * write it attempts refused at the kernel's write seam, whatever operation it names. A World's doors
28
+ * forward a read-scope request that is not a GET only to a twin that advertises it. */
29
+ export declare const TWIN_REQUEST_SCOPES: readonly string[];
30
+ /**
31
+ * THE READ SCOPE for a pack that writes its own fetch (the derived packs, the byte-wire packs): a
32
+ * request carrying the read-only marker runs with the kernel's write seam refusing its writes, and a
33
+ * refused write answers `refuse` (the vendor's own read-only error; a generic 405 without one) —
34
+ * whatever the handler made of the refusal. `GET /twin` advertises `requestScopes`. Everything else
35
+ * passes through untouched; the wrapped fetch keeps its own properties (a derived fetch's `owners`).
36
+ */
37
+ export declare function withRequestScopes<F extends (request: Request) => Promise<Response>>(fetch: F, opts?: {
38
+ refuse?: (request: Request, error: ReadOnlyRequestError) => Response | Promise<Response>;
39
+ }): F;
24
40
  export type TwinFetchHandlerResult = {
25
41
  status: number;
26
42
  body: unknown;
@@ -10,6 +10,8 @@
10
10
  // Workerd-clean by construction: no Bun APIs, no fs, no clock but worldNow() — the same
11
11
  // closure serves under Bun.serve locally and mounted in-process on Cloudflare.
12
12
  import { runWithCorrelationId } from "./actions.js";
13
+ import { runWithRequestTrace } from "./trace-context.js";
14
+ import { isReadOnlyRequest, runAsReadOnlyRequest } from "./request-scope.js";
13
15
  import { worldNow } from "./world-clock.js";
14
16
  /** The header a host sets when it mounts a twin under a path (a served World's wire:
15
17
  * `/<org>/<world>/<vendor>`), the reverse-proxy convention; with it the host sets
@@ -29,6 +31,50 @@ export function twinPublicBase(request) {
29
31
  const origin = host && /^[A-Za-z0-9.-]+(:\d+)?$/.test(host) && (proto === 'http' || proto === 'https') ? `${proto}://${host}` : new URL(request.url).origin;
30
32
  return `${origin}${prefix.replace(/\/+$/, '')}`;
31
33
  }
34
+ /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
35
+ * `requestScopes`: `read` — a request carrying `x-volter-read-only: 1` (request-scope.ts) has every
36
+ * write it attempts refused at the kernel's write seam, whatever operation it names. A World's doors
37
+ * forward a read-scope request that is not a GET only to a twin that advertises it. */
38
+ export const TWIN_REQUEST_SCOPES = ['read'];
39
+ /** `GET /twin`'s body with the request scopes this seam enforces. */
40
+ function advertised(manifest) {
41
+ return manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest) ? { ...manifest, requestScopes: [...TWIN_REQUEST_SCOPES] } : manifest;
42
+ }
43
+ /** The answer to a read-only request's write when the twin names no vendor-shaped one (rule D3). */
44
+ function readOnlyRefusal(error) {
45
+ return Response.json({ error: 'read_only', message: error.message }, { status: 405 });
46
+ }
47
+ /**
48
+ * THE READ SCOPE for a pack that writes its own fetch (the derived packs, the byte-wire packs): a
49
+ * request carrying the read-only marker runs with the kernel's write seam refusing its writes, and a
50
+ * refused write answers `refuse` (the vendor's own read-only error; a generic 405 without one) —
51
+ * whatever the handler made of the refusal. `GET /twin` advertises `requestScopes`. Everything else
52
+ * passes through untouched; the wrapped fetch keeps its own properties (a derived fetch's `owners`).
53
+ */
54
+ export function withRequestScopes(fetch, opts = {}) {
55
+ const scoped = async (request) => {
56
+ const url = new URL(request.url);
57
+ if (request.method === 'GET' && (url.pathname.replace(/\/+$/, '') || '/') === '/twin') {
58
+ const answer = await fetch(request);
59
+ if (!answer.ok || !(answer.headers.get('content-type') ?? '').includes('json'))
60
+ return answer;
61
+ const headers = new Headers(answer.headers);
62
+ headers.delete('content-length');
63
+ return new Response(JSON.stringify(advertised(await answer.json())), { status: answer.status, headers });
64
+ }
65
+ // the request's W3C traceparent follows every entry the fetch writes, as through the kernel's own adapters
66
+ // (a custom fetch never entered it, so the timeline's trace filter missed its entries)
67
+ return runWithRequestTrace(request, async () => {
68
+ if (!isReadOnlyRequest(request))
69
+ return fetch(request);
70
+ const out = await runAsReadOnlyRequest(() => fetch(request));
71
+ if (!out.refused)
72
+ return out.value;
73
+ return opts.refuse ? await opts.refuse(request, out.refused) : readOnlyRefusal(out.refused);
74
+ });
75
+ };
76
+ return Object.assign(scoped, fetch);
77
+ }
32
78
  export function createTwinFetchFromHandler(handler, config) {
33
79
  const readOnly = config.readOnly ?? false;
34
80
  return async function twinFetch(request) {
@@ -63,18 +109,23 @@ export function createTwinFetchFromHandler(handler, config) {
63
109
  // world instant. A client-settable occurredAt would be a direct R9 hole.
64
110
  // D3 — the wire's request id becomes the correlation of every action this handler appends.
65
111
  const requestId = request.headers.get('x-twins-request-id') ?? undefined;
66
- const invoke = () => handler({
112
+ const invoke = (asReadOnly = readOnly) => handler({
67
113
  ...config.handlerOptions,
68
114
  ...config.extras?.(request, url),
69
115
  method: request.method,
70
116
  path: url.pathname + (url.search || ''),
71
117
  body,
72
118
  headers,
73
- readOnly,
119
+ readOnly: asReadOnly,
74
120
  occurredAt: worldNow(),
75
121
  ...(config.root !== undefined ? { root: config.root } : {}),
76
122
  });
77
- const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
123
+ // a read-only request is, to the handler, a request to a read-only twin: the pack refuses its
124
+ // writes in the vendor's own shape (D3), and the kernel's write seam refuses any it misses
125
+ const scoped = readOnly || isReadOnlyRequest(request);
126
+ const run = (asReadOnly = scoped) => (requestId ? runWithCorrelationId(requestId, () => invoke(asReadOnly)) : invoke(asReadOnly));
127
+ // W3C trace context: a valid incoming traceparent scopes the handler, so its entries record it
128
+ const result = await runWithRequestTrace(request, () => (isReadOnlyRequest(request) ? readScoped(run) : run()));
78
129
  // A null/undefined body is an EMPTY reply (`JSON.stringify(null)` would serve the
79
130
  // four bytes "null") — and so is the estate's own 204 idiom, `body: ''` with a
80
131
  // null-body status: workerd THROWS on any body with 204/205/304 (review B2), where
@@ -89,3 +140,15 @@ export function createTwinFetchFromHandler(handler, config) {
89
140
  });
90
141
  };
91
142
  }
143
+ /** A read-only request through the handler seam: the handler runs once, with its writes refused at
144
+ * the kernel's write seam; a write it attempted answers 405 (rule D3). The handler is never asked
145
+ * twice (a second run would repeat whatever it did before its first write: a rate-limit slot, a
146
+ * vendor call). The seam enforces the marker for whoever sends it, but does not advertise
147
+ * `requestScopes`: a pack opts in to being handed a World's read-token requests by wrapping its
148
+ * fetch in `withRequestScopes`, once it has made sure nothing it does on a read escapes the seam. */
149
+ async function readScoped(run) {
150
+ const out = await runAsReadOnlyRequest(() => run());
151
+ if (!out.refused)
152
+ return out.value;
153
+ return { status: 405, body: { error: 'read_only', message: out.refused.message } };
154
+ }
@@ -25,6 +25,7 @@
25
25
  // `node:fs`. See its module header.
26
26
  import { appendFileSync, chmodSync, closeSync, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
27
27
  import { hostname } from 'node:os';
28
+ import { refuseReadOnlyWrite } from "./request-scope.js";
28
29
  import { basename, dirname, join, resolve } from 'node:path';
29
30
  // ── FsWorldStore — the DEFAULT, byte-identical to the historical node:fs behavior ────
30
31
  let fsAtomicSequence = 0;
@@ -122,6 +123,7 @@ export class FsWorldStore {
122
123
  }
123
124
  }
124
125
  append(path, data) {
126
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
125
127
  // Historical appendDurable: a completed append syscall survives a process crash;
126
128
  // fsync additionally survives kernel-panic/power-loss when VOLTER_DURABLE=1.
127
129
  const fd = openSync(path, 'a');
@@ -135,6 +137,7 @@ export class FsWorldStore {
135
137
  }
136
138
  }
137
139
  write(path, data, options = {}) {
140
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
138
141
  if (!options.secret) {
139
142
  mkdirSync(dirname(path), { recursive: true });
140
143
  writeFileSync(path, data);
@@ -145,6 +148,7 @@ export class FsWorldStore {
145
148
  chmodSync(path, 0o600);
146
149
  }
147
150
  writeAtomic(path, data, options = {}) {
151
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
148
152
  mkdirSync(dirname(path), { recursive: true });
149
153
  const tmp = `${path}.${process.pid}.${Date.now()}.${fsAtomicSequence++}.tmp`;
150
154
  try {
@@ -161,6 +165,7 @@ export class FsWorldStore {
161
165
  return existsSync(path);
162
166
  }
163
167
  remove(path) {
168
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
164
169
  rmSync(path, { recursive: true, force: true });
165
170
  }
166
171
  mkdir(dirPath) {
@@ -339,16 +344,19 @@ export class MemoryWorldStore {
339
344
  return content === undefined ? [] : content.split('\n');
340
345
  }
341
346
  append(path, data) {
347
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
342
348
  this.files.set(path, (this.files.get(path) ?? '') + data);
343
349
  this.sizes.set(path, (this.sizes.get(path) ?? 0) + byteLength(data));
344
350
  this.bump(path);
345
351
  }
346
352
  write(path, data) {
353
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
347
354
  this.files.set(path, data);
348
355
  this.sizes.set(path, byteLength(data));
349
356
  this.bump(path);
350
357
  }
351
358
  writeAtomic(path, data) {
359
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
352
360
  // Atomic by nature in a single process: the assignment is indivisible, so no reader
353
361
  // ever observes a partial document.
354
362
  this.files.set(path, data);
@@ -366,6 +374,7 @@ export class MemoryWorldStore {
366
374
  return false;
367
375
  }
368
376
  remove(path) {
377
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
369
378
  let removedAny = false;
370
379
  if (this.files.delete(path)) {
371
380
  this.versions.delete(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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,7 +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
25
  import { getActiveWorldStore } from './world-store.ts';
26
+ import { currentTraceparent } from './trace-context.ts';
25
27
  import type { WorldServiceEvent } from './types.ts';
26
28
  import type { TwinResource } from './serve.ts';
27
29
 
@@ -94,6 +96,9 @@ export type TwinAction = {
94
96
  * alone, with no dependence on actionId/pushId naming conventions.
95
97
  */
96
98
  correlationId?: string;
99
+ /** The W3C `traceparent` of the request that caused this action, when it carried a valid one
100
+ * (trace-context.ts). Authoring metadata like correlationId: excluded from replay identity. */
101
+ traceparent?: string;
97
102
  /**
98
103
  * The action's MERGE BASE against the remote (runtime contract R14, non-fast-forward
99
104
  * rule): per touched subject, the observed mirror's remote ref (shadow.ts remoteRefs)
@@ -129,7 +134,10 @@ function withCorrelationId(action: TwinAction): TwinAction {
129
134
  // The wire's request id (the addressed service stamps every response with one and threads it
130
135
  // into the handler's async context) is the correlation when the caller supplies none — the
131
136
  // join from a request on the wire to the action rows it caused, with no pack involved.
132
- return action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
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;
140
+ return traceparent ? { ...correlated, traceparent } : correlated;
133
141
  }
134
142
 
135
143
  /** Subjects a `set` action touches: its own subject, every projection resource, and every
@@ -200,7 +208,7 @@ function landIfPlaceholderPull(action: TwinAction, root?: string): TwinAction |
200
208
  }
201
209
 
202
210
  function replayBody(action: TwinAction): string {
203
- const { correlationId: _correlationId, ...stampedBody } = action;
211
+ const { correlationId: _correlationId, traceparent: _traceparent, ...stampedBody } = action;
204
212
  // A confirmation is content-addressed by the observed event ids. Its timestamp is observation
205
213
  // metadata, just like the occurredAt/observedAt values that appendEvent ignores when replaying
206
214
  // one content-addressed event. Two reconcilers confirming the same bytes at different wall-clock
@@ -233,6 +241,7 @@ export function runWithCorrelationId<T>(id: string, fn: () => T): T { return cor
233
241
  export function currentCorrelationId(): string | undefined { return correlationScope().getStore(); }
234
242
 
235
243
  export function appendAction(action: TwinAction, root?: string): TwinAction {
244
+ refuseReadOnlyWrite(action.service); // a read-only request writes nothing (request-scope.ts)
236
245
  // Evaluate and append under the service projection and actions locks. A precondition is a
237
246
  // compare-and-set, not an advisory validation: checking outside either lock would let another
238
247
  // writer invalidate it before this action lands. The shadow basis is stamped under the same
@@ -252,6 +261,7 @@ export function appendAction(action: TwinAction, root?: string): TwinAction {
252
261
  * atomic across processes (two concurrent identical writes converge to ONE action; distinct
253
262
  * writes both land). A reused id with different content fails loudly. */
254
263
  export function appendActionIfAbsent(action: TwinAction, root?: string): { action: TwinAction; appended: boolean; placeholder?: true } {
264
+ refuseReadOnlyWrite(action.service);
255
265
  return withFileLock(projectionLock(action.service, root), () => withFileLock(actionsLock(action.service, root), () => {
256
266
  const identified = withCorrelationId(action);
257
267
  // An exact retry is already committed. Resolve it before re-evaluating author-time
@@ -285,6 +295,7 @@ export function appendActionIfAbsent(action: TwinAction, root?: string): { actio
285
295
  * the same ordinals, which is what serve-path determinism requires.
286
296
  */
287
297
  export function appendActionOccurrence(base: TwinAction, root?: string): { action: TwinAction; appended: boolean; placeholder?: true } {
298
+ refuseReadOnlyWrite(base.service);
288
299
  return withFileLock(projectionLock(base.service, root), () => withFileLock(actionsLock(base.service, root), () => {
289
300
  const stamped = occurrenceOf(base, actionIds(base.service, root), root);
290
301
  assertPreconditions(stamped, root);
@@ -345,6 +356,7 @@ export function decideAndAppendAction<T>(
345
356
  const resources = projectResources(service, root);
346
357
  const decision = decide(resources);
347
358
  if (decision.kind === 'skip') return { value: decision.value, appended: false };
359
+ refuseReadOnlyWrite(service); // a decision to write is the write a read-only request may not make
348
360
  if (decision.action.service !== service) {
349
361
  throw new Error(`Atomic action service mismatch: expected ${service}, got ${decision.action.service}`);
350
362
  }
@@ -525,6 +537,7 @@ export function confirmAction(opts: {
525
537
  occurredAt: string;
526
538
  root?: string;
527
539
  }): { observedEventId: string; observedEventIds: string[] } {
540
+ refuseReadOnlyWrite(opts.service);
528
541
  // LANDING (log.ts): the entry is copied to the parent log with its receipt — no confirm row, no
529
542
  // suppression; the fold skips a branch entry the parent holds.
530
543
  const paths = worldPaths(opts.service, opts.root);
@@ -561,6 +574,7 @@ export type RevertOutcome =
561
574
  * Only `set` rows are revertable — reverting a revert or a confirm is a category error.
562
575
  */
563
576
  export function revertAction(opts: { service: string; actionId: string; occurredAt: string; root?: string }): RevertOutcome {
577
+ refuseReadOnlyWrite(opts.service);
564
578
  return withFileLock(projectionLock(opts.service, opts.root), () => withFileLock(actionsLock(opts.service, opts.root), (): RevertOutcome => {
565
579
  const all = listActions(opts.service, opts.root);
566
580
  const target = all.find((a) => a.id === opts.actionId);
package/src/blob-store.ts CHANGED
@@ -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.ts';
25
25
  import { sharedBlobIndex } from './shared-blob-index.ts';
26
+ import { refuseReadOnlyWrite, writesRefused } from './request-scope.ts';
26
27
 
27
28
  export interface BlobStore {
28
29
  /** Bytes at `key`, or null when absent. Never throws for a missing key. */
@@ -165,8 +166,21 @@ export function blobDigest(bytes: Uint8Array): string {
165
166
  // (or a test) swaps in its adapter for the scope of a request.
166
167
  let activeBlobStore: BlobStore = new FsBlobStore();
167
168
 
169
+ /** The active store; under a read-only request's refusing scope (request-scope.ts), one that refuses
170
+ * to store or remove bytes before any reach it, as the log's appenders refuse (a git push's objects,
171
+ * an upload's body), while reads pass. */
168
172
  export function getActiveBlobStore(): BlobStore {
169
- return activeBlobStore;
173
+ return writesRefused() ? readOnlyBlobs(activeBlobStore) : activeBlobStore;
174
+ }
175
+ function readOnlyBlobs(store: BlobStore): BlobStore {
176
+ return {
177
+ get: (key) => store.get(key),
178
+ exists: (key) => store.exists(key),
179
+ list: (prefix) => store.list(prefix),
180
+ size: (key) => store.size(key),
181
+ put: async () => { refuseReadOnlyWrite('blobs'); },
182
+ remove: async () => { refuseReadOnlyWrite('blobs'); },
183
+ };
170
184
  }
171
185
 
172
186
  export function setActiveBlobStore(store: BlobStore): BlobStore {
@@ -5,9 +5,22 @@
5
5
  // is retried on the next call. Nothing here runs at module scope.
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
7
 
8
+ /** A file URL's path on this machine, without `node:url` (the modules that call it ride into browser
9
+ * bundles): percent-escapes decoded, and without the slash `URL.pathname` puts before a Windows drive
10
+ * (`/C:/…`), which no file API opens. `new URL('../client/x.css', import.meta.url).pathname` is this. */
11
+ export function filePathOf(url: URL): string {
12
+ const path = decodeURIComponent(url.pathname);
13
+ return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
14
+ }
15
+
8
16
  const bundles = new Map<string, Promise<string>>();
9
17
  type BunBuild = { build: (o: { entrypoints: string[]; target: 'browser'; minify: boolean }) => Promise<{ success: boolean; logs: Array<{ message: string }>; outputs: Array<{ text: () => Promise<string> }> }> };
10
18
 
19
+ /** A browser has no `process`. A client that imports shared helpers from its pack's server-side module can pull Node
20
+ * code into its bundle (util.deprecate, crypto shims) that reads it; without a shim the page throws before it renders
21
+ * (newer Bun no longer adds one to a browser build). A minimal one, the page's own, before the bundle. */
22
+ 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';
23
+
11
24
  export function bundleClient(entry: string): Promise<string> {
12
25
  let pending = bundles.get(entry);
13
26
  if (!pending) {
@@ -15,12 +28,12 @@ export function bundleClient(entry: string): Promise<string> {
15
28
  pending = (bun && typeof bun.build === 'function'
16
29
  ? (bun as BunBuild).build({ entrypoints: [entry], target: 'browser', minify: true }).then(async (result) => {
17
30
  if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || `client build failed: ${entry}`);
18
- return result.outputs[0]!.text();
31
+ return PROCESS_SHIM + await result.outputs[0]!.text();
19
32
  })
20
33
  : Promise.resolve().then(() => {
21
34
  const prebuilt = entry.replace(/\.tsx?$/, '.bundle.js');
22
35
  if (!existsSync(prebuilt)) throw new Error(`client bundle missing at ${prebuilt} — the package's \`build\` writes it (scripts/publish/build.mjs); Node serves the prebuilt client`);
23
- return readFileSync(prebuilt, 'utf8');
36
+ return PROCESS_SHIM + readFileSync(prebuilt, 'utf8');
24
37
  }))
25
38
  .catch((error) => { bundles.delete(entry); throw error; });
26
39
  bundles.set(entry, pending);
@@ -14,7 +14,8 @@ import { hashFieldValue } from './hash.ts';
14
14
  import { resolveSubjectId, subjectAliases } from './actions.ts';
15
15
  import { packReferences } from './references.ts';
16
16
  import type { SubjectFields } from './hash.ts';
17
- import { twinPublicBase } from './twin-fetch.ts';
17
+ import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
18
+ import { isReadOnlyRequest } from './request-scope.ts';
18
19
  import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
19
20
 
20
21
  // ── the manifest ─────────────────────────────────────────────────────────────────────────────
@@ -965,8 +966,10 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
965
966
  }
966
967
  }
967
968
  // a read-only twin refuses writes: what the operation does, not the HTTP verb it came by (an RPC
968
- // wire POSTs its reads)
969
- if (opts.readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id)) return vendorError(m, m.readOnly);
969
+ // wire POSTs its reads). A read-only REQUEST (x-volter-read-only, request-scope.ts) is refused the
970
+ // same way, up front, on a writable twin.
971
+ const readOnly = opts.readOnly || isReadOnlyRequest(request);
972
+ if (readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id)) return vendorError(m, m.readOnly);
970
973
  // a body labelled JSON that does not parse is the vendor's refusal, never a crash of the twin
971
974
  if ((request.headers.get('content-type') ?? '').includes('json') || m.body.json === 'always') {
972
975
  const text = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.clone().text().catch(() => '');
@@ -988,6 +991,8 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
988
991
  // a streamed answer replays as the stream it was; a record kept before text was kept replays its JSON
989
992
  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 });
990
993
  }
994
+ // a read-only request replays a stored answer but records none: recording is a write
995
+ if (readOnly) return next();
991
996
  const response = await next();
992
997
  if (m.idempotency.onlySuccess && (response.status < 200 || response.status >= 300)) return response;
993
998
  const contentType = response.headers.get('content-type') ?? 'application/json';
@@ -997,6 +1002,15 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
997
1002
  }
998
1003
  }
999
1004
 
1005
+ /** A derived pack's whole fetch behind the kernel's read scope (twin-fetch.ts withRequestScopes): a
1006
+ * read-only request's writes are refused at the write seam — the ones `crossCutting` cannot see
1007
+ * coming (another wire's mutation, a door, a screen) — and answered with the vendor's own read-only
1008
+ * error; `GET /twin` advertises `requestScopes`. The pack's catch-up runs under `runAsVendorMove`
1009
+ * (request-scope.ts), so time's moves still land when a read-only request is what arrives. */
1010
+ export function derivedRequestScopes<F extends (request: Request) => Promise<Response>>(m: DerivedManifest, fetch: F): F {
1011
+ return withRequestScopes(fetch, { refuse: () => vendorError(m, m.readOnly) });
1012
+ }
1013
+
1000
1014
  /** A semantics context for a request no surface operation names: another wire's (GraphQL) resolvers
1001
1015
  * get the same interface as a handler, named by the operation id the wire gives. */
1002
1016
  export function semanticsContext(m: DerivedManifest, request: Request, operation: DerivedOperation, scope: CoreScope = {}): Promise<SemanticsContext> {
package/src/derived.ts CHANGED
@@ -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.ts';
8
9
 
9
10
  /** The slice of a generated surface the dispatch reads. */
10
11
  export type DerivedOperation = {
@@ -141,7 +142,9 @@ export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
141
142
  const routes = compileSurface(options.surface);
142
143
  const fallback = (request: Request, operation: DerivedOperation | undefined, reason: string): Promise<Response> =>
143
144
  options.legacy ? options.legacy(request) : Promise.resolve(options.gap(request, operation, reason));
144
- const fetch = (async (request: Request): Promise<Response> => {
145
+ // a valid incoming W3C traceparent scopes the dispatch, so every entry it appends records it (trace-context.ts)
146
+ const fetch = ((request: Request): Promise<Response> => runWithRequestTrace(request, () => dispatch(request))) as DerivedFetch;
147
+ const dispatch = async (request: Request): Promise<Response> => {
145
148
  const url = new URL(request.url);
146
149
  const matched = options.anyMethod
147
150
  ? [...routes.keys()].map((m) => matchOperation(routes, m, url.pathname, url.searchParams, request.headers)).find(Boolean)
@@ -167,7 +170,7 @@ export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
167
170
  }
168
171
  }
169
172
  return fallback(request, matched.operation, 'no handler');
170
- }) as DerivedFetch;
173
+ };
171
174
  fetch.owners = () =>
172
175
  Object.fromEntries(
173
176
  options.surface.operations.map((o) => [o.id, handlers[o.id] ? 'handler' : options.core?.owns(o) ? 'core' : options.legacy ? 'legacy' : 'gap'] as const),
package/src/git/refs.ts CHANGED
@@ -3,15 +3,17 @@
3
3
  // state a snapshot carries, a projection can read, and (through the action log the plane keeps
4
4
  // beside it) a push policy can gate. No packed-refs: the store lists directories.
5
5
  import type { WorldStore } from '../world-store.ts';
6
+ import { refuseReadOnlyWrite } from '../request-scope.ts';
6
7
 
7
8
  export class GitRefs {
8
9
  constructor(private readonly store: WorldStore, private readonly prefix: string) {}
9
10
  private path(name: string): string { return `${this.prefix}/${name}`; }
10
11
  get(name: string): string | null { const v = this.store.read(this.path(name)); return v === null ? null : v.trim(); }
11
- set(name: string, sha: string): void { this.store.write(this.path(name), `${sha}\n`); }
12
- delete(name: string): void { if (this.store.exists(this.path(name))) this.store.remove(this.path(name)); }
12
+ // a read-only request moves no ref (request-scope.ts): refused before the store is touched
13
+ set(name: string, sha: string): void { refuseReadOnlyWrite('git'); this.store.write(this.path(name), `${sha}\n`); }
14
+ delete(name: string): void { refuseReadOnlyWrite('git'); if (this.store.exists(this.path(name))) this.store.remove(this.path(name)); }
13
15
  head(): string { const v = this.store.read(this.path('HEAD')); return v === null ? 'refs/heads/main' : v.trim().replace(/^ref: /, ''); }
14
- setHead(target: string): void { this.store.write(this.path('HEAD'), `ref: ${target}\n`); }
16
+ setHead(target: string): void { 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<T>(fn: () => T): T { return this.store.withLock(this.path('refs.lock'), fn); }
17
19
  /** Every `refs/...` name with its sha, sorted by name. */
package/src/head.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // record: a `deployed` copy is never performed again, a `failed` one is retried.
10
10
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
- import { basename, dirname, join, resolve } from 'node:path';
12
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
13
13
  import { confirmAction, isTwinBookkeeping, resolveSubjectId, revertAction, type TwinAction } from './actions.ts';
14
14
  import { openSealedCredential, sealCredential, type CredentialPayload, type SealedCredential } from './credential.ts';
15
15
  import { buildRemoteExecute, validateRemoteOrigin, type CredentialCustody } from './executor.ts';
@@ -116,11 +116,20 @@ export async function loadChecks(worldRoot: string): Promise<Check[]> {
116
116
  * loader that runs the check in an isolate of its own, with no network and nothing of the World's. */
117
117
  export type CheckLoader = (path: string, name: string) => Promise<Check>;
118
118
  const importCheck: CheckLoader = async (path, name) => {
119
- const mod = (await import(path)) as { check?: Check; default?: Check };
119
+ // Node's ESM loader takes an absolute path only as a file URL (on Windows `C:\…` reads as the scheme `c:`); Bun takes either
120
+ const mod = (await import(isAbsolute(path) ? fileUrlOf(path) : path)) as { check?: Check; default?: Check };
120
121
  const check = mod.check ?? mod.default;
121
122
  if (!check || typeof check.run !== 'function') throw new Error(`${path} must export a check { name, run }`);
122
123
  return { name: check.name ?? name, run: check.run };
123
124
  };
125
+ /** An absolute path's file URL, built here: `node:url` is not in the browser bundles that also reach this module. */
126
+ const fileUrlOf = (path: string): string => {
127
+ // a long path (`\\?\C:\…`) is the drive path it names; a UNC path (`\\server\share\…`) puts the server in the URL's host
128
+ const long = /^\\\\\?\\(?:UNC\\)?/i.exec(path); const unc = /^\\\\\?\\UNC\\/i.test(path) || (!long && /^\\\\[^\\?]/.test(path));
129
+ const rest = (long ? path.slice(long[0].length) : unc ? path.slice(2) : path).replace(/\\/g, '/');
130
+ const encoded = rest.split('/').map((seg) => encodeURIComponent(seg).replace(/%3A/g, ':')).join('/');
131
+ return unc ? `file://${encoded}` : `file://${rest.startsWith('/') ? '' : '/'}${encoded}`;
132
+ };
124
133
  let checkLoader: CheckLoader = importCheck;
125
134
  export function setCheckLoader(loader: CheckLoader | null): void { checkLoader = loader ?? importCheck; }
126
135
  /** One check file through the host's loader: what placing a check validates with. */
package/src/index.ts CHANGED
@@ -15,10 +15,13 @@ export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrit
15
15
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.ts';
16
16
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
17
17
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
18
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, twinPublicBase } from './twin-fetch.ts';
18
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.ts';
19
+ export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
19
20
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.ts';
21
+ export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
22
+ export type { Traceparent } from './trace-context.ts';
20
23
  export type { DerivedCall, DerivedCoreOutcome, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface } from './derived.ts';
21
- export { bindSemantics, coreFor, crossCutting, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.ts';
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';
22
25
  export type { Actor, CoreScope, DerivedManifest, ErrorSpec, FieldRule, ResourceDecl, ScreenDecl, Semantics, SemanticsContext, StateField, Transition, TransitionObserver } from './derived-core.ts';
23
26
  export type { RemoteExecute, RemoteExecuteRequest, RemoteExecuteResponse } from './remote-execute.ts';
24
27
  export type { TwinFetchAdapterConfig, TwinFetchHandlerRequest, TwinFetchHandlerResult, TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
@@ -313,7 +316,7 @@ export { referenceField, registerReferences, packReferences, resolveReferences,
313
316
  export { serveHttp, nodeBuiltin, WORLD_BOOT_PATH, type HttpServer, type ServeHttpOptions, type HttpHandler } from './serve-http.ts';
314
317
  // two runtime-neutral helpers for a pack's serve path: a file as a Response, a mirror's client bundle
315
318
  export { fileResponse, contentTypeOf } from './file-response.ts';
316
- export { bundleClient } from './client-bundle.ts';
319
+ export { bundleClient, filePathOf } from './client-bundle.ts';
317
320
  // the brand's tokens and faces for a Volter page (the console, the site, the UI kit), fetched at build
318
321
  export { brandTokensResponse } from './brand-tokens.ts';
319
322
 
package/src/log.ts CHANGED
@@ -71,6 +71,8 @@ export type Entry = {
71
71
  /** a landed copy under the vendor's id: the local subject id it stands for */
72
72
  aliasOf?: string;
73
73
  receipt?: Receipt;
74
+ /** the W3C traceparent of the request that wrote this entry (trace-context.ts); never part of its identity */
75
+ traceparent?: string;
74
76
  /** a v1 observed row kept verbatim for `listEvents`; folds nothing unless `fields` was derived */
75
77
  event?: WorldServiceEvent;
76
78
  [extra: string]: unknown;
@@ -0,0 +1,74 @@
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
+
18
+ /** The request header that marks a request read-only. Its one value is `1`. */
19
+ export const READ_ONLY_REQUEST_HEADER = 'x-volter-read-only';
20
+
21
+ /** Whether a request carries the read-only marker. */
22
+ export function isReadOnlyRequest(request: Request | { headers: Headers }): boolean {
23
+ return request.headers.get(READ_ONLY_REQUEST_HEADER)?.trim() === '1';
24
+ }
25
+
26
+ /** A write a read-only request attempted: refused before anything was written (rule D3's 405). */
27
+ export class ReadOnlyRequestError extends Error {
28
+ constructor(readonly service: string) {
29
+ super(`${service}: this request is read-only (${READ_ONLY_REQUEST_HEADER}: 1); it cannot write`);
30
+ this.name = 'ReadOnlyRequestError';
31
+ }
32
+ }
33
+
34
+ type WriteScope = { readOnly: boolean; vendorMove: boolean; refusals: ReadOnlyRequestError[] };
35
+ let writeScopeStore: AsyncLocalStorage<WriteScope> | undefined;
36
+ const writeScope = (): AsyncLocalStorage<WriteScope> => (writeScopeStore ??= new AsyncLocalStorage<WriteScope>());
37
+
38
+ /** Run `fn` as a read-only request: every write it attempts outside a vendor move is refused. The
39
+ * answer says whether one was — also when the handler caught the refusal and answered something
40
+ * else, so a caller can answer the refusal whatever the handler made of it. */
41
+ export async function runAsReadOnlyRequest<T>(fn: () => T | Promise<T>): Promise<{ value: T; refused: undefined } | { refused: ReadOnlyRequestError }> {
42
+ const scope: WriteScope = { readOnly: true, vendorMove: false, refusals: [] };
43
+ try {
44
+ const value = await writeScope().run(scope, fn);
45
+ const refused = scope.refusals[0];
46
+ return refused ? { refused } : { value, refused: undefined };
47
+ } catch (error) {
48
+ const refused = scope.refusals[0] ?? (error instanceof ReadOnlyRequestError ? error : undefined);
49
+ if (refused) return { refused };
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ /** Run `fn` as the VENDOR's own move (a pack's catch-up): its writes land even under a read-only
55
+ * request. Outside one it changes nothing. */
56
+ export function runAsVendorMove<T>(fn: () => T): T {
57
+ const current = writeScope().getStore();
58
+ return current ? writeScope().run({ ...current, vendorMove: true }, fn) : fn();
59
+ }
60
+
61
+ /** Whether a write attempted here would be refused. */
62
+ export function writesRefused(): boolean {
63
+ const scope = writeScopeStore?.getStore();
64
+ return scope !== undefined && scope.readOnly && !scope.vendorMove;
65
+ }
66
+
67
+ /** The write seam's check: throws (and records) the refusal when a write attempted here is a
68
+ * read-only request's. Called by every appender before it writes anything. */
69
+ export function refuseReadOnlyWrite(service: string): void {
70
+ if (!writesRefused()) return;
71
+ const error = new ReadOnlyRequestError(service);
72
+ writeScopeStore!.getStore()!.refusals.push(error);
73
+ throw error;
74
+ }
package/src/storage.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  WorldServiceEventSchema,
7
7
  } from './schemas.ts';
8
8
  import { getActiveWorldStore } from './world-store.ts';
9
+ import { refuseReadOnlyWrite } from './request-scope.ts';
9
10
  import { parentEntries, toEvent } from './log.ts';
10
11
  import type {
11
12
  AppendEventResult,
@@ -249,6 +250,7 @@ export function projectionLockPath(paths: WorldPaths): string {
249
250
  */
250
251
  export function appendEvent(event: WorldServiceEvent, root?: string): AppendEventResult {
251
252
  const parsed = WorldServiceEventSchema.parse(event);
253
+ refuseReadOnlyWrite(parsed.service); // a read-only request writes nothing (request-scope.ts)
252
254
  const paths = worldPaths(parsed.service, root);
253
255
  ensureEventDirs(paths);
254
256
  const { appended } = appendParentEntry(toEntry(parsed as unknown as Record<string, unknown>), root);