Skip to content

Commit 3aa0c7b

Browse files
fix(runtime): the @objectstack/hono catch-all's PUT /meta honours If-Match, If-None-Match and ?mode=draft (#22206)
Fixes #22141 Clause-②: yes ## What this changes Behind `@objectstack/hono`'s `${prefix}/*` catch-all, `PUT /meta/:type/:name` is answered by the runtime dispatcher's `/meta` domain (`handleMetadataRequest`). That branch handed `saveMetaItem` no `parentVersion` and no `mode`. So through the catch-all a stale `If-Match` wrote (200, not 409), `If-None-Match: *` over an existing row wrote, and a `?mode=draft` save landed on the ACTIVE row. A draft went live without a publish, and the client read a 200. - **One mapping, two doors.** `RestServer`'s private precondition parser moves to `@objectstack/rest` as `metaSaveRequestOptions` (`packages/rest/src/meta-save-request.ts`). It now also reads `?mode=draft`. `RestServer`'s `PUT` door and the dispatcher's `PUT` branch both call it, and spread its `request` (`parentVersion` and `mode`, each present only when asked) into `saveMetaItem`. It reads a Fetch `Headers` (`get`) or a plain header record, so it takes the raw `Request` the catch-all hands `dispatch()` and the record the `plugin-hono-server` adapter hands `RestServer`. - **Dispatcher refusals.** A pin that can never be honoured (both headers, or an `If-None-Match` other than `*`) is `400 VALIDATION_ERROR`, after the capability gate and before anything is written, as on `RestServer`. The dispatcher alone has a fallback writer (`metadata.saveItem(type, name, item)`, used when the protocol has no `saveMetaItem`). It cannot carry a pin or a lifecycle, so a save that asks for one is refused `501 NOT_IMPLEMENTED` there instead of written unguarded or active. - **`RestServer` answers are unchanged.** One ordering moved: its multiplicity guard (`refuseRepeatedQueryParams` for `force` / `package` / `mode`) now runs just before the shared read, because the guard unwraps a single-element `?mode` array in place and the mapping must see the unwrapped string. A request that carries both a malformed pin and a repeated parameter now gets the repeated-parameter `400` first. Both are `400 VALIDATION_ERROR`. - **Widened public surface (`Clause-②: yes`).** `@objectstack/rest`'s package entry now exports `metaSaveRequestOptions` and the types `MetaSaveRequestHttp`, `MetaSaveRequestMembers` and `MetaSaveRequestOptions`. That widens its public surface, so its changeset is `minor` with `Clause-②: yes (widening)`, the shape the read-side twin of this parity declared when it published `createMetaItemReadGate`. `@objectstack/runtime`'s public surface does not change: its changeset stays `patch` with `Clause-②: no`. - `@objectstack/hono` itself is unchanged: the catch-all already hands `dispatch()` `{ request: c.req.raw }`, so the headers were on `context.request` all along (H3). The fix ships in `@objectstack/runtime` (`patch`) and `@objectstack/rest` (`minor`). ## Readings (on `origin/main` `6ed0c0f3`, this branch's base) - **H1, reach.** In this repository `objectstack serve` / `os dev` compose `createRestApiPlugin` and `createDispatcherPlugin`. The dispatcher plugin mounts no `/meta` route (its explicit mounts are `/notifications*`, `/keys`, `/analytics/*`, `/i18n/*`, `/health`, `/ready`, `/discovery`, plus AI routes), so `/meta` writes there are `RestServer`'s. `packages/adapters/` now holds only `hono`, and `createHonoApp` is called by no app, example or package outside tests. **Whether the hosted runtime's `PUT /meta` reaches this catch-all, and on which cloud pin: NOT MEASURED from this session** (objectstack-ai/cloud is not readable here). The grade is the seat's. - **H2, reproduced on `main`, through the real `createHonoApp`.** A scratch probe (not committed) mounted `createHonoApp({ kernel, prefix: '/api/v1', cors: false })` from this checkout's adapter source over a `LiteKernel` carrying a real `ObjectStackProtocolImplementation` on ObjectQL + better-sqlite3 `:memory:`, with identity stubbed to `manage_metadata`. Requests went through `app.request`. Same probe, `main`'s `meta.ts` then this branch's: | request through the catch-all | `main` | this branch | |:--|:--|:--| | `PUT /api/v1/meta/view/case_grid` (v1, then v2) | 200, 200 | 200, 200 | | `PUT` with `If-Match` = v1's token (stale) | **200, active row now `v3 stale`** | 409 `METADATA_CONFLICT`, active row `v2` | | `PUT` with `If-None-Match: *` over an existing row | **200, active row now `second`** | 409 `METADATA_CONFLICT`, active row `first` | | `PUT ?mode=draft` | **200, receipt `state: 'active'`, active row `staged`, no draft row** | 200, receipt `state: 'draft'`, active row `v2`, draft row `staged` | - **H3, where.** Confirmed: `handleMetadataRequest`'s `PUT` branch read neither headers nor `mode`. The catch-all's `dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix)` already carries the Fetch `Request`, so `context.request.headers.get(...)` reaches them. No adapter change is needed. - **H4, one mapping.** Dependency direction measured: `@objectstack/runtime` depends on `@objectstack/rest` (`dependencies`), and `rest` does not depend on `runtime`. So the shared parser lives in `@objectstack/rest`, beside the `/meta` read chain the runtime already imports from there. No `packages/spec` edit. - **H5, every `/meta` write verb.** Measured on both doors (catch-all rows driven through `dispatch()` with the catch-all's arguments, every header and parameter sent): | write verb | parameter | `RestServer` | catch-all before | catch-all after | |:--|:--|:--|:--|:--| | `PUT /meta/:type/:name` | `If-Match` | → `parentVersion`; stale → 409 `METADATA_CONFLICT` | **dropped**: stale token writes, 200 | as `RestServer` | | | `If-None-Match: *` | → `parentVersion: null`; over a row → 409. Non-`*` or beside `If-Match` → 400 `VALIDATION_ERROR` | **dropped**: writes, 200 | as `RestServer` | | | `?mode=draft` | → `mode: 'draft'` | **dropped**: lands active | as `RestServer` | | `DELETE /meta/:type/:name` (reset) | `If-Match` → `parentVersion`; `?state=draft` → `state`; `If-None-Match` not read | as listed | verb not served: 405 `METHOD_NOT_ALLOWED` (`Allow: GET, HEAD, PUT`), no protocol call | unchanged | | `POST /meta/:type/:name/publish` | none of the three is read | — | route not served: 404 `ROUTE_NOT_FOUND`, no protocol call | unchanged | | `POST /meta/:type/:name/rollback` | none of the three is read | — | 404 `ROUTE_NOT_FOUND`, no protocol call | unchanged | | `POST /meta/_migrate-stored` | none of the three is read | — | served; reads none of the three | unchanged | The three verbs the catch-all does not serve answer loudly and write nothing. The comment on the 405 branch records that mounting a real `DELETE` there "needs its own card". `RestServer`'s rows where a parameter is not read stay as they are (not widened). - **H6, same answer.** Partly holds. Same status, same `code`, same refusal sentence, and the draft receipt says `state: 'draft'` on both doors. Not byte-identical envelopes: each transport answers in its own dialect, and the spec declares `MetadataConflictErrorSchema` as "the REST door's flat ADR-0112 dialect". `RestServer`'s 409 is `{ error: SENTENCE, code: 'METADATA_CONFLICT', currentVersion: TOKEN }`. The catch-all's is `{ success: false, error: { code: 'METADATA_CONFLICT', message: SENTENCE, httpStatus: 409 } }` (measured through the real `createHonoApp`). **`currentVersion` is not carried as data on the catch-all.** That is an open question for the seat (see the report), not decided here. - **H7, neighbour.** `rest-server.ts`'s `?package` reads (the `PUT` door's `packageRaw` / `packageId` lines and the publish door's) are untouched. Edits there are the import, the removed private parser, and the `PUT` door's precondition / guard / spread lines. ## Pins (`packages/runtime/src/domains/meta-save-preconditions-parity.test.ts`) Each door over its own real store (better-sqlite3 `:memory:`, the real `sys_metadata*` objects, a real `ObjectStackProtocolImplementation`). The store is read after every write. The `RestServer` leg calls its registered `PUT` handler. The catch-all leg repeats the catch-all's four statements over a real Fetch `Request` (path below the prefix, JSON body, query flattened from the URL, the raw `Request` as `context.request`) into the real `HttpDispatcher.dispatch()`. It cannot import `createHonoApp`: this package cannot depend on `@objectstack/hono` (that package depends on it), and `packages/adapters/hono`'s suite aliases `@objectstack/runtime` to a stub. The H2 probe above is the real-`createHonoApp` reading. Per door: control (an unguarded save writes the active row, last writer wins); a stale `If-Match` → 409 `METADATA_CONFLICT`, nothing written, and the current token still saves; `If-None-Match: *` writes the first row and over an existing row → 409, nothing written; `?mode=draft` stages a draft and leaves the active row untouched; both unhonourable pins → 400 `VALIDATION_ERROR`, nothing written. Plus one side-by-side row (same status, code and token-masked sentence on both doors), and the dispatcher's fallback writer (three asks → 501 and `saveItem` never called; control: an unguarded save still reaches it). **Reverse verification and ablations** (fix committed first; each leg restored from `HEAD` and proven by blob hash and an empty `git diff HEAD`; the subject resolves by relative source import and `@objectstack/rest` by the runtime vitest alias to source, so no `dist/` leg applies): | leg | result | which rows went red | |:--|:--|:--| | this branch | 15 passed | — | | `main`'s `meta.ts` in the tree | 8 failed, 7 passed | all on the catch-all side (4 case rows, the side-by-side row, 3 fallback rows). `RestServer`'s 5 rows and both controls green. | | ablation A: catch-all reads `headers: undefined` | 6 failed, 9 passed | stale token, `If-None-Match: *`, the 400 row, the side-by-side row, 2 fallback header rows. `RestServer` green; the catch-all's draft row green. | | ablation B: catch-all reads `query: undefined` | 2 failed, 13 passed | the catch-all's draft row and the fallback draft row. `RestServer` green. | Ablations went through `node scripts/ablation-replace.mjs` (anchor hit x1 → x0, blob `e90328952e70` → `18cb795a909f` / `88f9509a61df`, restored to `e90328952e70` == HEAD, `git diff HEAD` empty). ## Verification All at this branch's head `bba46774` (`git rev-parse --short HEAD`), base `6ed0c0f3`. Heavy runs went through `scripts/pm/os-verify-lock.sh`. - **Build.** `pnpm --filter '@objectstack/runtime^...' build` (dependency closure), then `@objectstack/rest` and `@objectstack/runtime` rebuilt at head: exit 0. `dist/` checked to carry `metaSaveRequestOptions`. - **Typecheck.** `pnpm --filter @objectstack/rest typecheck`: exit 0. `pnpm --filter @objectstack/runtime typecheck`: exit 0. The test layer printed `check:test-typecheck: OK — 27 file(s) / 190 error(s) / 68 pinned signature(s) held`, ledger unchanged. The new test file is in that program: it was refused once, for one error, before the fix in `bba46774`. - **Unit tiers.** `pnpm --filter @objectstack/rest exec vitest run --project local`: 261 files passed, 4929 passed, 326 skipped. `pnpm --filter @objectstack/runtime exec vitest run --project local`: 335 files passed, 4736 passed, 19 skipped. The `repo` projects (`test:repo`) were not run locally; they are left to CI. - **Gates.** The order's 64 commands plus the full `pnpm lint`, each exit recorded. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` re-derived at head gives the same 64 (8 paths, 653 changed lines, under the 5000 threshold). `--ran` reconciliation: `64 derived famil(ies) accounted for — 63 run, 1 NOT-MEASURED`, 0 unrun. 63 exit 0, and `pnpm lint` (`eslint . --no-inline-config`, whole repo) exit 0 with no findings. - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: exit 3 `PREREQUISITE NOT MET`.** It reads every package's `dist/`, and 38 packages are unbuilt in this worktree. Declared narrowing in its place: the `require` entries of the two packages whose shipped code changed load at head. `require('packages/rest/dist/index.cjs')` gives 34 exports including `metaSaveRequestOptions`, and `require('packages/runtime/dist/index.cjs')` gives 312 exports. CI runs the full gate. - **Upstream.** `origin/main` is 6 commits past the base (`13aea189`). None of them touches `meta.ts`, `rest-server.ts`, `rest/src/index.ts`, `query-multiplicity.ts`, `http-dispatcher.ts` or `metadata-protocol/src/protocol.ts`, so no merge of `main` was taken. - **Round 1 head `6d5f6a5f`.** Only `.changeset/22141-rest-meta-save-request-options.md` changed (`patch` → `minor`, `Clause-②: no` → `Clause-②: yes (widening)`), so the code verification above stands. `dispatch-gates --commands` for that path derives 20 gates, and all 20 exit 0 at `6d5f6a5f` (`--ran`: 20 run, 0 NOT-MEASURED, 0 UNRUN). The branch union is unchanged (the same 64). Also exit 0: `check:type-check-coverage`, `check:type-check-debt`, and `check-changeset-no-major.mjs --base origin/main --event` over this body, whose level axis reads `Clause-②: yes` and finds `@objectstack/rest: minor`. ## Acceptance notes - **Reproduced, not fixed here (reported to the seat): `PUT ?package=all` through the catch-all binds the row to a package literally named `all`.** The dispatcher reads `query?.package || undefined`, while `RestServer` treats `all` and empty as the env-local overlay. Measured on the real store at this branch's head: catch-all → row `package_id: 'all'`; `RestServer` → `package_id: null`. This is the same write-parity family, but the shared fix would edit `RestServer`'s `?package` read, the region #22128's claim may edit. #22128 is not addressed here. - The catch-all's 409 does not carry `currentVersion` as data (H6). Open question in the report. - `RestServer`'s `PUT` treats any `?mode` value other than `draft` as an active save (no closed value set). Unchanged here: a row where `RestServer` itself does not refuse stays as it is. - The dispatcher's `/meta` domain does not judge query multiplicity. The catch-all flattens its query to one string per name, so a repeated `?mode` there is read as its last value. `meta-save-request.ts`'s header records that a door calls the mapping after its own multiplicity gate. - `meta-draft-read-door-census.test.ts` loses its `PUT ?mode=draft` row: that `write` switch is no longer read in `rest-server.ts`. Its header now says where it went and which tests hold it. Written by session `session_01RWZbGvPFcRKvUqASZtunCU`. Round 1 changed only the `Clause-②` declaration (the `@objectstack/rest` changeset regrade and this body), with no code change. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 11e6fc5 commit 3aa0c7b

