Skip to content

Commit 589ff57

Browse files
committed
fix(rest,runtime)!: a /data PATCH or DELETE refuses a version token that can never carry a version, on both doors
Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a2adc8 commit 589ff57

2 files changed

Lines changed: 157 additions & 10 deletions

File tree

‎packages/rest/src/rest-server.ts‎

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,6 +1733,54 @@ function sendMetaItemAbsent(res: any): void {
17331733
});
17341734
}
17351735

1736+
/**
1737+
* [#23492] What a `/data` write door does with the optimistic-concurrency
1738+
* token it read — the body's `expectedVersion` (`PATCH`) or `?expectedVersion`
1739+
* (`DELETE`), else `If-Match` — before the protocol is asked. One of three:
1740+
*
1741+
* - ABSENT: the spelling consulted is `undefined` or `null`. The body key or
1742+
* the query name falls through to `If-Match`, and no `If-Match` either is
1743+
* the legal unguarded write.
1744+
* - JUDGED: a string with something in it, or a finite number. The door
1745+
* forwards its string, and the protocol compares it (`409
1746+
* CONCURRENT_UPDATE` on a mismatch; the quoted-empty tag `""` is the
1747+
* protocol's own `400`, `MalformedVersionTokenError`).
1748+
* - REFUSED `400 VALIDATION_FAILED`: anything else the caller put in a token
1749+
* position — an empty or blank string, a boolean, an array, an object. It
1750+
* can never carry a version, so it is the protocol's ruled answer to `""`
1751+
* (maintainer decision #20 ①: a token shape that can never carry a version
1752+
* is refused, neither read as "no token" nor answered `409`), given here
1753+
* because the protocol never sees these: it reads a blank string as no
1754+
* token.
1755+
*
1756+
* What it replaces is a truthiness test. The door forwarded a token only when
1757+
* it was truthy, and `bodyVersion ?? If-Match` reads `If-Match` only when the
1758+
* body key is nullish, so `expectedVersion: ''` or `false` — not nullish, not
1759+
* truthy — hid a stale `If-Match`, and the write ran unguarded with a `200`.
1760+
* `?expectedVersion=` did the same to a `DELETE`, and an empty `If-Match`
1761+
* alone, the caller asking for a guard and sending nothing to judge, ran
1762+
* unguarded too.
1763+
*
1764+
* Returns the refusal's sentence, or `undefined` when the token is absent or
1765+
* judged. The runtime dispatcher's `/data` domain carries this function
1766+
* statement for statement (`versionTokenRefusal` in
1767+
* `packages/runtime/src/domains/data.ts`), so the two doors answer every token
1768+
* alike; `data-update-expected-version.test.ts` and
1769+
* `data-delete-expected-version.test.ts` hold them to it.
1770+
*/
1771+
function versionTokenRefusal(token: unknown, spelling: string): string | undefined {
1772+
if (token === undefined || token === null) return undefined;
1773+
if (typeof token === 'string' && token.trim() !== '') return undefined;
1774+
if (typeof token === 'number' && Number.isFinite(token)) return undefined;
1775+
const shape = typeof token === 'string' ? 'empty'
1776+
: Array.isArray(token) ? 'an array'
1777+
: typeof token === 'object' ? 'an object'
1778+
: `the ${typeof token} \`${String(token)}\``;
1779+
return `${spelling} is ${shape}, which can never carry a version, so it is refused rather than read as `
1780+
+ 'no token. Send the version you read (the record\'s `updated_at`), or omit both expectedVersion and '
1781+
+ 'If-Match to perform an unguarded write.';
1782+
}
1783+
17361784
/**
17371785
* [#20338] May this caller read PENDING metadata — a `sys_metadata` row in
17381786
* `state: 'draft'`, unpublished authoring work? THE question every door that
@@ -9067,6 +9115,23 @@ export class RestServer {
90679115
const { expectedVersion: _drop, ...rest } = data as any;
90689116
data = rest;
90699117
}
9118+
// [#23492] A token that can never carry a version is
9119+
// refused here, before anything is asked, and every
9120+
// other present token is forwarded below: no token the
9121+
// caller sent is read as "no token".
9122+
const versionRefusal = versionTokenRefusal(
9123+
expectedVersion,
9124+
bodyVersion != null ? 'The body\'s `expectedVersion`' : 'The `If-Match` header',
9125+
);
9126+
if (versionRefusal) {
9127+
res.status(400).json({
9128+
error: versionRefusal,
9129+
code: 'VALIDATION_FAILED',
9130+
fields: [],
9131+
object: req.params?.object,
9132+
});
9133+
return;
9134+
}
90709135
if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return;
90719136
// [#3899] Same gate as create: the wire body is the bare
90729137
// field patch (`expectedVersion` already stripped above),
@@ -9078,7 +9143,7 @@ export class RestServer {
90789143
object: req.params.object,
90799144
id: req.params.id,
90809145
data: data ?? {},
9081-
...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}),
9146+
...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}),
90829147
};
90839148
const parsedUpdateOne = (UpdateDataRequestSchema as any).safeParse(updateInput);
90849149
if (!parsedUpdateOne.success) {
@@ -9094,7 +9159,7 @@ export class RestServer {
90949159
object: req.params.object,
90959160
id: req.params.id,
90969161
data: data ?? {},
9097-
...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}),
9162+
...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}),
90989163
...(environmentId ? { environmentId } : {}),
90999164
...(context ? { context } : {}),
91009165
};
@@ -9142,11 +9207,27 @@ export class RestServer {
91429207
? (req.query as any).expectedVersion
91439208
: undefined;
91449209
const expectedVersion = queryVersion ?? ifMatchHeader;
9210+
// [#23492] The `PATCH` above's rule: a token that can
9211+
// never carry a version is refused, every other present
9212+
// token is forwarded.
9213+
const versionRefusal = versionTokenRefusal(
9214+
expectedVersion,
9215+
queryVersion != null ? 'The `expectedVersion` query parameter' : 'The `If-Match` header',
9216+
);
9217+
if (versionRefusal) {
9218+
res.status(400).json({
9219+
error: versionRefusal,
9220+
code: 'VALIDATION_FAILED',
9221+
fields: [],
9222+
object: req.params?.object,
9223+
});
9224+
return;
9225+
}
91459226
if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return;
91469227
const deleteRequest: ServerScopedDataRequest<DeleteDataRequest> = {
91479228
object: req.params.object,
91489229
id: req.params.id,
9149-
...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}),
9230+
...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}),
91509231
...(environmentId ? { environmentId } : {}),
91519232
...(context ? { context } : {}),
91529233
};

‎packages/runtime/src/domains/data.ts‎

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.
3434
// [#23265] …and the repo's one sentence for a repeated single-valued
3535
// parameter, which the delete's `?expectedVersion` answers.
3636
import { assertFilterParamSuppliedOnce, repeatedQueryParamMessage } from '@objectstack/rest';
37+
// [#23492] The dispatcher's one constructor for a `400 VALIDATION_FAILED`
38+
// answer, which a refused version token returns in this transport's envelope.
39+
import { validationFailure, VALIDATION_FAILED_STATUS } from '../validation-failure.js';
3740

3841
/**
3942
* [#23265] The request's `If-Match`, from either header shape a host hands
@@ -50,6 +53,37 @@ function requestIfMatch(headers: unknown): unknown {
5053
return h['if-match'] ?? h['If-Match'];
5154
}
5255

56+
/**
57+
* [#23492] What this domain's `PATCH` and `DELETE` do with the
58+
* optimistic-concurrency token they read, before `callData` is asked: absent
59+
* (`undefined` or `null`, which falls through to `If-Match`), judged (a string
60+
* with something in it, or a finite number, forwarded as its string), or
61+
* refused `400 VALIDATION_FAILED` (an empty or blank string, a boolean, an
62+
* array, an object: a token shape that can never carry a version, maintainer
63+
* decision #20 ①). It replaces a truthiness test that let `expectedVersion: ''`
64+
* or `false`, and `?expectedVersion=`, hide a stale `If-Match` and run the
65+
* write unguarded.
66+
*
67+
* `RestServer`'s twin routes ask the same function, statement for statement
68+
* (`versionTokenRefusal` in `packages/rest/src/rest-server.ts`, whose docblock
69+
* carries the full argument), so the two doors answer every token alike;
70+
* `data-update-expected-version.test.ts` and
71+
* `data-delete-expected-version.test.ts` hold them to it. Returns the refusal's
72+
* sentence, or `undefined` when the token is absent or judged.
73+
*/
74+
function versionTokenRefusal(token: unknown, spelling: string): string | undefined {
75+
if (token === undefined || token === null) return undefined;
76+
if (typeof token === 'string' && token.trim() !== '') return undefined;
77+
if (typeof token === 'number' && Number.isFinite(token)) return undefined;
78+
const shape = typeof token === 'string' ? 'empty'
79+
: Array.isArray(token) ? 'an array'
80+
: typeof token === 'object' ? 'an object'
81+
: `the ${typeof token} \`${String(token)}\``;
82+
return `${spelling} is ${shape}, which can never carry a version, so it is refused rather than read as `
83+
+ 'no token. Send the version you read (the record\'s `updated_at`), or omit both expectedVersion and '
84+
+ 'If-Match to perform an unguarded write.';
85+
}
86+
5387
export function createDataDomain(deps: DomainHandlerDeps): DomainRoute {
5488
return {
5589
prefix: '/data',
@@ -170,22 +204,39 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m
170204
// whole body over as the patch, so through the `@objectstack/hono`
171205
// catch-all a stale `If-Match` update was APPLIED, and a body
172206
// `expectedVersion` reached the engine as a field it refused as
173-
// unknown. The key is stripped from the patch whatever its value,
174-
// and a token is forwarded only when truthy: the twin's own
175-
// statements, so both doors send `updateData` the same thing.
207+
// unknown. The key is stripped from the patch whatever its value:
208+
// the twin's own statements, so both doors send `updateData` the
209+
// same thing.
210+
//
211+
// [#23492] …and every token the caller sent is judged or refused:
212+
// one that can never carry a version is refused `400` before
213+
// `callData` is asked (`versionTokenRefusal`), and every other
214+
// present token is forwarded. A truthiness test used to decide
215+
// the forward, so `expectedVersion: ''` or `false` hid a stale
216+
// `If-Match` and the update ran unguarded, on both doors.
176217
const bodyVersion = (body && typeof body === 'object') ? body.expectedVersion : undefined;
177218
const expectedVersion = bodyVersion ?? requestIfMatch(_context.request?.headers);
178219
let data = body;
179220
if (body && typeof body === 'object' && 'expectedVersion' in body) {
180221
const { expectedVersion: _token, ...patch } = body;
181222
data = patch;
182223
}
224+
const versionRefusal = versionTokenRefusal(
225+
expectedVersion,
226+
bodyVersion != null ? 'The body\'s `expectedVersion`' : 'The `If-Match` header',
227+
);
228+
if (versionRefusal) {
229+
return {
230+
handled: true,
231+
response: deps.errorFromThrown(validationFailure(versionRefusal, []), VALIDATION_FAILED_STATUS),
232+
};
233+
}
183234
// Spec: returns UpdateDataResponse = { object, id, record }
184235
const result = await actionExec.callData(deps, _context, 'update', {
185236
object: objectName,
186237
id,
187238
data,
188-
...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}),
239+
...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}),
189240
}, _context.dataDriver, _context.environmentId, _context.executionContext);
190241
return { handled: true, response: deps.success(result) };
191242
}
@@ -210,8 +261,13 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m
210261
// request that sent no token. A repeated `?expectedVersion` is
211262
// refused first, as the twin's arity gate refuses it, before the
212263
// service is asked; a one-element array is one occurrence and an
213-
// empty one is none. A token is forwarded only when non-empty, the
214-
// twin's own test, so both doors send `deleteData` the same thing.
264+
// empty one is none.
265+
//
266+
// [#23492] The `PATCH` above's rule, the twin's own: a token that
267+
// can never carry a version is refused `400` before `callData` is
268+
// asked, and every other present token is forwarded. A truthiness
269+
// test used to decide the forward, so `?expectedVersion=` hid a
270+
// stale `If-Match` and the delete ran unguarded, on both doors.
215271
const rawVersion = query?.expectedVersion;
216272
if (Array.isArray(rawVersion) && rawVersion.length > 1) {
217273
return {
@@ -221,10 +277,20 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m
221277
}
222278
const queryVersion = Array.isArray(rawVersion) ? rawVersion[0] : rawVersion;
223279
const expectedVersion = queryVersion ?? requestIfMatch(_context.request?.headers);
280+
const versionRefusal = versionTokenRefusal(
281+
expectedVersion,
282+
queryVersion != null ? 'The `expectedVersion` query parameter' : 'The `If-Match` header',
283+
);
284+
if (versionRefusal) {
285+
return {
286+
handled: true,
287+
response: deps.errorFromThrown(validationFailure(versionRefusal, []), VALIDATION_FAILED_STATUS),
288+
};
289+
}
224290
const result = await actionExec.callData(deps, _context, 'delete', {
225291
object: objectName,
226292
id,
227-
...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}),
293+
...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}),
228294
}, _context.dataDriver, _context.environmentId, _context.executionContext);
229295
return { handled: true, response: deps.success(result) };
230296
}

0 commit comments

Comments
 (0)