Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/22141-rest-meta-save-request-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@objectstack/rest": minor
---

`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`

Clause-②: yes (widening)

- `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`.
- 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.
11 changes: 11 additions & 0 deletions .changeset/22141-runtime-meta-save-preconditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/runtime": patch
---

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

Clause-②: no

- 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.
- 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.
- 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 } }`).
15 changes: 15 additions & 0 deletions packages/rest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,18 @@ export type {
MetaTypeReadRefusal,
MetaTypeWriteRefusal,
} from './meta-item-read-gate.js';

// [#22141] …and the write side of the same parity: the precondition
// (`If-Match` / `If-None-Match: *` → `parentVersion`, with its two `400`
// refusals) and the lifecycle (`?mode=draft` → `mode`) a `PUT /meta/:type/:name`
// asks `saveMetaItem` for. `RestServer` reads them through it, and so does the
// runtime dispatcher's `/meta` domain — the only answer behind the
// `@objectstack/hono` catch-all, which dropped all three until this landed.
// Each door hands in its own headers and query and writes a refusal in its own
// envelope (`meta-save-request.ts`'s header is the authority).
export { metaSaveRequestOptions } from './meta-save-request.js';
export type {
MetaSaveRequestHttp,
MetaSaveRequestMembers,
MetaSaveRequestOptions,
} from './meta-save-request.js';
12 changes: 7 additions & 5 deletions packages/rest/src/meta-draft-read-door-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
* Spellings it cannot see — a switch read under another name, a draft asked
* for by a spread of the raw query — are review questions, not rows; the
* behaviour pins are what hold the doors it names.
*
* [#22141] `PUT /meta/:type/:name?mode=draft` — a `write` switch — is no row
* here because it is no longer read in this file: the door asks
* `metaSaveRequestOptions` (`meta-save-request.ts`), the one mapping the
* runtime dispatcher's `/meta` door asks too, after its write-capability gate.
* Its behaviour is held by `meta-compound-save-mode-parity.test.ts` and by both
* doors' rows in `packages/runtime/src/domains/meta-save-preconditions-parity.test.ts`.
*/

import { describe, it, expect } from 'vitest';
Expand Down Expand Up @@ -160,11 +167,6 @@ const LEDGER: readonly LedgerRow[] = [
disposition: 'read',
door: 'GET /meta/:type/:name?preview=draft — the draft overlaid on the active item',
},
{
site: "PUT ${metaPath}/:type/:name » req.query.mode.toLowerCase() === 'draft'",
disposition: 'write',
door: 'PUT /meta/:type/:name?mode=draft — SAVES a draft; admitted by the write-capability gate first',
},
{
site: "DELETE ${metaPath}/:type/:name » req.query.state.toLowerCase() === 'draft'",
disposition: 'write',
Expand Down
127 changes: 127 additions & 0 deletions packages/rest/src/meta-save-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22141] What a `PUT /meta/:type/:name` request asks of `saveMetaItem`
* beyond the item itself — the ADR-0008 precondition and the ADR-0033
* lifecycle — read ONCE, here, for both doors that serve that route:
* `RestServer`'s `PUT` and the runtime dispatcher's `/meta` domain, which is
* the only answer on a host that mounts just `@objectstack/hono`'s
* `${prefix}/*` catch-all.
*
* The dispatcher's door read neither. It 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 ACTIVE — a draft went live without a publish,
* past ADR-0033's gate, with a `200` the client read as success. Both doors now
* call this one mapping, so they cannot answer the same request differently.
*
* What travels is the decision and nothing transport-shaped: each door hands
* in its own request members ({@link MetaSaveRequestHttp}), spreads
* `request` into its `saveMetaItem` literal, and writes a refusal on its own
* wire, in its own envelope — the split `metaRequestLocale` makes. Every key
* of `request` is ABSENT unless the caller asked for it, so an unguarded,
* active save reaches `saveMetaItem` exactly as before.
*
* ## The precondition (moved here unchanged from `RestServer`, #22114)
*
* `parentVersion` is the `If-Match` token (ETag-style quotes stripped), `null`
* for `If-None-Match: *` ("no row of this lifecycle is here", the first-write
* pin the protocol has always declared), or absent for neither (unpinned,
* last-write-wins).
*
* `If-None-Match` is read with a CLOSED value set, `*` alone (AGENTS.md
* 〈Route & surface ownership〉 rule 5's reason, one carrier over): a header
* read for the values it knows and dropped otherwise would write a caller
* unguarded who asked for a guard. Measured before it landed: no first-party
* client sends `If-None-Match` on a `PUT` (the SDK sends it only on its cached
* `GET`, objectui's ETag hook has no caller). Two refusals, both `400`:
*
* - a value other than `*` — an entity-tag list asks "write unless the head is
* one of these", a condition no `/meta` client has and neither door
* evaluates;
* - `If-None-Match` beside `If-Match` — the pair can never hold (RFC 9110
* §13.2.2 evaluates both: `If-Match` true needs a current row, `*` true
* needs none), and a `409` would send the caller round a re-read that
* serves a token it would pair with `*` again.
*
* ## The lifecycle
*
* `?mode=draft` (any case) is `mode: 'draft'`: the save stages a draft row and
* leaves the active row alone. Any other value is an active save, as it has
* always been on `RestServer`.
*
* `query.mode` is read as the string a door has already judged for
* multiplicity. `RestServer` calls this AFTER `refuseRepeatedQueryParams`,
* which refuses a repeated `mode` and unwraps one occurrence encoded as an
* array; the `@objectstack/hono` catch-all flattens its query from the URL, one
* string per name. ⛔ So call it after your own multiplicity gate, never
* before: an array reaching here is not read as a draft.
*/

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