8 files changed

Lines changed: 585 additions & 68 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
`metaSaveRequestOptions` is exported: the one mapping from a `PUT /meta/:type/:name` request's `If-Match`, `If-None-Match` and `?mode=draft` to `saveMetaItem`'s `parentVersion` and `mode`
6+
7+
Clause-②: yes (widening)
8+
9+
- `RestServer`'s `PUT /api/v1/meta/:type/:name` door now reads its precondition and its lifecycle through this function. The runtime dispatcher's `/meta` door, the one behind the `@objectstack/hono` catch-all, reads them through it too, so the two doors cannot answer the same save differently. It accepts a `Headers`-like object (`get`) or a plain header record, plus the query. It answers `{ ok: true, request }`, where `request` holds only the members the caller asked for, or `{ ok: false, message }`, the sentence of a `400`.
10+
- Unchanged: `RestServer`'s answers. The token still has ETag quotes stripped, `If-None-Match: *` still pins "no row of this lifecycle", and the two `400` refusals keep their wording. `?mode=draft`, matched case-insensitively, still stages a draft. Any other `mode` value is still an active save.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The runtime dispatcher's `PUT /meta/:type/:name` honours `If-Match`, `If-None-Match: *` and `?mode=draft`, so a host that mounts the `@objectstack/hono` catch-all no longer overwrites a pinned save or publishes a draft save
6+
7+
Clause-②: no
8+
9+
- Behind `createHonoApp`'s `${prefix}/*` catch-all, this door handed `saveMetaItem` no `parentVersion` and no `mode`. A stale `If-Match` wrote (`200`, not `409`), `If-None-Match: *` over an existing row wrote, and a `?mode=draft` save landed on the ACTIVE row with a receipt saying `state: 'active'`, so every draft edit went live without a publish. The door now reads the request through `metaSaveRequestOptions` from `@objectstack/rest`, the same mapping `RestServer`'s `PUT` door reads it through. It answers as that door does: `409 METADATA_CONFLICT` for a stale token or for `*` over an existing row, a draft row that leaves the active row alone, and `400 VALIDATION_ERROR` for `If-Match` sent beside `If-None-Match` or for an `If-None-Match` other than `*`. Each refusal writes nothing.
10+
- A host whose protocol has no `saveMetaItem` falls back to the metadata service's `saveItem(type, name, item)`, which cannot carry a precondition or a lifecycle. A save that asks for one there is now refused `501 NOT_IMPLEMENTED` and is not written.
11+
- Unchanged: a save that sends neither header and no `?mode=draft` writes the active row, last writer wins, as before. The refusal envelope is this transport's own (`{ success: false, error: { code, message, httpStatus } }`).

