Skip to content

Commit be968a2

Browse files
committed
fix(metadata-protocol): refuse the quoted-empty If-Match entity-tag at ingress (#13576)
WIP checkpoint before gates/ablation — reject `expectedVersion`/`If-Match: ""` with 400 VALIDATION_FAILED instead of silently skipping the OCC guard.
1 parent 16c3601 commit be968a2

4 files changed

Lines changed: 461 additions & 16 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol): refuse the quoted-empty `If-Match` entity-tag instead of silently disabling optimistic concurrency (#13576)
6+
7+
**BREAKING** accept-set narrowing at the guarded-write door, shipped as
8+
`minor` under the repo's launch-window convention for breaking changes.
9+
10+
`If-Match: ""` — a syntactically legal RFC-7232 entity-tag with an EMPTY
11+
opaque value — was silently accepted as "no version token supplied", which
12+
**skipped the optimistic-concurrency guard entirely** on both `PATCH
13+
/data/:object/:id` (via `If-Match` or the body's `expectedVersion` field) and
14+
`DELETE /data/:object/:id` (via `If-Match` or the query's `expectedVersion`).
15+
`normaliseVersionToken` strips the RFC-7232 quotes off the token and only
16+
*then* checks emptiness, so `'""'` (2 chars, non-empty) passed every upstream
17+
truthiness gate only to normalise to `''` one layer down — the exact falsy
18+
value every caller's own `if (!token) return` reads as "the client sent
19+
nothing". It was the one token shape that opted OUT of the guard instead of
20+
failing it: a garbage-but-nonempty token (`v2`) has always failed *toward*
21+
`409 CONCURRENT_UPDATE`, the safe direction for a concurrency primitive —
22+
`""` failed toward silent, unguarded acceptance instead.
23+
24+
**What changes.** Both doors now refuse `expectedVersion`/`If-Match: ""` at
25+
ingress with `400 VALIDATION_FAILED`:
26+
27+
> expectedVersion (If-Match) is the empty entity-tag `""`. An empty version
28+
> token can never match any stored version, so this is almost certainly a
29+
> client defect rather than a real concurrency check — send the real version
30+
> token you read (e.g. the record's `updated_at`), or omit If-Match /
31+
> expectedVersion entirely to perform an unguarded write.
32+
33+
**What does NOT change** (both explicitly pinned as regression controls):
34+
omitting `If-Match`/`expectedVersion` entirely is still a legal **unguarded**
35+
write (opt-in semantics, unaffected) — including a bare unquoted empty string
36+
or whitespace-only value, which is not the malformed shape and stays
37+
opted-out; and a garbage-but-nonempty token (`v2`) still fails toward `409
38+
CONCURRENT_UPDATE`, unchanged.
39+
40+
**Why 400 rather than 409** (a fail-closed alternative was considered and
41+
rejected — maintainer ruling, 決裁批 #20 ①, 2026-08-31): a 409 would still
42+
have collapsed two different facts into one answer — "you lost a race"
43+
(retry-actionable) and "you sent a token that can never carry a version"
44+
(a client-side bug, not a race). 400 keeps the two legible, which is the
45+
entire point of refusing the *shape* rather than failing the comparison.
46+
`""` is syntactically legal per RFC 7232 §2.3 (`*etagc` — zero or more —
47+
permits an empty opaque-tag); this refusal is a deliberate platform CONTRACT
48+
choice ("an empty tag can never match ⇒ it is necessarily a client defect"),
49+
not a syntax verdict.
50+
51+
**Who this affects.** Measured: the first-party Console never sends this
52+
shape — `occVersionOf` (`plugin-form/src/occSave.tsx`) and its
53+
`InlineEditSaveBar` counterpart in `objectui` only forward a **truthy**
54+
`updated_at` string as `ifMatch`, and the `@object-ui/data-objectstack`
55+
adapter only sets the `If-Match` header when `options.ifMatch` is itself
56+
truthy — an empty value never reaches the wire on any first-party path. The
57+
exposure was to third-party and hand-rolled clients sending the RFC-7232
58+
empty-tag shape, which previously got an unguarded write where they asked for
59+
a guarded one.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13576] `If-Match: ""` — a valid RFC-7232 entity-tag with an EMPTY opaque
5+
* value — is refused `400 VALIDATION_FAILED` at ingress, instead of being
6+
* read as "no token supplied" and silently skipping the OCC guard.
7+
*
8+
* ## The defect this closes
9+
*
10+
* `normaliseVersionToken` strips RFC-7232 quotes off an `If-Match` value and
11+
* THEN checks emptiness — so `'""'` (non-empty, 2 chars) passes every
12+
* upstream truthiness check (the REST layer's `expectedVersion ? … : …`)
13+
* only to normalise to the empty string `''` one layer down, which every
14+
* caller's OWN falsiness test (`if (!expected) return`) reads as "the client
15+
* sent no version" — the OPPOSITE of what `If-Match` requests. It is the one
16+
* token shape that opts OUT of the guard rather than failing it: a garbage
17+
* token (`v2`) still normalises to a real, comparable token and fails toward
18+
* `409 CONCURRENT_UPDATE` — the safe direction for a concurrency primitive.
19+
*
20+
* ## The ruling (决裁批 #20 ①, maintainer, 2026-08-31) — option 3, verbatim
21+
*
22+
* `If-Match: ""` (header) and `expectedVersion: '""'` (body) are judged a
23+
* MALFORMED concurrency token AT INGRESS and refused (400-family); the
24+
* message must name the mechanism ("an empty token can never match any
25+
* stored version — this is a client defect, not a lost race"), because that
26+
* diagnostic distinction is the entire reason option 3 (refuse the shape) was
27+
* chosen over option 2 (fail closed to 409, which would have collapsed "you
28+
* sent something meaningless" into "you lost a race"). `""` is syntactically
29+
* LEGAL per RFC-7232 §2.3 (`*etagc` — zero or more — permits an empty opaque
30+
* tag); this refusal is an explicit platform CONTRACT choice ("an empty tag
31+
* can never match ⇒ it is necessarily a client defect"), not a syntax
32+
* verdict. Two things stay explicitly UNCHANGED: no `If-Match` at all is
33+
* still a legal unguarded write, and a garbage-but-nonempty token (`v2`)
34+
* still fails toward 409.
35+
*
36+
* ## The four-way pin set (Zone 3 of the dispatch order) — one describe each
37+
*
38+
* Each catches a DIFFERENT way a naive patch could overreach or underreach:
39+
* 1. `""` → 400. (the fix itself)
40+
* 2. no `If-Match` at all → unguarded write still succeeds.
41+
* (catches a change that rejects EVERY falsy token, not just `""`)
42+
* 3. `v2` (garbage, nonempty) → 409 still.
43+
* (catches a change that also swallows the legitimate-conflict path)
44+
* 4. a real, matching token → guarded write still succeeds.
45+
* (catches a change that rejects even a well-formed token)
46+
*
47+
* ## A2.2 — TWO ingress doors, not one (falsified my own assumption)
48+
*
49+
* `assertVersionOf` (the PATCH door, called from `updateData` after the
50+
* existence probe) and `assertVersionMatch` (the DELETE door, called from
51+
* `deleteData` BEFORE any probe — and which short-circuits BEFORE ever
52+
* calling `assertVersionOf`) each read `normaliseVersionToken`'s falsy return
53+
* independently. A fix at one site alone leaves the other exhibiting the
54+
* exact original defect — the DELETE-door describe block below regresses
55+
* independently of the PATCH-door one for exactly this reason.
56+
*/
57+
58+
import { describe, it, expect, vi } from 'vitest';
59+
import {
60+
assertEngineDeleteDispatch,
61+
assertEngineFindOnePredicate,
62+
assertEngineUpdateDispatch,
63+
} from '@objectstack/metadata-core';
64+
import { ObjectStackProtocolImplementation, MalformedVersionTokenError } from './protocol.js';
65+
66+
const SCHEMA = {
67+
name: 'task',
68+
fields: {
69+
title: { name: 'title', type: 'text' },
70+
updated_at: { name: 'updated_at', type: 'datetime' },
71+
},
72+
};
73+
74+
/** A fake engine holding ONE row — mirrors `protocol.occ-version-token-instant.test.ts`'s fixture. */
75+
function makeProtocol(updatedAt: unknown) {
76+
const row: Record<string, unknown> = { id: 'rec_1', title: 'one', updated_at: updatedAt };
77+
const findOne = vi.fn(async (_object: string, opts: any) => {
78+
assertEngineFindOnePredicate(_object, opts);
79+
return String(opts?.where?.id) === 'rec_1' ? { ...row } : null;
80+
});
81+
const update = vi.fn(async (_object: string, data: any, opts?: any) => {
82+
const dispatch = assertEngineUpdateDispatch(data, opts);
83+
if (dispatch.kind !== 'by-id') {
84+
throw new Error(`fixture drives by-id updates only, got '${dispatch.kind}'`);
85+
}
86+
const fields = { ...(data as Record<string, unknown>) };
87+
delete fields.id;
88+
Object.assign(row, fields);
89+
return { ...row };
90+
});
91+
const del = vi.fn(async (_object: string, opts?: any) => {
92+
assertEngineDeleteDispatch(opts);
93+
return String(opts?.where?.id) === 'rec_1';
94+
});
95+
const engine = {
96+
registry: { getObject: (n: string) => (n === 'task' ? SCHEMA : undefined) },
97+
findOne,
98+
update,
99+
delete: del,
100+
};
101+
return { p: new ObjectStackProtocolImplementation(engine as any) as any, findOne, update, del };
102+
}
103+
104+
const NOW = new Date('2026-08-30T10:19:25.947Z');
105+
const NOW_ISO = NOW.toISOString();
106+
107+
// ─────────────────────────────────────────────────────────────────────────────
108+
// Pin 1 — `""` ⇒ 400, on BOTH doors, and the exact shipped message
109+
// ─────────────────────────────────────────────────────────────────────────────
110+
111+
describe('[#13576] pin 1 — the quoted-empty entity-tag is refused 400', () => {
112+
it('PATCH: `expectedVersion: \'""\'` throws MalformedVersionTokenError (400 VALIDATION_FAILED), and writes nothing', async () => {
113+
const { p, update } = makeProtocol(NOW);
114+
await expect(
115+
p.updateData({ object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: '""' }),
116+
).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400, name: 'MalformedVersionTokenError' });
117+
expect(update).not.toHaveBeenCalled();
118+
});
119+
120+
it('DELETE: the same shape throws before the probe, and deletes nothing', async () => {
121+
const { p, del, findOne } = makeProtocol(NOW);
122+
await expect(
123+
p.deleteData({ object: 'task', id: 'rec_1', expectedVersion: '""' }),
124+
).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400 });
125+
expect(del).not.toHaveBeenCalled();
126+
expect(findOne).not.toHaveBeenCalled();
127+
});
128+
129+
it('the shipped error text names the MECHANISM — "cannot match" / "client defect" — not just a generic validation failure', async () => {
130+
// Ruling clause ①: 文案不达意即白改 — the diagnostic value (this is a
131+
// meaningless token, not a lost race) IS the reason option 3 beat
132+
// option 2, so the message is asserted verbatim-in-substance, not just
133+
// the code/status envelope.
134+
const { p } = makeProtocol(NOW);
135+
try {
136+
await p.updateData({ object: 'task', id: 'rec_1', data: {}, expectedVersion: '""' });
137+
expect.unreachable('expected a MalformedVersionTokenError throw');
138+
} catch (e: any) {
139+
expect(e).toBeInstanceOf(MalformedVersionTokenError);
140+
expect(e.message).toMatch(/empty/i);
141+
expect(e.message).toMatch(/never match/i);
142+
expect(e.message).toMatch(/client defect/i);
143+
// Tells the caller what to do instead — both remedies the ruling names.
144+
expect(e.message).toMatch(/updated_at/);
145+
expect(e.message).toMatch(/If-Match/);
146+
}
147+
});
148+
149+
it('surrounding whitespace around the quoted-empty tag is still caught (REST forwards the trimmed header verbatim)', async () => {
150+
const { p } = makeProtocol(NOW);
151+
await expect(
152+
p.updateData({ object: 'task', id: 'rec_1', data: {}, expectedVersion: ' "" ' }),
153+
).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400 });
154+
});
155+
});
156+
157+
// ─────────────────────────────────────────────────────────────────────────────
158+
// Pin 2 — no `If-Match` at all ⇒ unguarded write still succeeds (Zone 1.2)
159+
// ─────────────────────────────────────────────────────────────────────────────
160+
161+
describe('[#13576] pin 2 — the legitimate no-token path is UNCHANGED', () => {
162+
it('PATCH with no `expectedVersion` field at all still writes unguarded', async () => {
163+
const { p, update } = makeProtocol(NOW);
164+
const result = await p.updateData({ object: 'task', id: 'rec_1', data: { title: 'edited' } });
165+
expect(result.record.title).toBe('edited');
166+
expect(update).toHaveBeenCalledOnce();
167+
});
168+
169+
it('DELETE with no `expectedVersion` field at all still deletes unguarded', async () => {
170+
const { p, del } = makeProtocol(NOW);
171+
await p.deleteData({ object: 'task', id: 'rec_1' });
172+
expect(del).toHaveBeenCalledOnce();
173+
});
174+
175+
it('an unquoted empty string / whitespace-only token is NOT the malformed shape — still opts out (distinct from `\'""\'`)', async () => {
176+
// These are what the REST layer's own `expectedVersion ? {...} : {}`
177+
// truthiness gate already filters before a bare '' ever reaches this
178+
// layer for the external HTTP doors — pinned here anyway because
179+
// `updateData`/`deleteData` are also reachable directly (import-runner,
180+
// action-execution), where no such gate runs.
181+
const { p: p1, update } = makeProtocol(NOW);
182+
await p1.updateData({ object: 'task', id: 'rec_1', data: { title: 'a' }, expectedVersion: '' });
183+
expect(update).toHaveBeenCalledOnce();
184+
const { p: p2, update: update2 } = makeProtocol(NOW);
185+
await p2.updateData({ object: 'task', id: 'rec_1', data: { title: 'b' }, expectedVersion: ' ' });
186+
expect(update2).toHaveBeenCalledOnce();
187+
});
188+
});
189+
190+
// ─────────────────────────────────────────────────────────────────────────────
191+
// Pin 3 — a garbage-but-nonempty token still fails toward 409 (Zone 1.2)
192+
// ─────────────────────────────────────────────────────────────────────────────
193+
194+
describe('[#13576] pin 3 — an opaque non-matching token is UNCHANGED — still 409, never 400', () => {
195+
it('PATCH: `expectedVersion: \'v2\'` against a real stored version still throws ConcurrentUpdateError (409)', async () => {
196+
const { p, update } = makeProtocol(NOW);
197+
await expect(
198+
p.updateData({ object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: 'v2' }),
199+
).rejects.toMatchObject({ code: 'CONCURRENT_UPDATE', status: 409 });
200+
expect(update).not.toHaveBeenCalled();
201+
});
202+
203+
it('DELETE: the same garbage token still throws ConcurrentUpdateError (409), not the new 400', async () => {
204+
const { p, del } = makeProtocol(NOW);
205+
await expect(
206+
p.deleteData({ object: 'task', id: 'rec_1', expectedVersion: 'v2' }),
207+
).rejects.toMatchObject({ code: 'CONCURRENT_UPDATE', status: 409 });
208+
expect(del).not.toHaveBeenCalled();
209+
});
210+
});
211+
212+
// ─────────────────────────────────────────────────────────────────────────────
213+
// Pin 4 — a real, matching token still guards the write through, successfully
214+
// ─────────────────────────────────────────────────────────────────────────────
215+
216+
describe('[#13576] pin 4 — a real matching token still performs the guarded write', () => {
217+
it('PATCH succeeds when `expectedVersion` matches the stored `updated_at`', async () => {
218+
const { p, update } = makeProtocol(NOW);
219+
const result = await p.updateData({
220+
object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: NOW_ISO,
221+
});
222+
expect(result.record.title).toBe('edited');
223+
expect(update).toHaveBeenCalledOnce();
224+
});
225+
226+
it('DELETE succeeds when `expectedVersion` matches the stored `updated_at`', async () => {
227+
const { p, del } = makeProtocol(NOW);
228+
await p.deleteData({ object: 'task', id: 'rec_1', expectedVersion: NOW_ISO });
229+
expect(del).toHaveBeenCalledOnce();
230+
});
231+
232+
it('the RFC-7232-quoted form of the SAME real token still matches (quotes stripped, not the emptiness path)', async () => {
233+
const { p, update } = makeProtocol(NOW);
234+
const result = await p.updateData({
235+
object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: `"${NOW_ISO}"`,
236+
});
237+
expect(result.record.title).toBe('edited');
238+
expect(update).toHaveBeenCalledOnce();
239+
});
240+
});

0 commit comments

Comments
 (0)