/** The two request members a save's precondition and lifecycle are read from — each door hands in its own. */
export interface MetaSaveRequestHttp {
/** A `Headers`-like (`get`) — the Fetch `Request` the catch-all hands on — or a plain header record. */
readonly headers?: unknown;
readonly query?: Readonly<Record<string, unknown>>;
}

/** The `saveMetaItem` members {@link metaSaveRequestOptions} answers, each present only when the request asked for it. */
export type MetaSaveRequestMembers = Pick<SaveMetaItemRequest, 'parentVersion' | 'mode'>;

/** {@link metaSaveRequestOptions}'s answer: the members to spread into the save, or the sentence of a `400`. */
export type MetaSaveRequestOptions =
| { readonly ok: true; readonly request: MetaSaveRequestMembers }
| { readonly ok: false; readonly message: string };

/**
* One request header, read from either shape a door hands in. `Headers.get`
* answers `null` for an absent header; that is folded to `undefined`, the
* record shape's absence, so both shapes ask the same question below.
*/
function requestHeader(headers: unknown, lower: string, canonical: string): unknown {
if (!headers || typeof headers !== 'object') return undefined;
const h = headers as { get?: unknown } & Record<string, unknown>;
if (typeof h.get === 'function') return (h.get as (name: string) => unknown).call(headers, lower) ?? undefined;
return h[lower] ?? h[canonical];
}

