@volter/world-core 2.0.0 → 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.
- package/app-route.cjs +95 -6
- package/app-route.d.cts +2 -1
- package/dist/app-route.cjs +95 -6
- package/dist/app-route.d.cts +2 -1
- package/dist/generated/pack-facts.json +127 -8
- package/dist/inject.cjs +46 -1
- package/dist/src/actions.d.ts +27 -0
- package/dist/src/actions.js +62 -4
- 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 +8 -5
- package/dist/src/index.js +9 -5
- package/dist/src/log.d.ts +2 -0
- package/dist/src/packRegistry.d.ts +8 -6
- package/dist/src/redis/engine.d.ts +308 -0
- package/dist/src/redis/engine.js +1663 -0
- package/dist/src/redis/index.d.ts +2 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/lua.d.ts +153 -0
- package/dist/src/redis/lua.js +1373 -0
- package/dist/src/request-scope.d.ts +28 -0
- package/dist/src/request-scope.js +70 -0
- package/dist/src/storage.d.ts +22 -1
- package/dist/src/storage.js +57 -1
- 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-clock.d.ts +2 -2
- package/dist/src/world-clock.js +18 -17
- package/dist/src/world-store.js +9 -0
- package/dist/vendor-hosts.cjs +2 -2
- package/dist/world-clock.cjs +40 -0
- package/dist/world-clock.d.cts +4 -0
- package/generated/pack-facts.json +127 -8
- package/inject.cjs +46 -1
- package/package.json +11 -1
- package/src/actions.ts +69 -4
- 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 +11 -3
- package/src/log.ts +2 -0
- package/src/packRegistry.ts +8 -6
- package/src/redis/engine.ts +1468 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/lua.ts +1250 -0
- package/src/request-scope.ts +74 -0
- package/src/storage.ts +54 -2
- package/src/trace-context.ts +87 -0
- package/src/twin-fetch.ts +69 -3
- package/src/world-clock.ts +19 -16
- package/src/world-store.ts +9 -0
- package/vendor-hosts.cjs +2 -2
- package/world-clock.cjs +40 -0
- package/world-clock.d.cts +4 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The request header that marks a request read-only. Its one value is `1`. */
|
|
2
|
+
export declare const READ_ONLY_REQUEST_HEADER = "x-volter-read-only";
|
|
3
|
+
/** Whether a request carries the read-only marker. */
|
|
4
|
+
export declare function isReadOnlyRequest(request: Request | {
|
|
5
|
+
headers: Headers;
|
|
6
|
+
}): boolean;
|
|
7
|
+
/** A write a read-only request attempted: refused before anything was written (rule D3's 405). */
|
|
8
|
+
export declare class ReadOnlyRequestError extends Error {
|
|
9
|
+
readonly service: string;
|
|
10
|
+
constructor(service: string);
|
|
11
|
+
}
|
|
12
|
+
/** Run `fn` as a read-only request: every write it attempts outside a vendor move is refused. The
|
|
13
|
+
* answer says whether one was — also when the handler caught the refusal and answered something
|
|
14
|
+
* else, so a caller can answer the refusal whatever the handler made of it. */
|
|
15
|
+
export declare function runAsReadOnlyRequest<T>(fn: () => T | Promise<T>): Promise<{
|
|
16
|
+
value: T;
|
|
17
|
+
refused: undefined;
|
|
18
|
+
} | {
|
|
19
|
+
refused: ReadOnlyRequestError;
|
|
20
|
+
}>;
|
|
21
|
+
/** Run `fn` as the VENDOR's own move (a pack's catch-up): its writes land even under a read-only
|
|
22
|
+
* request. Outside one it changes nothing. */
|
|
23
|
+
export declare function runAsVendorMove<T>(fn: () => T): T;
|
|
24
|
+
/** Whether a write attempted here would be refused. */
|
|
25
|
+
export declare function writesRefused(): boolean;
|
|
26
|
+
/** The write seam's check: throws (and records) the refusal when a write attempted here is a
|
|
27
|
+
* read-only request's. Called by every appender before it writes anything. */
|
|
28
|
+
export declare function refuseReadOnlyWrite(service: string): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// THE READ-ONLY REQUEST (docs/contributing/architecture.md, "Viewing a World"): a request that
|
|
2
|
+
// carries `x-volter-read-only: 1` is served with the twin's writes refused — whatever operation it
|
|
3
|
+
// names and whatever HTTP method it came by (an RPC wire POSTs its reads). The marker only ever
|
|
4
|
+
// RESTRICTS, so the twin trusts it without auth: a caller who sends it only limits itself. The
|
|
5
|
+
// World's doors set it on every read-scope request and never classify operations themselves.
|
|
6
|
+
//
|
|
7
|
+
// Enforcement lives at the kernel's write seam: every appender (actions.ts, storage.ts appendEvent)
|
|
8
|
+
// asks `refuseReadOnlyWrite` before it writes anything, so the first write a read-only request
|
|
9
|
+
// attempts is refused and nothing is partially applied. The VENDOR's own moves are not the caller's
|
|
10
|
+
// writes: a pack's catch-up (a renewal, a payout, a file's expiry falling due by the World clock)
|
|
11
|
+
// runs under `runAsVendorMove` and still lands under a read-only request, as a real vendor renews a
|
|
12
|
+
// subscription whoever is looking.
|
|
13
|
+
//
|
|
14
|
+
// Created on first use, never at import: a browser bundle of a mirror client carries the kernel and
|
|
15
|
+
// has no AsyncLocalStorage (see serve.ts identitySlot).
|
|
16
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
|
+
/** The request header that marks a request read-only. Its one value is `1`. */
|
|
18
|
+
export const READ_ONLY_REQUEST_HEADER = 'x-volter-read-only';
|
|
19
|
+
/** Whether a request carries the read-only marker. */
|
|
20
|
+
export function isReadOnlyRequest(request) {
|
|
21
|
+
return request.headers.get(READ_ONLY_REQUEST_HEADER)?.trim() === '1';
|
|
22
|
+
}
|
|
23
|
+
/** A write a read-only request attempted: refused before anything was written (rule D3's 405). */
|
|
24
|
+
export class ReadOnlyRequestError extends Error {
|
|
25
|
+
service;
|
|
26
|
+
constructor(service) {
|
|
27
|
+
super(`${service}: this request is read-only (${READ_ONLY_REQUEST_HEADER}: 1); it cannot write`);
|
|
28
|
+
this.service = service;
|
|
29
|
+
this.name = 'ReadOnlyRequestError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let writeScopeStore;
|
|
33
|
+
const writeScope = () => (writeScopeStore ??= new AsyncLocalStorage());
|
|
34
|
+
/** Run `fn` as a read-only request: every write it attempts outside a vendor move is refused. The
|
|
35
|
+
* answer says whether one was — also when the handler caught the refusal and answered something
|
|
36
|
+
* else, so a caller can answer the refusal whatever the handler made of it. */
|
|
37
|
+
export async function runAsReadOnlyRequest(fn) {
|
|
38
|
+
const scope = { readOnly: true, vendorMove: false, refusals: [] };
|
|
39
|
+
try {
|
|
40
|
+
const value = await writeScope().run(scope, fn);
|
|
41
|
+
const refused = scope.refusals[0];
|
|
42
|
+
return refused ? { refused } : { value, refused: undefined };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const refused = scope.refusals[0] ?? (error instanceof ReadOnlyRequestError ? error : undefined);
|
|
46
|
+
if (refused)
|
|
47
|
+
return { refused };
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Run `fn` as the VENDOR's own move (a pack's catch-up): its writes land even under a read-only
|
|
52
|
+
* request. Outside one it changes nothing. */
|
|
53
|
+
export function runAsVendorMove(fn) {
|
|
54
|
+
const current = writeScope().getStore();
|
|
55
|
+
return current ? writeScope().run({ ...current, vendorMove: true }, fn) : fn();
|
|
56
|
+
}
|
|
57
|
+
/** Whether a write attempted here would be refused. */
|
|
58
|
+
export function writesRefused() {
|
|
59
|
+
const scope = writeScopeStore?.getStore();
|
|
60
|
+
return scope !== undefined && scope.readOnly && !scope.vendorMove;
|
|
61
|
+
}
|
|
62
|
+
/** The write seam's check: throws (and records) the refusal when a write attempted here is a
|
|
63
|
+
* read-only request's. Called by every appender before it writes anything. */
|
|
64
|
+
export function refuseReadOnlyWrite(service) {
|
|
65
|
+
if (!writesRefused())
|
|
66
|
+
return;
|
|
67
|
+
const error = new ReadOnlyRequestError(service);
|
|
68
|
+
writeScopeStore.getStore().refusals.push(error);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
package/dist/src/storage.d.ts
CHANGED
|
@@ -7,8 +7,29 @@ import type { AppendEventResult, GenericWorldState, WorldPaths, WorldReducer, Wo
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function stateDirName(): string;
|
|
9
9
|
export declare function worldStateRoot(root?: string): string;
|
|
10
|
-
|
|
10
|
+
/** Install the observer; the one it replaces is returned (a test puts it back). */
|
|
11
|
+
export declare function observeWorldPaths(observer: ((service: string, root: string | undefined) => void) | undefined): ((service: string, root: string | undefined) => void) | undefined;
|
|
12
|
+
/** Run `fn` without its worldPaths calls reaching the identity observer. */
|
|
13
|
+
export declare function withoutIdentityClaim<T>(fn: () => T): T;
|
|
11
14
|
export declare function worldPaths(service: string, root?: string): WorldPaths;
|
|
15
|
+
/**
|
|
16
|
+
* THE WORLD'S DATA DIRECTORY, as the World runtime hands it to every service it starts. Each twin of a World runs on
|
|
17
|
+
* its own root, `<data>/<service id>` (world-runtime's startService and startColocatedServices), so one twin's
|
|
18
|
+
* `.volter/world/<service>` is not another's; `ownerStoreRoots` finds a store across them.
|
|
19
|
+
*/
|
|
20
|
+
export declare const WORLD_DATA_ENV = "VOLTER_WORLD_DATA";
|
|
21
|
+
/**
|
|
22
|
+
* The roots that hold the store of `owner` (ANOTHER pack's, read by contract: architecture A3), for a twin on `root`:
|
|
23
|
+
* - `[root]` when `root` itself holds that store (both packs on one root: `volter world serve`, a test, a pack serving
|
|
24
|
+
* several services on its root, as aws does);
|
|
25
|
+
* - else, when `root` is one of a World's service roots (a directory directly under the World's data directory), every
|
|
26
|
+
* other service root there that holds it (usually one; two when the World runs the owning pack twice);
|
|
27
|
+
* - else `[]`: nothing is issued yet, or the twin runs outside a World.
|
|
28
|
+
* Only a declared cross-pack read resolves through here (`projectOwnerResources`); a pack's reads of its OWN store never
|
|
29
|
+
* leave its root, so a fresh twin never reads a sibling that happens to run the same pack. Not memoized: it is a few
|
|
30
|
+
* stats per service root, and a store that appears after the first read (the owner's first write) must be found.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ownerStoreRoots(owner: string, root?: string): string[];
|
|
12
33
|
/** Read + parse a whole-file JSON sidecar, citing the path on a parse failure (mirrors
|
|
13
34
|
* readJsonl's `${path}:...` error shape). Callers own existence semantics — this parses a
|
|
14
35
|
* file that is expected to exist; guard with existsSync first when absence is allowed. */
|
package/dist/src/storage.js
CHANGED
|
@@ -3,6 +3,7 @@ import { assertNotBeingRemoved, withAncestryLock, withStateRemoval } from "./anc
|
|
|
3
3
|
import { appendParentEntry, toEntry } from "./log.js";
|
|
4
4
|
import { GenericWorldStateSchema, WorldServiceEventSchema, } from "./schemas.js";
|
|
5
5
|
import { getActiveWorldStore } from "./world-store.js";
|
|
6
|
+
import { refuseReadOnlyWrite } from "./request-scope.js";
|
|
6
7
|
import { parentEntries, toEvent } from "./log.js";
|
|
7
8
|
function nowIso() {
|
|
8
9
|
return new Date().toISOString();
|
|
@@ -40,12 +41,29 @@ function assertServiceName(service) {
|
|
|
40
41
|
* Nothing else may use this: it is a one-slot observability hook, never a control seam.
|
|
41
42
|
*/
|
|
42
43
|
let worldPathsObserver;
|
|
44
|
+
/** Install the observer; the one it replaces is returned (a test puts it back). */
|
|
43
45
|
export function observeWorldPaths(observer) {
|
|
46
|
+
const previous = worldPathsObserver;
|
|
44
47
|
worldPathsObserver = observer;
|
|
48
|
+
return previous;
|
|
49
|
+
}
|
|
50
|
+
/** While > 0, a read of ANOTHER pack's store is in progress (`projectOwnerResources`): that is not the request's twin
|
|
51
|
+
* saying who it is, so it claims no journal identity. */
|
|
52
|
+
let identityQuiet = 0;
|
|
53
|
+
/** Run `fn` without its worldPaths calls reaching the identity observer. */
|
|
54
|
+
export function withoutIdentityClaim(fn) {
|
|
55
|
+
identityQuiet++;
|
|
56
|
+
try {
|
|
57
|
+
return fn();
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
identityQuiet--;
|
|
61
|
+
}
|
|
45
62
|
}
|
|
46
63
|
export function worldPaths(service, root) {
|
|
47
64
|
const safeService = assertServiceName(service);
|
|
48
|
-
|
|
65
|
+
if (identityQuiet === 0)
|
|
66
|
+
worldPathsObserver?.(safeService, root);
|
|
49
67
|
const resolvedRoot = projectRoot(root);
|
|
50
68
|
const dir = join(worldStateRoot(root), safeService);
|
|
51
69
|
return {
|
|
@@ -60,6 +78,43 @@ export function worldPaths(service, root) {
|
|
|
60
78
|
eventQueue: join(dir, 'event-queue.jsonl'),
|
|
61
79
|
};
|
|
62
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* THE WORLD'S DATA DIRECTORY, as the World runtime hands it to every service it starts. Each twin of a World runs on
|
|
83
|
+
* its own root, `<data>/<service id>` (world-runtime's startService and startColocatedServices), so one twin's
|
|
84
|
+
* `.volter/world/<service>` is not another's; `ownerStoreRoots` finds a store across them.
|
|
85
|
+
*/
|
|
86
|
+
export const WORLD_DATA_ENV = 'VOLTER_WORLD_DATA';
|
|
87
|
+
/** Does `root` hold `service`'s store: its log or its branch record (what a write or a fork leaves; a read leaves
|
|
88
|
+
* neither)? The paths are built without worldPaths, so a probe claims no journal identity. */
|
|
89
|
+
function holdsStore(service, root) {
|
|
90
|
+
const dir = join(worldStateRoot(root), service);
|
|
91
|
+
const store = getActiveWorldStore();
|
|
92
|
+
return ['events.jsonl', 'actions.jsonl', 'branch.json'].some((file) => store.exists(join(dir, file)));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The roots that hold the store of `owner` (ANOTHER pack's, read by contract: architecture A3), for a twin on `root`:
|
|
96
|
+
* - `[root]` when `root` itself holds that store (both packs on one root: `volter world serve`, a test, a pack serving
|
|
97
|
+
* several services on its root, as aws does);
|
|
98
|
+
* - else, when `root` is one of a World's service roots (a directory directly under the World's data directory), every
|
|
99
|
+
* other service root there that holds it (usually one; two when the World runs the owning pack twice);
|
|
100
|
+
* - else `[]`: nothing is issued yet, or the twin runs outside a World.
|
|
101
|
+
* Only a declared cross-pack read resolves through here (`projectOwnerResources`); a pack's reads of its OWN store never
|
|
102
|
+
* leave its root, so a fresh twin never reads a sibling that happens to run the same pack. Not memoized: it is a few
|
|
103
|
+
* stats per service root, and a store that appears after the first read (the owner's first write) must be found.
|
|
104
|
+
*/
|
|
105
|
+
export function ownerStoreRoots(owner, root) {
|
|
106
|
+
const safeOwner = assertServiceName(owner);
|
|
107
|
+
const own = projectRoot(root);
|
|
108
|
+
if (holdsStore(safeOwner, own))
|
|
109
|
+
return [own];
|
|
110
|
+
const data = typeof process === 'undefined' ? undefined : process.env[WORLD_DATA_ENV];
|
|
111
|
+
if (!data || dirname(own) !== resolve(data))
|
|
112
|
+
return [];
|
|
113
|
+
return getActiveWorldStore().list(dirname(own))
|
|
114
|
+
.map((name) => join(dirname(own), name))
|
|
115
|
+
.filter((candidate) => candidate !== own && holdsStore(safeOwner, candidate))
|
|
116
|
+
.sort();
|
|
117
|
+
}
|
|
63
118
|
function readJsonl(path) {
|
|
64
119
|
const rows = [];
|
|
65
120
|
for (const [index, line] of getActiveWorldStore().readLines(path).entries()) {
|
|
@@ -172,6 +227,7 @@ export function projectionLockPath(paths) {
|
|
|
172
227
|
*/
|
|
173
228
|
export function appendEvent(event, root) {
|
|
174
229
|
const parsed = WorldServiceEventSchema.parse(event);
|
|
230
|
+
refuseReadOnlyWrite(parsed.service); // a read-only request writes nothing (request-scope.ts)
|
|
175
231
|
const paths = worldPaths(parsed.service, root);
|
|
176
232
|
ensureEventDirs(paths);
|
|
177
233
|
const { appended } = appendParentEntry(toEntry(parsed), root);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const TRACEPARENT_HEADER = "traceparent";
|
|
2
|
+
export type Traceparent = {
|
|
3
|
+
version: string;
|
|
4
|
+
traceId: string;
|
|
5
|
+
parentId: string;
|
|
6
|
+
flags: string;
|
|
7
|
+
};
|
|
8
|
+
/** A `traceparent` header value parsed strictly, or null: malformed, version `ff`, or an all-zero
|
|
9
|
+
* trace-id or parent-id is no trace at all (the spec: such a header is ignored). */
|
|
10
|
+
export declare function parseTraceparent(value: unknown): Traceparent | null;
|
|
11
|
+
/** The value itself when it is a valid traceparent (normalized: trimmed), else undefined. */
|
|
12
|
+
export declare function validTraceparent(value: unknown): string | undefined;
|
|
13
|
+
/** Run `fn` with `traceparent` as the request's trace context; an invalid value runs `fn` outside any. */
|
|
14
|
+
export declare function runWithTraceparent<T>(traceparent: string | undefined, fn: () => T): T;
|
|
15
|
+
/** The trace context of the request being handled, when it carried a valid traceparent. */
|
|
16
|
+
export declare function currentTraceparent(): string | undefined;
|
|
17
|
+
/** Run `fn` inside the trace context `request` carries (its `traceparent` header), if any. */
|
|
18
|
+
export declare function runWithRequestTrace<T>(request: Request, fn: () => T): T;
|
|
19
|
+
/**
|
|
20
|
+
* The `traceparent` an outbound delivery carries: a CHILD of its cause — the same trace-id and
|
|
21
|
+
* flags, a new parent-id — so the receiving handler continues the cause's trace. The cause is the
|
|
22
|
+
* given traceparent, or an entry's recorded one, or (omitted) the trace context of the request
|
|
23
|
+
* being handled. No valid cause, no traceparent: a delivery never invents a trace.
|
|
24
|
+
*/
|
|
25
|
+
export declare function traceparentForDelivery(cause?: string | {
|
|
26
|
+
traceparent?: unknown;
|
|
27
|
+
} | null): string | undefined;
|
|
28
|
+
/** `traceparentForDelivery` as headers to spread into a delivery's own: `{ traceparent }` or `{}`. */
|
|
29
|
+
export declare function deliveryTraceHeaders(cause?: string | {
|
|
30
|
+
traceparent?: unknown;
|
|
31
|
+
} | null): Record<string, string>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// W3C TRACE CONTEXT across a World (https://www.w3.org/TR/trace-context/): one cause followed across
|
|
2
|
+
// vendors by the tracing standard, never an id of our own. An app instrumented with OpenTelemetry
|
|
3
|
+
// sends `traceparent` on its outgoing calls; a real vendor ignores it, so the vendor wire is
|
|
4
|
+
// unchanged. The kernel serve seams (twin-fetch.ts, derived.ts) run a handler inside the request's
|
|
5
|
+
// valid traceparent; every entry appended while handling it records that value (actions.ts
|
|
6
|
+
// `traceparent`, authoring metadata excluded from replay identity like `correlationId`); and a
|
|
7
|
+
// pack's outbound delivery the write caused (a webhook to the app) carries a CHILD of it — the
|
|
8
|
+
// same trace-id, a new parent-id — so the app's handler continues the same trace.
|
|
9
|
+
//
|
|
10
|
+
// Nothing here is vendor knowledge, and nothing runs at import: the async-context store is made on
|
|
11
|
+
// first use (a browser bundle of a mirror client carries the kernel and has no AsyncLocalStorage).
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
|
+
export const TRACEPARENT_HEADER = 'traceparent';
|
|
14
|
+
// version-traceid-parentid-flags, lowercase hex only (the spec's HEXDIGLC). Version ff is invalid;
|
|
15
|
+
// a future version is read by its version-00 prefix only when nothing else follows, which keeps the
|
|
16
|
+
// accepted form exactly the one this module emits.
|
|
17
|
+
const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
18
|
+
const ZERO_TRACE = '0'.repeat(32);
|
|
19
|
+
const ZERO_SPAN = '0'.repeat(16);
|
|
20
|
+
/** A `traceparent` header value parsed strictly, or null: malformed, version `ff`, or an all-zero
|
|
21
|
+
* trace-id or parent-id is no trace at all (the spec: such a header is ignored). */
|
|
22
|
+
export function parseTraceparent(value) {
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
return null;
|
|
25
|
+
const m = TRACEPARENT.exec(value.trim());
|
|
26
|
+
if (!m)
|
|
27
|
+
return null;
|
|
28
|
+
const [, version, traceId, parentId, flags] = m;
|
|
29
|
+
if (version === 'ff' || traceId === ZERO_TRACE || parentId === ZERO_SPAN)
|
|
30
|
+
return null;
|
|
31
|
+
return { version, traceId, parentId, flags };
|
|
32
|
+
}
|
|
33
|
+
/** The value itself when it is a valid traceparent (normalized: trimmed), else undefined. */
|
|
34
|
+
export function validTraceparent(value) {
|
|
35
|
+
const parsed = parseTraceparent(value);
|
|
36
|
+
return parsed ? `${parsed.version}-${parsed.traceId}-${parsed.parentId}-${parsed.flags}` : undefined;
|
|
37
|
+
}
|
|
38
|
+
// created on first use, never at import (see the header)
|
|
39
|
+
let traceStore;
|
|
40
|
+
const traceScope = () => (traceStore ??= new AsyncLocalStorage());
|
|
41
|
+
/** Run `fn` with `traceparent` as the request's trace context; an invalid value runs `fn` outside any. */
|
|
42
|
+
export function runWithTraceparent(traceparent, fn) {
|
|
43
|
+
const valid = validTraceparent(traceparent);
|
|
44
|
+
return valid ? traceScope().run(valid, fn) : fn();
|
|
45
|
+
}
|
|
46
|
+
/** The trace context of the request being handled, when it carried a valid traceparent. */
|
|
47
|
+
export function currentTraceparent() {
|
|
48
|
+
return traceStore?.getStore();
|
|
49
|
+
}
|
|
50
|
+
/** Run `fn` inside the trace context `request` carries (its `traceparent` header), if any. */
|
|
51
|
+
export function runWithRequestTrace(request, fn) {
|
|
52
|
+
return runWithTraceparent(request.headers.get(TRACEPARENT_HEADER) ?? undefined, fn);
|
|
53
|
+
}
|
|
54
|
+
function newSpanId() {
|
|
55
|
+
const bytes = new Uint8Array(8);
|
|
56
|
+
for (;;) {
|
|
57
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
58
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
59
|
+
if (hex !== ZERO_SPAN)
|
|
60
|
+
return hex;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The `traceparent` an outbound delivery carries: a CHILD of its cause — the same trace-id and
|
|
65
|
+
* flags, a new parent-id — so the receiving handler continues the cause's trace. The cause is the
|
|
66
|
+
* given traceparent, or an entry's recorded one, or (omitted) the trace context of the request
|
|
67
|
+
* being handled. No valid cause, no traceparent: a delivery never invents a trace.
|
|
68
|
+
*/
|
|
69
|
+
export function traceparentForDelivery(cause) {
|
|
70
|
+
const source = cause === undefined ? currentTraceparent() : typeof cause === 'string' ? cause : cause?.traceparent;
|
|
71
|
+
const parent = parseTraceparent(source);
|
|
72
|
+
return parent ? `00-${parent.traceId}-${newSpanId()}-${parent.flags}` : undefined;
|
|
73
|
+
}
|
|
74
|
+
/** `traceparentForDelivery` as headers to spread into a delivery's own: `{ traceparent }` or `{}`. */
|
|
75
|
+
export function deliveryTraceHeaders(cause) {
|
|
76
|
+
const traceparent = traceparentForDelivery(cause);
|
|
77
|
+
return traceparent ? { [TRACEPARENT_HEADER]: traceparent } : {};
|
|
78
|
+
}
|
package/dist/src/twin-fetch.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/twin-fetch.js
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.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
|
-
|
|
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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare const WORLD_CLOCK_ENV = "TWIN_WORLD_CLOCK_FILE";
|
|
2
|
-
/** The world's "now": the
|
|
3
|
-
*
|
|
2
|
+
/** The world's "now": the clock file's time when one is configured (its frozen instant, or a running clock's time
|
|
3
|
+
* now); real wall-clock time otherwise. */
|
|
4
4
|
export declare function worldNow(): string;
|
package/dist/src/world-clock.js
CHANGED
|
@@ -6,27 +6,28 @@
|
|
|
6
6
|
// is the operator door (set / advance / show). No file, or no env → real wall-clock time,
|
|
7
7
|
// exactly the pre-clock behavior; a world only has scripted time when its operator says so.
|
|
8
8
|
//
|
|
9
|
-
// File format
|
|
10
|
-
// instant
|
|
11
|
-
// a
|
|
12
|
-
//
|
|
13
|
-
// hydrated serverless namespace must carry its frozen instant with it (runtime contract R11–R14
|
|
14
|
-
// — a MemoryWorldStore world that read the clock off the host filesystem would silently
|
|
15
|
-
// serve the HOST's time, the exact two-services-disagreeing-about-now defect below).
|
|
9
|
+
// File format (world-clock.cjs, the one home of it, shared with the injector that gives an application process the
|
|
10
|
+
// same time): a FROZEN instant, one ISO-8601 line, every read returning exactly that instant; or a RUNNING clock,
|
|
11
|
+
// `<instant> from <wall instant>`, the World's time at a moment of the machine's, running on at the machine's pace (a
|
|
12
|
+
// World serving an application). Moving time is an explicit operator act either way, never a background drift.
|
|
16
13
|
import { getActiveWorldStore } from "./world-store.js";
|
|
14
|
+
import clockForm from '../world-clock.cjs';
|
|
17
15
|
export const WORLD_CLOCK_ENV = 'TWIN_WORLD_CLOCK_FILE';
|
|
18
|
-
/** The
|
|
19
|
-
*
|
|
16
|
+
/** The machine's own time: the real Date.now even where the injector gives the process the World's time (inject.cjs
|
|
17
|
+
* leaves the original under this symbol), so a running clock's offset is applied once. */
|
|
18
|
+
const machineNow = () => {
|
|
19
|
+
const real = globalThis[Symbol.for('volter.machineDateNow')];
|
|
20
|
+
return typeof real === 'function' ? real() : Date.now();
|
|
21
|
+
};
|
|
22
|
+
/** The world's "now": the clock file's time when one is configured (its frozen instant, or a running clock's time
|
|
23
|
+
* now); real wall-clock time otherwise. */
|
|
20
24
|
export function worldNow() {
|
|
21
25
|
const file = process.env[WORLD_CLOCK_ENV];
|
|
22
26
|
if (file && getActiveWorldStore().exists(file)) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// A configured-but-corrupt clock is a WORLD defect — fail loudly, never silently drift
|
|
28
|
-
// back to wall-clock (two services disagreeing about now breaks the world).
|
|
29
|
-
throw new Error(`world clock: ${file} does not contain a parseable ISO-8601 instant (got ${JSON.stringify(raw.slice(0, 40))})`);
|
|
27
|
+
// A configured-but-corrupt clock is a WORLD defect — parseClock fails loudly, never silently drifting back to
|
|
28
|
+
// wall-clock (two services disagreeing about now breaks the world).
|
|
29
|
+
const clock = clockForm.parseClock(getActiveWorldStore().read(file) ?? '');
|
|
30
|
+
return new Date(clockForm.clockNowMs(clock, machineNow())).toISOString();
|
|
30
31
|
}
|
|
31
|
-
return new Date().toISOString();
|
|
32
|
+
return new Date(machineNow()).toISOString();
|
|
32
33
|
}
|
package/dist/src/world-store.js
CHANGED
|
@@ -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/dist/vendor-hosts.cjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
//
|
|
23
23
|
// THE SHARED-HOST RULE (Cal.com incident, 2026-08): a shared-host predicate must claim ONLY the
|
|
24
24
|
// paths its twin actually serves — never "the rest of the host". Claiming the remainder is how
|
|
25
|
-
// every `@googleapis/calendar` call (
|
|
25
|
+
// every `@googleapis/calendar` call (all of its methods default to www.googleapis.com) was routed
|
|
26
26
|
// into the googleauth/gemini twin and answered with a plausible Google-shaped 404: a mis-route
|
|
27
27
|
// that fails OPEN and PLAUSIBLE, strictly worse than a coverage gap. An UNCLAIMED path on a
|
|
28
28
|
// claimed host is refused LOUDLY instead (see unclaimedTwinnedHostPathMessage below): the error
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* Exactly the routes gemini-twin.ts §googleauth models: `POST /token` (the oauth2.googleapis.com
|
|
33
33
|
* path, also answered on the legacy host) and `POST /oauth2/v4/token` (the legacy
|
|
34
34
|
* www.googleapis.com token path older google-auth-library versions default to). NOTHING else:
|
|
35
|
-
* /calendar/v3
|
|
35
|
+
* /calendar/v3/* (the googlecalendar pack's), /drive/v3/*, … are other Google products, and
|
|
36
36
|
* claiming them would mis-route those SDKs into an auth twin that answers with vendor-shaped
|
|
37
37
|
* 404s (the Cal.com incident).
|
|
38
38
|
*/
|