@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.
@@ -0,0 +1,87 @@
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
+
14
+ export const TRACEPARENT_HEADER = 'traceparent';
15
+
16
+ // version-traceid-parentid-flags, lowercase hex only (the spec's HEXDIGLC). Version ff is invalid;
17
+ // a future version is read by its version-00 prefix only when nothing else follows, which keeps the
18
+ // accepted form exactly the one this module emits.
19
+ const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
20
+ const ZERO_TRACE = '0'.repeat(32);
21
+ const ZERO_SPAN = '0'.repeat(16);
22
+
23
+ export type Traceparent = { version: string; traceId: string; parentId: string; flags: string };
24
+
25
+ /** A `traceparent` header value parsed strictly, or null: malformed, version `ff`, or an all-zero
26
+ * trace-id or parent-id is no trace at all (the spec: such a header is ignored). */
27
+ export function parseTraceparent(value: unknown): Traceparent | null {
28
+ if (typeof value !== 'string') return null;
29
+ const m = TRACEPARENT.exec(value.trim());
30
+ if (!m) return null;
31
+ const [, version, traceId, parentId, flags] = m as unknown as [string, string, string, string, string];
32
+ if (version === 'ff' || traceId === ZERO_TRACE || parentId === ZERO_SPAN) return null;
33
+ return { version, traceId, parentId, flags };
34
+ }
35
+
36
+ /** The value itself when it is a valid traceparent (normalized: trimmed), else undefined. */
37
+ export function validTraceparent(value: unknown): string | undefined {
38
+ const parsed = parseTraceparent(value);
39
+ return parsed ? `${parsed.version}-${parsed.traceId}-${parsed.parentId}-${parsed.flags}` : undefined;
40
+ }
41
+
42
+ // created on first use, never at import (see the header)
43
+ let traceStore: AsyncLocalStorage<string> | undefined;
44
+ const traceScope = (): AsyncLocalStorage<string> => (traceStore ??= new AsyncLocalStorage<string>());
45
+
46
+ /** Run `fn` with `traceparent` as the request's trace context; an invalid value runs `fn` outside any. */
47
+ export function runWithTraceparent<T>(traceparent: string | undefined, fn: () => T): T {
48
+ const valid = validTraceparent(traceparent);
49
+ return valid ? traceScope().run(valid, fn) : fn();
50
+ }
51
+
52
+ /** The trace context of the request being handled, when it carried a valid traceparent. */
53
+ export function currentTraceparent(): string | undefined {
54
+ return traceStore?.getStore();
55
+ }
56
+
57
+ /** Run `fn` inside the trace context `request` carries (its `traceparent` header), if any. */
58
+ export function runWithRequestTrace<T>(request: Request, fn: () => T): T {
59
+ return runWithTraceparent(request.headers.get(TRACEPARENT_HEADER) ?? undefined, fn);
60
+ }
61
+
62
+ function newSpanId(): string {
63
+ const bytes = new Uint8Array(8);
64
+ for (;;) {
65
+ globalThis.crypto.getRandomValues(bytes);
66
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
67
+ if (hex !== ZERO_SPAN) return hex;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The `traceparent` an outbound delivery carries: a CHILD of its cause — the same trace-id and
73
+ * flags, a new parent-id — so the receiving handler continues the cause's trace. The cause is the
74
+ * given traceparent, or an entry's recorded one, or (omitted) the trace context of the request
75
+ * being handled. No valid cause, no traceparent: a delivery never invents a trace.
76
+ */
77
+ export function traceparentForDelivery(cause?: string | { traceparent?: unknown } | null): string | undefined {
78
+ const source = cause === undefined ? currentTraceparent() : typeof cause === 'string' ? cause : cause?.traceparent;
79
+ const parent = parseTraceparent(source);
80
+ return parent ? `00-${parent.traceId}-${newSpanId()}-${parent.flags}` : undefined;
81
+ }
82
+
83
+ /** `traceparentForDelivery` as headers to spread into a delivery's own: `{ traceparent }` or `{}`. */
84
+ export function deliveryTraceHeaders(cause?: string | { traceparent?: unknown } | null): Record<string, string> {
85
+ const traceparent = traceparentForDelivery(cause);
86
+ return traceparent ? { [TRACEPARENT_HEADER]: traceparent } : {};
87
+ }
package/src/twin-fetch.ts CHANGED
@@ -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.ts';
13
+ import { runWithRequestTrace } from './trace-context.ts';
14
+ import { isReadOnlyRequest, runAsReadOnlyRequest, type ReadOnlyRequestError } from './request-scope.ts';
13
15
  import { worldNow } from './world-clock.ts';
14
16
 
15
17
  /** The header a host sets when it mounts a twin under a path (a served World's wire:
@@ -40,6 +42,53 @@ export type TwinStreamSink = { write(bytes: Uint8Array): void; end(): void };
40
42
  export type TwinStreamConnection = { data(chunk: Uint8Array): void; settled(): Promise<void>; close(): void };
41
43
  export type TwinStream = (sink: TwinStreamSink, peer: string) => TwinStreamConnection;
42
44
 
45
+ /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
46
+ * `requestScopes`: `read` — a request carrying `x-volter-read-only: 1` (request-scope.ts) has every
47
+ * write it attempts refused at the kernel's write seam, whatever operation it names. A World's doors
48
+ * forward a read-scope request that is not a GET only to a twin that advertises it. */
49
+ export const TWIN_REQUEST_SCOPES: readonly string[] = ['read'];
50
+
51
+ /** `GET /twin`'s body with the request scopes this seam enforces. */
52
+ function advertised(manifest: unknown): unknown {
53
+ return manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest) ? { ...(manifest as Record<string, unknown>), requestScopes: [...TWIN_REQUEST_SCOPES] } : manifest;
54
+ }
55
+
56
+ /** The answer to a read-only request's write when the twin names no vendor-shaped one (rule D3). */
57
+ function readOnlyRefusal(error: ReadOnlyRequestError): Response {
58
+ return Response.json({ error: 'read_only', message: error.message }, { status: 405 });
59
+ }
60
+
61
+ /**
62
+ * THE READ SCOPE for a pack that writes its own fetch (the derived packs, the byte-wire packs): a
63
+ * request carrying the read-only marker runs with the kernel's write seam refusing its writes, and a
64
+ * refused write answers `refuse` (the vendor's own read-only error; a generic 405 without one) —
65
+ * whatever the handler made of the refusal. `GET /twin` advertises `requestScopes`. Everything else
66
+ * passes through untouched; the wrapped fetch keeps its own properties (a derived fetch's `owners`).
67
+ */
68
+ export function withRequestScopes<F extends (request: Request) => Promise<Response>>(
69
+ fetch: F,
70
+ opts: { refuse?: (request: Request, error: ReadOnlyRequestError) => Response | Promise<Response> } = {},
71
+ ): F {
72
+ const scoped = async (request: Request): Promise<Response> => {
73
+ const url = new URL(request.url);
74
+ if (request.method === 'GET' && (url.pathname.replace(/\/+$/, '') || '/') === '/twin') {
75
+ const answer = await fetch(request);
76
+ if (!answer.ok || !(answer.headers.get('content-type') ?? '').includes('json')) return answer;
77
+ const headers = new Headers(answer.headers); headers.delete('content-length');
78
+ return new Response(JSON.stringify(advertised(await answer.json())), { status: answer.status, headers });
79
+ }
80
+ // the request's W3C traceparent follows every entry the fetch writes, as through the kernel's own adapters
81
+ // (a custom fetch never entered it, so the timeline's trace filter missed its entries)
82
+ return runWithRequestTrace(request, async () => {
83
+ if (!isReadOnlyRequest(request)) return fetch(request);
84
+ const out = await runAsReadOnlyRequest(() => fetch(request));
85
+ if (!out.refused) return out.value;
86
+ return opts.refuse ? await opts.refuse(request, out.refused) : readOnlyRefusal(out.refused);
87
+ });
88
+ };
89
+ return Object.assign(scoped, fetch);
90
+ }
91
+
43
92
  export type TwinFetchHandlerResult = {
44
93
  status: number;
45
94
  body: unknown;
@@ -119,18 +168,23 @@ export function createTwinFetchFromHandler(
119
168
  // world instant. A client-settable occurredAt would be a direct R9 hole.
120
169
  // D3 — the wire's request id becomes the correlation of every action this handler appends.
121
170
  const requestId = request.headers.get('x-twins-request-id') ?? undefined;
122
- const invoke = () => handler({
171
+ const invoke = (asReadOnly = readOnly) => handler({
123
172
  ...config.handlerOptions,
124
173
  ...config.extras?.(request, url),
125
174
  method: request.method,
126
175
  path: url.pathname + (url.search || ''),
127
176
  body,
128
177
  headers,
129
- readOnly,
178
+ readOnly: asReadOnly,
130
179
  occurredAt: worldNow(),
131
180
  ...(config.root !== undefined ? { root: config.root } : {}),
132
181
  });
133
- const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
182
+ // a read-only request is, to the handler, a request to a read-only twin: the pack refuses its
183
+ // writes in the vendor's own shape (D3), and the kernel's write seam refuses any it misses
184
+ const scoped = readOnly || isReadOnlyRequest(request);
185
+ const run = (asReadOnly = scoped) => (requestId ? runWithCorrelationId(requestId, () => invoke(asReadOnly)) : invoke(asReadOnly));
186
+ // W3C trace context: a valid incoming traceparent scopes the handler, so its entries record it
187
+ const result = await runWithRequestTrace(request, () => (isReadOnlyRequest(request) ? readScoped(run) : run()));
134
188
  // A null/undefined body is an EMPTY reply (`JSON.stringify(null)` would serve the
135
189
  // four bytes "null") — and so is the estate's own 204 idiom, `body: ''` with a
136
190
  // null-body status: workerd THROWS on any body with 204/205/304 (review B2), where
@@ -145,3 +199,15 @@ export function createTwinFetchFromHandler(
145
199
  });
146
200
  };
147
201
  }
202
+
203
+ /** A read-only request through the handler seam: the handler runs once, with its writes refused at
204
+ * the kernel's write seam; a write it attempted answers 405 (rule D3). The handler is never asked
205
+ * twice (a second run would repeat whatever it did before its first write: a rate-limit slot, a
206
+ * vendor call). The seam enforces the marker for whoever sends it, but does not advertise
207
+ * `requestScopes`: a pack opts in to being handed a World's read-token requests by wrapping its
208
+ * fetch in `withRequestScopes`, once it has made sure nothing it does on a read escapes the seam. */
209
+ async function readScoped(run: (asReadOnly?: boolean) => TwinFetchHandlerResult | Promise<TwinFetchHandlerResult>): Promise<TwinFetchHandlerResult> {
210
+ const out = await runAsReadOnlyRequest(() => run());
211
+ if (!out.refused) return out.value;
212
+ return { status: 405, body: { error: 'read_only', message: out.refused.message } };
213
+ }
@@ -44,6 +44,7 @@ import {
44
44
  writeFileSync,
45
45
  } from 'node:fs';
46
46
  import { hostname } from 'node:os';
47
+ import { refuseReadOnlyWrite } from './request-scope.ts';
47
48
  import { basename, dirname, join, resolve } from 'node:path';
48
49
 
49
50
  /** Metadata a caller needs about a stored path. `isDirectory` distinguishes a JSON
@@ -183,6 +184,7 @@ export class FsWorldStore implements WorldStore {
183
184
  }
184
185
 
185
186
  append(path: string, data: string): void {
187
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
186
188
  // Historical appendDurable: a completed append syscall survives a process crash;
187
189
  // fsync additionally survives kernel-panic/power-loss when VOLTER_DURABLE=1.
188
190
  const fd = openSync(path, 'a');
@@ -195,6 +197,7 @@ export class FsWorldStore implements WorldStore {
195
197
  }
196
198
 
197
199
  write(path: string, data: string, options: { secret?: boolean } = {}): void {
200
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
198
201
  if (!options.secret) {
199
202
  mkdirSync(dirname(path), { recursive: true });
200
203
  writeFileSync(path, data);
@@ -206,6 +209,7 @@ export class FsWorldStore implements WorldStore {
206
209
  }
207
210
 
208
211
  writeAtomic(path: string, data: string, options: { secret?: boolean } = {}): void {
212
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
209
213
  mkdirSync(dirname(path), { recursive: true });
210
214
  const tmp = `${path}.${process.pid}.${Date.now()}.${fsAtomicSequence++}.tmp`;
211
215
  try {
@@ -222,6 +226,7 @@ export class FsWorldStore implements WorldStore {
222
226
  }
223
227
 
224
228
  remove(path: string): void {
229
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
225
230
  rmSync(path, { recursive: true, force: true });
226
231
  }
227
232
 
@@ -391,18 +396,21 @@ export class MemoryWorldStore implements WorldStore {
391
396
  }
392
397
 
393
398
  append(path: string, data: string): void {
399
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
394
400
  this.files.set(path, (this.files.get(path) ?? '') + data);
395
401
  this.sizes.set(path, (this.sizes.get(path) ?? 0) + byteLength(data));
396
402
  this.bump(path);
397
403
  }
398
404
 
399
405
  write(path: string, data: string): void {
406
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
400
407
  this.files.set(path, data);
401
408
  this.sizes.set(path, byteLength(data));
402
409
  this.bump(path);
403
410
  }
404
411
 
405
412
  writeAtomic(path: string, data: string): void {
413
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
406
414
  // Atomic by nature in a single process: the assignment is indivisible, so no reader
407
415
  // ever observes a partial document.
408
416
  this.files.set(path, data);
@@ -419,6 +427,7 @@ export class MemoryWorldStore implements WorldStore {
419
427
  }
420
428
 
421
429
  remove(path: string): void {
430
+ refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
422
431
  let removedAny = false;
423
432
  if (this.files.delete(path)) {
424
433
  this.versions.delete(path);