/**
* [#22141] The precondition and lifecycle a `PUT /meta/:type/:name` request
* asks `saveMetaItem` for — see this module's header.
*/
export function metaSaveRequestOptions(http: MetaSaveRequestHttp): MetaSaveRequestOptions {
const ifMatch = requestHeader(http.headers, 'if-match', 'If-Match');
const ifNoneMatch = requestHeader(http.headers, 'if-none-match', 'If-None-Match');
let parentVersion: string | null | undefined;
if (ifNoneMatch !== undefined) {
if (ifMatch !== undefined) {
return {
ok: false,
message: 'Send If-Match or If-None-Match, not both. If-Match: <version> saves only over that '
+ 'version; If-None-Match: * saves only where no row of this lifecycle exists. A row '
+ 'cannot both exist and not exist, so this pair can never be honoured.',
};
}
if (typeof ifNoneMatch !== 'string' || ifNoneMatch.trim() !== '*') {
return {
ok: false,
message: 'If-None-Match on this route takes "*" alone (save only if no row of this lifecycle '
+ 'exists). To pin a save to the version you read, send it as If-Match: <version>.',
};
}
parentVersion = null;
} else if (typeof ifMatch === 'string') {
parentVersion = ifMatch.replace(/^"|"$/g, '');
}
const mode = http.query?.mode;
const draft = typeof mode === 'string' && mode.toLowerCase() === 'draft';
return {
ok: true,
request: {
...(parentVersion !== undefined ? { parentVersion } : {}),
...(draft ? { mode: 'draft' as const } : {}),
},
};
}
89 changes: 26 additions & 63 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ import type {
// to the implementation is a governed-surface edit, so it is left to the
// maintainer; this line goes when that row moves.
export { filterAppForUser } from './meta-item-read-gate.js';
// [#22141] The `PUT /meta/:type/:name` door's precondition and lifecycle, read
// through the ONE mapping the runtime dispatcher's `/meta` domain asks too.
import { metaSaveRequestOptions } from './meta-save-request.js';
import type { ISecurityService } from '@objectstack/spec/contracts';
import {
resolveEffectiveApiMethods,
Expand Down Expand Up @@ -1717,57 +1720,6 @@ function mayReadPendingDrafts(caller: unknown): boolean {
return isObjectSchemaMaskExempt(caller);
}

/**
* [#22114] The ADR-0008 pin `PUT /meta/:type/:name` reads off its request
* headers: `parentVersion` for `saveMetaItem` — the `If-Match` token (ETag-style
* quotes stripped), `null` for `If-None-Match: *` ("no row of this lifecycle
* is here", the first-write pin the protocol has always declared), or
* `undefined` for neither (unpinned, last-write-wins, as before) — or the
* sentence of a `400` for a pin that cannot be honoured.
*
* `If-None-Match` is read on this route from the day it lands with a CLOSED
* value set, `*` alone (AGENTS.md 〈Route & surface ownership〉 rule 5's reason,
* one carrier over): a header read for the values it knows and dropped
* otherwise would write a caller unguarded who asked for a guard. Measured
* before it landed: no first-party client sends `If-None-Match` on a `PUT`
* (the SDK sends it only on its cached `GET`, objectui's ETag hook has no
* caller), and nothing on this path read it. Two refusals, both `400`:
*
* - a value other than `*` — an entity-tag list asks "write unless the head is
* one of these", a condition no `/meta` client has and this door does not
* evaluate;
* - `If-None-Match` beside `If-Match` — the pair can never hold (RFC 9110
* §13.2.2 evaluates both: `If-Match` true needs a current row, `*` true
* needs none), and a `409` would send the caller round a re-read that
* serves a token it would pair with `*` again.
*/
function metaSavePreconditionPin(headers: Record<string, unknown> | undefined):
| { ok: true; parentVersion?: string | null }
| { ok: false; message: string } {
const ifMatch = headers?.['if-match'] ?? headers?.['If-Match'];
const ifNoneMatch = headers?.['if-none-match'] ?? headers?.['If-None-Match'];
if (ifNoneMatch !== undefined) {
if (ifMatch !== undefined) {
return {
ok: false,
message: 'Send If-Match or If-None-Match, not both. If-Match: <version> saves only over that '
+ 'version; If-None-Match: * saves only where no row of this lifecycle exists. A row '
+ 'cannot both exist and not exist, so this pair can never be honoured.',
};
}
if (typeof ifNoneMatch !== 'string' || ifNoneMatch.trim() !== '*') {
return {
ok: false,
message: 'If-None-Match on this route takes "*" alone (save only if no row of this lifecycle '
+ 'exists). To pin a save to the version you read, send it as If-Match: <version>.',
};
}
return { ok: true, parentVersion: null };
}
if (typeof ifMatch === 'string') return { ok: true, parentVersion: ifMatch.replace(/^"|"$/g, '') };
return { ok: true };
}

/**
* [#22128] The ONE reading of `?package=` on the `/meta/:type/:name` item doors
* — the read (`GET`), the save (`PUT`) and the publish
Expand Down Expand Up @@ -7160,14 +7112,25 @@ export class RestServer {
// stored content hash, never the hash itself; the protocol
// compares it in that form, so it passes through here as sent.
// [#22114] The item read serves the same token as `version`,
// and `If-None-Match: *` pins a save that expects no row —
// see {@link metaSavePreconditionPin}.
const pin = metaSavePreconditionPin(req.headers);
if (!pin.ok) {
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: pin.message } });
// and `If-None-Match: *` pins a save that expects no row.
// [#22141] The pin and `?mode=draft` are read through
// {@link metaSaveRequestOptions} — the ONE mapping the
// runtime dispatcher's `/meta` door asks too, so the
// `@objectstack/hono` catch-all cannot answer this request
// differently. Its `request` members are spread into the
// save below, each present only when the caller asked.
//
// The multiplicity guard runs FIRST: it refuses a repeated
// `force`, `package` or `mode` (see the `force` read below
// for why that one is sharp) and UNWRAPS one occurrence
// encoded as an array, so `?mode=` reaches the mapping as
// the string it reads.
if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return;
const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query });
if (!saveOptions.ok) {
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: saveOptions.message } });
return;
}
const parentVersion = pin.parentVersion;
// [#7749 producer, #7941 precedence] The request's authenticated
// identity — one producer, shared by every `/meta` write (see
// resolveMetaWriteActor). `X-Actor` is not consulted.
Expand All @@ -7181,8 +7144,8 @@ export class RestServer {
// string, and a non-empty array is truthy — so
// `?force=false&force=false`, a caller repeating an explicit
// OPT-OUT, turned the destructive-change guard ON. An
// inversion, on a destructive verb, reported as 200.
if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return;
// inversion, on a destructive verb, reported as 200. The
// multiplicity guard above refuses it before this read.
const forceRaw = req.query?.force;
const force = typeof forceRaw === 'string'
? ['true', '1', 'yes', 'on'].includes(forceRaw.toLowerCase())
Expand Down Expand Up @@ -7291,13 +7254,13 @@ export class RestServer {
// a client cannot smuggle a face in.
writeFace: 'meta-envelope',
...(environmentId ? { environmentId } : {}),
...(parentVersion !== undefined ? { parentVersion } : {}),
// `parentVersion` and `mode`, each only when asked —
// a closed two-key object read off the headers and the
// query, never the body.
...saveOptions.request,
...(actor ? { actor } : {}),
...(force ? { force: true } : {}),
...(packageId ? { packageId } : {}),
...((typeof req.query?.mode === 'string'
&& req.query.mode.toLowerCase() === 'draft')
? { mode: 'draft' } : {}),
};
const result = await p.saveMetaItem(saveRequest);
res.json(result);
Expand Down
Loading
Loading