@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.
- 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/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/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
|
+
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/actions.ts
CHANGED
|
@@ -21,7 +21,9 @@ import { appendDurable, eventsLockPath, ownerStoreRoots, projectionLockPath, twi
|
|
|
21
21
|
import { landAsPlaceholder, placeholderPullActive } from './placeholder-remote.ts';
|
|
22
22
|
import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp, type Receipt } from './log.ts';
|
|
23
23
|
import { WorldServiceEventSchema } from './schemas.ts';
|
|
24
|
+
import { refuseReadOnlyWrite } from './request-scope.ts';
|
|
24
25
|
import { getActiveWorldStore } from './world-store.ts';
|
|
26
|
+
import { currentTraceparent } from './trace-context.ts';
|
|
25
27
|
import type { WorldServiceEvent } from './types.ts';
|
|
26
28
|
import type { TwinResource } from './serve.ts';
|
|
27
29
|
|
|
@@ -94,6 +96,9 @@ export type TwinAction = {
|
|
|
94
96
|
* alone, with no dependence on actionId/pushId naming conventions.
|
|
95
97
|
*/
|
|
96
98
|
correlationId?: string;
|
|
99
|
+
/** The W3C `traceparent` of the request that caused this action, when it carried a valid one
|
|
100
|
+
* (trace-context.ts). Authoring metadata like correlationId: excluded from replay identity. */
|
|
101
|
+
traceparent?: string;
|
|
97
102
|
/**
|
|
98
103
|
* The action's MERGE BASE against the remote (runtime contract R14, non-fast-forward
|
|
99
104
|
* rule): per touched subject, the observed mirror's remote ref (shadow.ts remoteRefs)
|
|
@@ -129,7 +134,10 @@ function withCorrelationId(action: TwinAction): TwinAction {
|
|
|
129
134
|
// The wire's request id (the addressed service stamps every response with one and threads it
|
|
130
135
|
// into the handler's async context) is the correlation when the caller supplies none — the
|
|
131
136
|
// join from a request on the wire to the action rows it caused, with no pack involved.
|
|
132
|
-
|
|
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 {
|
package/src/client-bundle.ts
CHANGED
|
@@ -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);
|
package/src/derived-core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
12
|
-
|
|
12
|
+
// a read-only request moves no ref (request-scope.ts): refused before the store is touched
|
|
13
|
+
set(name: string, sha: string): void { refuseReadOnlyWrite('git'); this.store.write(this.path(name), `${sha}\n`); }
|
|
14
|
+
delete(name: string): void { refuseReadOnlyWrite('git'); if (this.store.exists(this.path(name))) this.store.remove(this.path(name)); }
|
|
13
15
|
head(): string { const v = this.store.read(this.path('HEAD')); return v === null ? 'refs/heads/main' : v.trim().replace(/^ref: /, ''); }
|
|
14
|
-
setHead(target: string): void { this.store.write(this.path('HEAD'), `ref: ${target}\n`); }
|
|
16
|
+
setHead(target: string): void { refuseReadOnlyWrite('git'); this.store.write(this.path('HEAD'), `ref: ${target}\n`); }
|
|
15
17
|
/** Run `fn` holding the repository's ref lock (the world store's exclusive lock). */
|
|
16
18
|
withLock<T>(fn: () => T): T { return this.store.withLock(this.path('refs.lock'), fn); }
|
|
17
19
|
/** Every `refs/...` name with its sha, sorted by name. */
|
package/src/head.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// record: a `deployed` copy is never performed again, a `failed` one is retried.
|
|
10
10
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
13
|
import { confirmAction, isTwinBookkeeping, resolveSubjectId, revertAction, type TwinAction } from './actions.ts';
|
|
14
14
|
import { openSealedCredential, sealCredential, type CredentialPayload, type SealedCredential } from './credential.ts';
|
|
15
15
|
import { buildRemoteExecute, validateRemoteOrigin, type CredentialCustody } from './executor.ts';
|
|
@@ -116,11 +116,20 @@ export async function loadChecks(worldRoot: string): Promise<Check[]> {
|
|
|
116
116
|
* loader that runs the check in an isolate of its own, with no network and nothing of the World's. */
|
|
117
117
|
export type CheckLoader = (path: string, name: string) => Promise<Check>;
|
|
118
118
|
const importCheck: CheckLoader = async (path, name) => {
|
|
119
|
-
|
|
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);
|