@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.
- package/dist/generated/pack-facts.json +36 -0
- package/dist/src/actions.d.ts +3 -0
- package/dist/src/actions.js +13 -2
- package/dist/src/blob-store.d.ts +3 -0
- package/dist/src/blob-store.js +15 -1
- package/dist/src/client-bundle.d.ts +4 -0
- package/dist/src/client-bundle.js +13 -2
- package/dist/src/derived-core.d.ts +6 -0
- package/dist/src/derived-core.js +17 -3
- package/dist/src/derived.js +5 -2
- package/dist/src/git/refs.js +5 -3
- package/dist/src/head.js +12 -2
- package/dist/src/index.d.ts +6 -3
- package/dist/src/index.js +5 -3
- package/dist/src/log.d.ts +2 -0
- package/dist/src/request-scope.d.ts +28 -0
- package/dist/src/request-scope.js +70 -0
- package/dist/src/storage.js +2 -0
- package/dist/src/trace-context.d.ts +31 -0
- package/dist/src/trace-context.js +78 -0
- package/dist/src/twin-fetch.d.ts +16 -0
- package/dist/src/twin-fetch.js +66 -3
- package/dist/src/world-store.js +9 -0
- package/dist/vendor-hosts.cjs +6 -5
- package/generated/pack-facts.json +36 -0
- package/package.json +1 -1
- package/src/actions.ts +16 -2
- package/src/blob-store.ts +15 -1
- package/src/client-bundle.ts +15 -2
- package/src/derived-core.ts +17 -3
- package/src/derived.ts +5 -2
- package/src/git/refs.ts +5 -3
- package/src/head.ts +11 -2
- package/src/index.ts +6 -3
- package/src/log.ts +2 -0
- package/src/request-scope.ts +74 -0
- package/src/storage.ts +2 -0
- package/src/trace-context.ts +87 -0
- package/src/twin-fetch.ts +69 -3
- package/src/world-store.ts +9 -0
- package/vendor-hosts.cjs +6 -5
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/world-store.ts
CHANGED
|
@@ -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);
|
package/vendor-hosts.cjs
CHANGED
|
@@ -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,
|
|
81
|
-
* userinfo alias
|
|
82
|
-
*
|
|
83
|
-
* (
|
|
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
|