@volter/world-core 2.0.1 → 2.0.3

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.
@@ -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
+ }
@@ -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);
@@ -77,13 +77,14 @@ const isGoogleOAuthTokenPath = (p) =>
77
77
  // operation must fail LOCALLY, not succeed remotely.
78
78
 
79
79
  /**
80
- * The www.googleapis.com paths it serves: the v1 (PEM) and v3 (JWK) cert endpoints, and the v3
81
- * userinfo alias. NOT `/oauth2/v2/*` — Google publishes no v2 certs endpoint at all, and the v2
82
- * userinfo alias returns a DIFFERENT field set the pack does not model
83
- * (`googleoauth.endpoints.userinfo_v2`, todo), so its handler refuses it.
80
+ * The www.googleapis.com paths it serves: the v1 (PEM) and v3 (JWK) cert endpoints, the v3
81
+ * userinfo alias, and the OAuth2 API v2's userinfo (`/oauth2/v2/userinfo` and its `/userinfo/v2/me`
82
+ * alias, a different field set: `googleoauth.endpoints.userinfo_v2`), which `@googleapis/oauth2`
83
+ * calls (Cal.com's Google Calendar callback). NOT `/oauth2/v2/certs`: Google publishes no v2 certs
84
+ * endpoint at all.
84
85
  */
85
86
  const isGoogleOAuthApisPath = (p) =>
86
- typeof p === 'string' && /^\/oauth2\/(v1\/certs|v3\/(certs|userinfo))\/?$/.test(p);
87
+ typeof p === 'string' && /^\/(oauth2\/(v1\/certs|v3\/(certs|userinfo)|v2\/userinfo)|userinfo\/v2\/me)\/?$/.test(p);
87
88
 
88
89
  // vendor → predicate(hostname, pathname?). A vendor is only active if its twin URL is set.
89
90
  // THE HAND TABLE'S ONLY RESIDENTS: keys served by KERNEL packages (browser-assets serves
@@ -506,6 +506,10 @@
506
506
  {
507
507
  "host": "cdn.bsky.app",
508
508
  "pathPattern": "^/img/"
509
+ },
510
+ {
511
+ "host": "bsky.app",
512
+ "pathPattern": "^/profile/[^/]+/?$|^/profile/[^/]+/post/[A-Za-z0-9._:~-]{1,512}/?$"
509
513
  }
510
514
  ],
511
515
  "archetype": "crud",
@@ -4274,6 +4278,38 @@
4274
4278
  {
4275
4279
  "host": "api.twitter.com",
4276
4280
  "pathPattern": "^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/(?!assets/consent\\.(?:js|css)$)"
4281
+ },
4282
+ {
4283
+ "host": "x.com",
4284
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
4285
+ },
4286
+ {
4287
+ "host": "twitter.com",
4288
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
4289
+ },
4290
+ {
4291
+ "host": "www.x.com",
4292
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
4293
+ },
4294
+ {
4295
+ "host": "www.twitter.com",
4296
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
4297
+ },
4298
+ {
4299
+ "host": "mobile.twitter.com",
4300
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
4301
+ },
4302
+ {
4303
+ "host": "mobile.x.com",
4304
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
4305
+ },
4306
+ {
4307
+ "host": "pbs.twimg.com",
4308
+ "pathPattern": "^/media/[0-9a-f]{64}\\.(?:jpg|png|gif|webp)$|^/ext_tw_video_thumb/[0-9]{1,19}/pu/img/[0-9a-f]{64}\\.svg$"
4309
+ },
4310
+ {
4311
+ "host": "video.twimg.com",
4312
+ "pathPattern": "^/ext_tw_video/[0-9]{1,19}/pu/vid/avc1/[0-9]+x[0-9]+/[0-9a-f]{64}\\.mp4$"
4277
4313
  }
4278
4314
  ],
4279
4315
  "archetype": "crud",
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.3",
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. */