Skip to content

Commit bba4677

Browse files
committed
fix(rest): read the PUT door's mode after its multiplicity guard; re-ledger the moved draft switch
refuseRepeatedQueryParams unwraps one ?mode occurrence encoded as an array, so the shared mapping must read the query after it, as the inline read did. The census of rest-server.ts draft switches loses its PUT row because that switch is now read in meta-save-request.ts. The parity test's unhonourable header list is typed so tsc accepts it. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9588411 commit bba4677

4 files changed

Lines changed: 26 additions & 9 deletions

File tree

‎packages/rest/src/meta-draft-read-door-census.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
* Spellings it cannot see — a switch read under another name, a draft asked
3636
* for by a spread of the raw query — are review questions, not rows; the
3737
* behaviour pins are what hold the doors it names.
38+
*
39+
* [#22141] `PUT /meta/:type/:name?mode=draft` — a `write` switch — is no row
40+
* here because it is no longer read in this file: the door asks
41+
* `metaSaveRequestOptions` (`meta-save-request.ts`), the one mapping the
42+
* runtime dispatcher's `/meta` door asks too, after its write-capability gate.
43+
* Its behaviour is held by `meta-compound-save-mode-parity.test.ts` and by both
44+
* doors' rows in `packages/runtime/src/domains/meta-save-preconditions-parity.test.ts`.
3845
*/
3946

4047
import { describe, it, expect } from 'vitest';
@@ -160,11 +167,6 @@ const LEDGER: readonly LedgerRow[] = [
160167
disposition: 'read',
161168
door: 'GET /meta/:type/:name?preview=draft — the draft overlaid on the active item',
162169
},
163-
{
164-
site: "PUT ${metaPath}/:type/:name » req.query.mode.toLowerCase() === 'draft'",
165-
disposition: 'write',
166-
door: 'PUT /meta/:type/:name?mode=draft — SAVES a draft; admitted by the write-capability gate first',
167-
},
168170
{
169171
site: "DELETE ${metaPath}/:type/:name » req.query.state.toLowerCase() === 'draft'",
170172
disposition: 'write',

‎packages/rest/src/meta-save-request.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@
4949
* `?mode=draft` (any case) is `mode: 'draft'`: the save stages a draft row and
5050
* leaves the active row alone. Any other value is an active save, as it has
5151
* always been on `RestServer`.
52+
*
53+
* `query.mode` is read as the string a door has already judged for
54+
* multiplicity. `RestServer` calls this AFTER `refuseRepeatedQueryParams`,
55+
* which refuses a repeated `mode` and unwraps one occurrence encoded as an
56+
* array; the `@objectstack/hono` catch-all flattens its query from the URL, one
57+
* string per name. ⛔ So call it after your own multiplicity gate, never
58+
* before: an array reaching here is not read as a draft.
5259
*/
5360

5461
import type { SaveMetaItemRequest } from '@objectstack/spec/api';

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7094,6 +7094,13 @@ export class RestServer {
70947094
// `@objectstack/hono` catch-all cannot answer this request
70957095
// differently. Its `request` members are spread into the
70967096
// save below, each present only when the caller asked.
7097+
//
7098+
// The multiplicity guard runs FIRST: it refuses a repeated
7099+
// `force`, `package` or `mode` (see the `force` read below
7100+
// for why that one is sharp) and UNWRAPS one occurrence
7101+
// encoded as an array, so `?mode=` reaches the mapping as
7102+
// the string it reads.
7103+
if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return;
70977104
const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query });
70987105
if (!saveOptions.ok) {
70997106
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: saveOptions.message } });
@@ -7112,8 +7119,8 @@ export class RestServer {
71127119
// string, and a non-empty array is truthy — so
71137120
// `?force=false&force=false`, a caller repeating an explicit
71147121
// OPT-OUT, turned the destructive-change guard ON. An
7115-
// inversion, on a destructive verb, reported as 200.
7116-
if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return;
7122+
// inversion, on a destructive verb, reported as 200. The
7123+
// multiplicity guard above refuses it before this read.
71177124
const forceRaw = req.query?.force;
71187125
const force = typeof forceRaw === 'string'
71197126
? ['true', '1', 'yes', 'on'].includes(forceRaw.toLowerCase())

‎packages/runtime/src/domains/meta-save-preconditions-parity.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,11 @@ for (const [doorName, boot] of DOORS) {
273273

274274
it('a pin that can never be honoured is refused 400 VALIDATION_ERROR and writes nothing', async () => {
275275
const door = await boot();
276-
for (const headers of [
276+
const unhonourable: Array<Record<string, string>> = [
277277
{ 'If-Match': 'any-token', 'If-None-Match': '*' },
278278
{ 'If-None-Match': 'W/"an-entity-tag"' },
279-
]) {
279+
];
280+
for (const headers of unhonourable) {
280281
const refused = await door.save('case_grid', 'never', { headers });
281282
expect({ status: refused.status, code: refused.code }, JSON.stringify(headers)).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
282283
}

0 commit comments

Comments
 (0)