@@ -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.
3636import { 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+
5387export 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