‎packages/rest/src/index.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,18 @@ export type {
168168
MetaTypeReadRefusal,
169169
MetaTypeWriteRefusal,
170170
} from './meta-item-read-gate.js';
171+
172+
// [#22141] …and the write side of the same parity: the precondition
173+
// (`If-Match` / `If-None-Match: *` → `parentVersion`, with its two `400`
174+
// refusals) and the lifecycle (`?mode=draft` → `mode`) a `PUT /meta/:type/:name`
175+
// asks `saveMetaItem` for. `RestServer` reads them through it, and so does the
176+
// runtime dispatcher's `/meta` domain — the only answer behind the
177+
// `@objectstack/hono` catch-all, which dropped all three until this landed.
178+
// Each door hands in its own headers and query and writes a refusal in its own
179+
// envelope (`meta-save-request.ts`'s header is the authority).
180+
export { metaSaveRequestOptions } from './meta-save-request.js';
181+
export type {
182+
MetaSaveRequestHttp,
183+
MetaSaveRequestMembers,
184+
MetaSaveRequestOptions,
185+
} from './meta-save-request.js';

‎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',
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22141] What a `PUT /meta/:type/:name` request asks of `saveMetaItem`
5+
* beyond the item itself — the ADR-0008 precondition and the ADR-0033
6+
* lifecycle — read ONCE, here, for both doors that serve that route:
7+
* `RestServer`'s `PUT` and the runtime dispatcher's `/meta` domain, which is
8+
* the only answer on a host that mounts just `@objectstack/hono`'s
9+
* `${prefix}/*` catch-all.
10+
*
11+
* The dispatcher's door read neither. It handed `saveMetaItem` no
12+
* `parentVersion` and no `mode`, so through the catch-all a stale `If-Match`
13+
* wrote (`200`, not `409`), `If-None-Match: *` over an existing row wrote, and
14+
* a `?mode=draft` save landed ACTIVE — a draft went live without a publish,
15+
* past ADR-0033's gate, with a `200` the client read as success. Both doors now
16+
* call this one mapping, so they cannot answer the same request differently.
17+
*
18+
* What travels is the decision and nothing transport-shaped: each door hands
19+
* in its own request members ({@link MetaSaveRequestHttp}), spreads
20+
* `request` into its `saveMetaItem` literal, and writes a refusal on its own
21+
* wire, in its own envelope — the split `metaRequestLocale` makes. Every key
22+
* of `request` is ABSENT unless the caller asked for it, so an unguarded,
23+
* active save reaches `saveMetaItem` exactly as before.
24+
*
25+
* ## The precondition (moved here unchanged from `RestServer`, #22114)
26+
*
27+
* `parentVersion` is the `If-Match` token (ETag-style quotes stripped), `null`
28+
* for `If-None-Match: *` ("no row of this lifecycle is here", the first-write
29+
* pin the protocol has always declared), or absent for neither (unpinned,
30+
* last-write-wins).
31+
*
32+
* `If-None-Match` is read with a CLOSED value set, `*` alone (AGENTS.md
33+
* 〈Route & surface ownership〉 rule 5's reason, one carrier over): a header
34+
* read for the values it knows and dropped otherwise would write a caller
35+
* unguarded who asked for a guard. Measured before it landed: no first-party
36+
* client sends `If-None-Match` on a `PUT` (the SDK sends it only on its cached
37+
* `GET`, objectui's ETag hook has no caller). Two refusals, both `400`:
38+
*
39+
* - a value other than `*` — an entity-tag list asks "write unless the head is
40+
* one of these", a condition no `/meta` client has and neither door
41+
* evaluates;
42+
* - `If-None-Match` beside `If-Match` — the pair can never hold (RFC 9110
43+
* §13.2.2 evaluates both: `If-Match` true needs a current row, `*` true
44+
* needs none), and a `409` would send the caller round a re-read that
45+
* serves a token it would pair with `*` again.
46+
*
47+
* ## The lifecycle
48+
*
49+
* `?mode=draft` (any case) is `mode: 'draft'`: the save stages a draft row and
50+
* leaves the active row alone. Any other value is an active save, as it has
51+
* 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.
59+
*/
60+
61+
import type { SaveMetaItemRequest } from '@objectstack/spec/api';
62+
63+
/** The two request members a save's precondition and lifecycle are read from — each door hands in its own. */
64+
export interface MetaSaveRequestHttp {
65+
/** A `Headers`-like (`get`) — the Fetch `Request` the catch-all hands on — or a plain header record. */
66+
readonly headers?: unknown;
67+
readonly query?: Readonly<Record<string, unknown>>;
68+
}
69+
70+
/** The `saveMetaItem` members {@link metaSaveRequestOptions} answers, each present only when the request asked for it. */
71+
export type MetaSaveRequestMembers = Pick<SaveMetaItemRequest, 'parentVersion' | 'mode'>;
72+
73+
/** {@link metaSaveRequestOptions}'s answer: the members to spread into the save, or the sentence of a `400`. */
74+
export type MetaSaveRequestOptions =
75+
| { readonly ok: true; readonly request: MetaSaveRequestMembers }
76+
| { readonly ok: false; readonly message: string };
77+
78+
/**
79+
* One request header, read from either shape a door hands in. `Headers.get`
80+
* answers `null` for an absent header; that is folded to `undefined`, the
81+
* record shape's absence, so both shapes ask the same question below.
82+
*/
83+
function requestHeader(headers: unknown, lower: string, canonical: string): unknown {
84+
if (!headers || typeof headers !== 'object') return undefined;
85+
const h = headers as { get?: unknown } & Record<string, unknown>;
86+
if (typeof h.get === 'function') return (h.get as (name: string) => unknown).call(headers, lower) ?? undefined;
87+
return h[lower] ?? h[canonical];
88+
}
89+
90+
/**
91+
* [#22141] The precondition and lifecycle a `PUT /meta/:type/:name` request
92+
* asks `saveMetaItem` for — see this module's header.
93+
*/
94+
export function metaSaveRequestOptions(http: MetaSaveRequestHttp): MetaSaveRequestOptions {
95+
const ifMatch = requestHeader(http.headers, 'if-match', 'If-Match');
96+
const ifNoneMatch = requestHeader(http.headers, 'if-none-match', 'If-None-Match');
97+
let parentVersion: string | null | undefined;
98+
if (ifNoneMatch !== undefined) {
99+
if (ifMatch !== undefined) {
100+
return {
101+
ok: false,
102+
message: 'Send If-Match or If-None-Match, not both. If-Match: <version> saves only over that '
103+
+ 'version; If-None-Match: * saves only where no row of this lifecycle exists. A row '
104+
+ 'cannot both exist and not exist, so this pair can never be honoured.',
105+
};
106+
}
107+
if (typeof ifNoneMatch !== 'string' || ifNoneMatch.trim() !== '*') {
108+
return {
109+
ok: false,
110+
message: 'If-None-Match on this route takes "*" alone (save only if no row of this lifecycle '
111+
+ 'exists). To pin a save to the version you read, send it as If-Match: <version>.',
112+
};
113+
}
114+
parentVersion = null;
115+
} else if (typeof ifMatch === 'string') {
116+
parentVersion = ifMatch.replace(/^"|"$/g, '');
117+
}
118+
const mode = http.query?.mode;
119+
const draft = typeof mode === 'string' && mode.toLowerCase() === 'draft';
120+
return {
121+
ok: true,
122+
request: {
123+
...(parentVersion !== undefined ? { parentVersion } : {}),
124+
...(draft ? { mode: 'draft' as const } : {}),
125+
},
126+
};
127+
}

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

Lines changed: 26 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ import type {
213213
// to the implementation is a governed-surface edit, so it is left to the
214214
// maintainer; this line goes when that row moves.
215215
export { filterAppForUser } from './meta-item-read-gate.js';
216+
// [#22141] The `PUT /meta/:type/:name` door's precondition and lifecycle, read
217+
// through the ONE mapping the runtime dispatcher's `/meta` domain asks too.
218+
import { metaSaveRequestOptions } from './meta-save-request.js';
216219
import type { ISecurityService } from '@objectstack/spec/contracts';
217220
import {
218221
resolveEffectiveApiMethods,
@@ -1717,57 +1720,6 @@ function mayReadPendingDrafts(caller: unknown): boolean {
17171720
return isObjectSchemaMaskExempt(caller);
17181721
}
17191722

1720-
/**
1721-
* [#22114] The ADR-0008 pin `PUT /meta/:type/:name` reads off its request
1722-
* headers: `parentVersion` for `saveMetaItem` — the `If-Match` token (ETag-style
1723-
* quotes stripped), `null` for `If-None-Match: *` ("no row of this lifecycle
1724-
* is here", the first-write pin the protocol has always declared), or
1725-
* `undefined` for neither (unpinned, last-write-wins, as before) — or the
1726-
* sentence of a `400` for a pin that cannot be honoured.
1727-
*
1728-
* `If-None-Match` is read on this route from the day it lands with a CLOSED
1729-
* value set, `*` alone (AGENTS.md 〈Route & surface ownership〉 rule 5's reason,
1730-
* one carrier over): a header read for the values it knows and dropped
1731-
* otherwise would write a caller unguarded who asked for a guard. Measured
1732-
* before it landed: no first-party client sends `If-None-Match` on a `PUT`
1733-
* (the SDK sends it only on its cached `GET`, objectui's ETag hook has no
1734-
* caller), and nothing on this path read it. Two refusals, both `400`:
1735-
*
1736-
* - a value other than `*` — an entity-tag list asks "write unless the head is
1737-
* one of these", a condition no `/meta` client has and this door does not
1738-
* evaluate;
1739-
* - `If-None-Match` beside `If-Match` — the pair can never hold (RFC 9110
1740-
* §13.2.2 evaluates both: `If-Match` true needs a current row, `*` true
1741-
* needs none), and a `409` would send the caller round a re-read that
1742-
* serves a token it would pair with `*` again.
1743-
*/
1744-
function metaSavePreconditionPin(headers: Record<string, unknown> | undefined):
1745-
| { ok: true; parentVersion?: string | null }
1746-
| { ok: false; message: string } {
1747-
const ifMatch = headers?.['if-match'] ?? headers?.['If-Match'];
1748-
const ifNoneMatch = headers?.['if-none-match'] ?? headers?.['If-None-Match'];
1749-
if (ifNoneMatch !== undefined) {
1750-
if (ifMatch !== undefined) {
1751-
return {
1752-
ok: false,
1753-
message: 'Send If-Match or If-None-Match, not both. If-Match: <version> saves only over that '
1754-
+ 'version; If-None-Match: * saves only where no row of this lifecycle exists. A row '
1755-
+ 'cannot both exist and not exist, so this pair can never be honoured.',
1756-
};
1757-
}
1758-
if (typeof ifNoneMatch !== 'string' || ifNoneMatch.trim() !== '*') {
1759-
return {
1760-
ok: false,
1761-
message: 'If-None-Match on this route takes "*" alone (save only if no row of this lifecycle '
1762-
+ 'exists). To pin a save to the version you read, send it as If-Match: <version>.',
1763-
};
1764-
}
1765-
return { ok: true, parentVersion: null };
1766-
}
1767-
if (typeof ifMatch === 'string') return { ok: true, parentVersion: ifMatch.replace(/^"|"$/g, '') };
1768-
return { ok: true };
1769-
}
1770-
17711723
/**
17721724
* [#22128] The ONE reading of `?package=` on the `/meta/:type/:name` item doors
17731725
* — the read (`GET`), the save (`PUT`) and the publish
@@ -7160,14 +7112,25 @@ export class RestServer {
71607112
// stored content hash, never the hash itself; the protocol
71617113
// compares it in that form, so it passes through here as sent.
71627114
// [#22114] The item read serves the same token as `version`,
7163-
// and `If-None-Match: *` pins a save that expects no row —
7164-
// see {@link metaSavePreconditionPin}.
7165-
const pin = metaSavePreconditionPin(req.headers);
7166-
if (!pin.ok) {
7167-
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: pin.message } });
7115+
// and `If-None-Match: *` pins a save that expects no row.
7116+
// [#22141] The pin and `?mode=draft` are read through
7117+
// {@link metaSaveRequestOptions} — the ONE mapping the
7118+
// runtime dispatcher's `/meta` door asks too, so the
7119+
// `@objectstack/hono` catch-all cannot answer this request
7120+
// differently. Its `request` members are spread into the
7121+
// save below, each present only when the caller asked.
7122+
//
7123+
// The multiplicity guard runs FIRST: it refuses a repeated
7124+
// `force`, `package` or `mode` (see the `force` read below
7125+
// for why that one is sharp) and UNWRAPS one occurrence
7126+
// encoded as an array, so `?mode=` reaches the mapping as
7127+
// the string it reads.
7128+
if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return;
7129+
const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query });
7130+
if (!saveOptions.ok) {
7131+
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: saveOptions.message } });
71687132
return;
71697133
}
7170-
const parentVersion = pin.parentVersion;
71717134
// [#7749 producer, #7941 precedence] The request's authenticated
71727135
// identity — one producer, shared by every `/meta` write (see
71737136
// resolveMetaWriteActor). `X-Actor` is not consulted.
@@ -7181,8 +7144,8 @@ export class RestServer {
71817144
// string, and a non-empty array is truthy — so
71827145
// `?force=false&force=false`, a caller repeating an explicit
71837146
// OPT-OUT, turned the destructive-change guard ON. An
7184-
// inversion, on a destructive verb, reported as 200.
7185-
if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return;
7147+
// inversion, on a destructive verb, reported as 200. The
7148+
// multiplicity guard above refuses it before this read.
71867149
const forceRaw = req.query?.force;
71877150
const force = typeof forceRaw === 'string'
71887151
? ['true', '1', 'yes', 'on'].includes(forceRaw.toLowerCase())
@@ -7291,13 +7254,13 @@ export class RestServer {
72917254
// a client cannot smuggle a face in.
72927255
writeFace: 'meta-envelope',
72937256
...(environmentId ? { environmentId } : {}),
7294-
...(parentVersion !== undefined ? { parentVersion } : {}),
7257+
// `parentVersion` and `mode`, each only when asked —
7258+
// a closed two-key object read off the headers and the
7259+
// query, never the body.
7260+
...saveOptions.request,
72957261
...(actor ? { actor } : {}),
72967262
...(force ? { force: true } : {}),
72977263
...(packageId ? { packageId } : {}),
7298-
...((typeof req.query?.mode === 'string'
7299-
&& req.query.mode.toLowerCase() === 'draft')
7300-
? { mode: 'draft' } : {}),
73017264
};
73027265
const result = await p.saveMetaItem(saveRequest);
73037266
res.json(result);

0 commit comments

Comments
 (0)