From 1c3964a0dd0d1d400a1e8bcf4cffe83f9d112b95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:31:08 +0000 Subject: [PATCH 1/5] fix(runtime): the /meta catch-all save honours If-Match, If-None-Match and ?mode=draft The runtime dispatcher's PUT /meta/:type/:name (the only answer behind the @objectstack/hono catch-all) handed saveMetaItem no parentVersion and no mode, so a stale If-Match wrote, If-None-Match: * over an existing row wrote, and a ?mode=draft save landed active. RestServer's precondition parser moves to @objectstack/rest's metaSaveRequestOptions, which now also reads ?mode=draft, and both doors call it. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- packages/rest/src/index.ts | 15 ++++ packages/rest/src/meta-save-request.ts | 120 +++++++++++++++++++++++++ packages/rest/src/rest-server.ts | 78 ++++------------ packages/runtime/src/domains/meta.ts | 45 ++++++++++ 4 files changed, 197 insertions(+), 61 deletions(-) create mode 100644 packages/rest/src/meta-save-request.ts diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index f9f0852c32b..e87f506a585 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -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'; diff --git a/packages/rest/src/meta-save-request.ts b/packages/rest/src/meta-save-request.ts new file mode 100644 index 00000000000..fa777570522 --- /dev/null +++ b/packages/rest/src/meta-save-request.ts @@ -0,0 +1,120 @@ +// 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`. + */ + +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>; +} + +/** The `saveMetaItem` members {@link metaSaveRequestOptions} answers, each present only when the request asked for it. */ +export type MetaSaveRequestMembers = Pick; + +/** {@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; + 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: 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: .', + }; + } + 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 } : {}), + }, + }; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 30dc3f1168c..9f213471de0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -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, @@ -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 | 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: 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: .', - }; - } - return { ok: true, parentVersion: null }; - } - if (typeof ifMatch === 'string') return { ok: true, parentVersion: ifMatch.replace(/^"|"$/g, '') }; - return { ok: true }; -} - /** * [#20378 · #20441] THE AUTHORING-DOOR REFUSAL — ruling 5865708652 (letter B), * carried to `/audit` by triage's grade 5871509797. Sends it and answers `true` @@ -7135,14 +7087,18 @@ 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. + 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. @@ -7268,13 +7224,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); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 65882c0e5f0..e90328952e7 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -59,6 +59,8 @@ import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-pr // [#20478] …and the layered view, on both of its spellings: its post-read // chain, the deprecated `?layers=` flag's parse and the headers that flag is // served under. +// [#22141] …and the save door's precondition and lifecycle +// (`metaSaveRequestOptions`): `If-Match` / `If-None-Match: *` and `?mode=draft`. // Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { createMetaBookTreeAnswer, @@ -71,6 +73,7 @@ import { metaItemLayersDeprecationHeaders, metaReadOrganizationId, metaRequestLocale, + metaSaveRequestOptions, metaTypeReadRefusal, metaTypeWriteRefusal, projectMetaObjectSchema, @@ -1334,6 +1337,27 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } + // [#22141] The ADR-0008 precondition and the ADR-0033 lifecycle, + // read off THIS request through the one mapping `RestServer`'s + // `PUT` door reads them through (`metaSaveRequestOptions`, + // `@objectstack/rest`). This branch read neither: it handed + // `saveMetaItem` no `parentVersion` and no `mode`, so through the + // `@objectstack/hono` 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 live without a publish. + // + // The headers are the request's own: the catch-all hands + // `dispatch()` the raw Fetch `Request` (`Headers.get`), a Node host + // its header record, and the mapping reads both. Asked after the + // capability gate, so a refused caller learns nothing from a pin's + // shape. A pin that cannot be honoured is a `400` whose code + // `deps.error` derives from the status — `VALIDATION_ERROR`, the + // code `RestServer` answers it with. + const saveOptions = metaSaveRequestOptions({ headers: _context.request?.headers, query }); + if (!saveOptions.ok) { + return { handled: true, response: deps.error(saveOptions.message, 400) }; + } + // [#8842] Fold a nullish body to `{}` and let the per-type schema // refuse it downstream with `422 INVALID_METADATA`, rather than // minting a second, bespoke refusal here. This is byte-for-byte @@ -1431,6 +1455,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin type, name, item, organizationId, writeFace: 'meta-dispatch', ...(packageId ? { packageId } : {}), + // [#22141] `parentVersion` and `mode`, each present + // only when the caller asked — read off the headers and + // the query above, never off `item`. + ...saveOptions.request, }); return { handled: true, response: deps.success(result) }; } catch (e: any) { @@ -1441,6 +1469,23 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } } + // [#22141] The fallback below writes `(type, name, item)` and + // nothing else, so it can honour neither a precondition nor a + // draft. A caller who asked for one is refused — `501`, the answer + // `RestServer` gives any save its protocol cannot take — rather than + // written unguarded or ACTIVE, the very drop just closed above. + if (saveOptions.request.parentVersion !== undefined || saveOptions.request.mode !== undefined) { + return { + handled: true, + response: deps.error( + 'This host has no protocol saveMetaItem, so it cannot honour If-Match, If-None-Match ' + + 'or ?mode=draft on a save. Send the save without them, or save through a host whose ' + + 'protocol implements saveMetaItem.', + 501, + ), + }; + } + // Fallback: try MetadataService directly const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metaSvc && typeof (metaSvc as any).saveItem === 'function') { From bea3eb29e887c955f22e2927c665e9aee5c75fa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:34:21 +0000 Subject: [PATCH 2/5] test(runtime): pin both /meta save doors to one answer for If-Match, If-None-Match and ?mode=draft RestServer's PUT handler and the dispatcher driven exactly as the @objectstack/hono catch-all drives it, each over its own real sqlite store: a stale token and If-None-Match: * over a row are refused 409 and write nothing, a draft save leaves the active row untouched, an unguarded save is unchanged, and the dispatcher's fallback writer refuses a pin it cannot carry. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .../meta-save-preconditions-parity.test.ts | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 packages/runtime/src/domains/meta-save-preconditions-parity.test.ts diff --git a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts new file mode 100644 index 00000000000..d9365b2aea1 --- /dev/null +++ b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts @@ -0,0 +1,343 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22141] Both doors of `PUT /meta/:type/:name` honour the save's precondition + * and lifecycle alike — `RestServer`'s route and the runtime dispatcher's + * `/meta` domain, the only answer on a host that mounts just + * `@objectstack/hono`'s `${prefix}/*` catch-all. + * + * ## What was wrong + * + * The dispatcher's `PUT` branch handed `saveMetaItem` no `parentVersion` and + * no `mode`. 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 live without a publish — each answered `200`. + * Both doors now read the request through ONE mapping, + * `metaSaveRequestOptions` (`@objectstack/rest`), and these rows hold the two + * to the same answer over the same cases: the store, not the door's word for + * it, is read after every write. + * + * ## The rig + * + * Each door gets its own REAL store — better-sqlite3 `:memory:`, the real + * `sys_metadata*` objects, a real `ObjectStackProtocolImplementation` — so the + * claim is about what each request DID to the rows: + * + * - `RestServer`: its registered `PUT` handler, called with the request the + * `plugin-hono-server` adapter builds (a lowercased header record), the + * auth boundary (`resolveExecCtx`) stubbed, as + * `meta-item-version-token-occ.test.ts` drives it; + * - the catch-all: {@link catchAll} repeats the catch-all's four statements + * over a real Fetch `Request` — the path below the prefix, the JSON body of + * a write verb, the query flattened from the URL's `searchParams`, and + * `{ request }` the raw `Request` itself, whose `Headers` is where the pins + * ride — into the real `HttpDispatcher.dispatch()`. The identity step is + * the one stubbed seam, as in the sibling dispatch-level pins. + * + * ⚠️ Why the catch-all's statements are repeated here rather than imported: + * this package cannot import `@objectstack/hono` (that package depends on this + * one), and `packages/adapters/hono`'s suite aliases `@objectstack/runtime` to + * a stub, so no test there reaches the real dispatcher. What `catchAll` hands + * `dispatch()` is what the adapter hands it, argument for argument. + * + * Each door answers in its own envelope — the dispatcher nests `code` under + * `error` beside `success: false`; `RestServer`'s `409` is the flat + * `MetadataConflictErrorSchema` dialect and its `400` nests — so a refusal is + * read through {@link codeOf}, and the parity asserted is status, `code` and + * the store, never the envelope's bytes. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PREFIX = '/api/v1'; +const META = `${PREFIX}/meta`; +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; +/** An author: `manage_metadata` saves, on both doors. */ +const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata'] }; + +const TASK = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const view = (name: string, label: string) => ({ + name, + label, + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], +}); + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** One real store: the engine, the metadata objects, and the protocol over them. */ +async function bootStore() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + const protocol = new ObjectStackProtocolImplementation(engine as any); + /** The stored labels of a view, by lifecycle — the store, not the door's word for it. */ + const stored = async (name: string, state: 'active' | 'draft'): Promise => { + const rows = await engine.find('sys_metadata', { where: { type: 'view', name, state } }); + return (rows ?? []).map((r: any) => JSON.parse(String(r.metadata)).label as string); + }; + return { protocol, stored }; +} + +// ── The two doors ───────────────────────────────────────────────────────────── + +interface Answer { status: number; code?: string; message?: string; receipt?: any } +interface SaveOptions { headers?: Record; query?: Record } +interface Door { + save(name: string, label: string, opts?: SaveOptions): Promise; + stored(name: string, state: 'active' | 'draft'): Promise; +} + +/** The refusal's `code` in either door's envelope: nested under `error`, or beside a string `error`. */ +const codeOf = (body: any): string | undefined => + body?.error && typeof body.error === 'object' ? body.error.code : body?.code; +const messageOf = (body: any): string | undefined => + body?.error && typeof body.error === 'object' ? body.error.message : body?.error; + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; + res.write = () => true; + return res; +} + +async function bootRestServer(): Promise { + const { protocol, stored } = await bootStore(); + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + rest.resolveExecCtx = async () => clone(AUTHOR); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'PUT' && r.path === `${META}/:type/:name`); + if (!route) throw new Error(`PUT ${META}/:type/:name is not registered`); + return { + stored, + save: async (name, label, { headers = {}, query = {} } = {}) => { + const res = makeRes(); + await route.handler({ + method: 'PUT', path: `${META}/view/${name}`, params: { type: 'view', name }, + // The adapter's header record is lowercased (`c.req.header()`). + headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])), + query, body: view(name, label), + }, res); + return { status: res.statusCode, code: codeOf(res.body), message: messageOf(res.body), receipt: res.body }; + }, + }; +} + +/** + * What `createHonoApp`'s `${prefix}/*` catch-all hands `dispatch()`, statement + * for statement: the path below the prefix, a JSON body for `POST` / `PUT` / + * `PATCH` (an unparseable one folded to `{}`), the query flattened from the + * URL, and the raw Fetch `Request` as `context.request`. + */ +async function catchAll(dispatcher: HttpDispatcher, raw: Request) { + const subPath = new URL(raw.url).pathname.substring(PREFIX.length); + const method = raw.method; + let body: any = undefined; + if (method === 'POST' || method === 'PUT' || method === 'PATCH') { + body = await raw.clone().json().catch(() => ({})); + } + const queryParams: Record = {}; + new URL(raw.url).searchParams.forEach((val, key) => { queryParams[key] = val; }); + return dispatcher.dispatch(method, subPath, body, queryParams, { request: raw }, PREFIX); +} + +function dispatcherOver(services: Record): HttpDispatcher { + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch()` resolves identity through (the sibling + // dispatch-level pins supply the principal the same way). + (dispatcher as any).timedResolveExecutionContext = async () => clone(AUTHOR); + return dispatcher; +} + +const putRequest = (name: string, label: string, { headers = {}, query = {} }: SaveOptions = {}) => { + const url = new URL(`http://localhost${META}/view/${name}`); + for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v); + return new Request(url, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(view(name, label)), + }); +}; + +async function bootCatchAll(): Promise { + const { protocol, stored } = await bootStore(); + const dispatcher = dispatcherOver({ protocol }); + return { + stored, + save: async (name, label, opts = {}) => { + const res = await catchAll(dispatcher, putRequest(name, label, opts)); + const body = res.response?.body; + return { status: res.response?.status ?? 0, code: codeOf(body), message: messageOf(body), receipt: body?.data }; + }, + }; +} + +const DOORS: ReadonlyArray Promise]> = [ + ['RestServer', bootRestServer], + ['the @objectstack/hono catch-all', bootCatchAll], +]; + +const ok = (a: Answer) => { + expect(a.status, JSON.stringify(a)).toBe(200); + return a.receipt; +}; + +// ── The four cases, on each door ────────────────────────────────────────────── + +for (const [doorName, boot] of DOORS) { + describe(`[#22141] ${doorName}: PUT /meta/:type/:name honours If-Match, If-None-Match and ?mode=draft`, () => { + it('control: an unguarded save writes the active row, and the last writer wins', async () => { + const door = await boot(); + expect(ok(await door.save('case_grid', 'v1')).state).toBe('active'); + expect(ok(await door.save('case_grid', 'v2')).state).toBe('active'); + expect(await door.stored('case_grid', 'active')).toEqual(['v2']); + expect(await door.stored('case_grid', 'draft')).toEqual([]); + }, 60_000); + + it('a stale If-Match is refused 409 METADATA_CONFLICT and writes nothing; the current token saves', async () => { + const door = await boot(); + const first = ok(await door.save('case_grid', 'v1')); + const second = ok(await door.save('case_grid', 'v2')); + expect(second.version).not.toBe(first.version); + + const stale = await door.save('case_grid', 'v3 over a stale read', { headers: { 'If-Match': first.version } }); + expect({ status: stale.status, code: stale.code }).toEqual({ status: 409, code: 'METADATA_CONFLICT' }); + expect(await door.stored('case_grid', 'active')).toEqual(['v2']); + + // Preservation: the token the last receipt served still pins a save. + ok(await door.save('case_grid', 'v3', { headers: { 'If-Match': second.version } })); + expect(await door.stored('case_grid', 'active')).toEqual(['v3']); + }, 60_000); + + it('If-None-Match: * writes the first row, and over an existing row is refused 409 and writes nothing', async () => { + const door = await boot(); + ok(await door.save('fresh_grid', 'first', { headers: { 'If-None-Match': '*' } })); + expect(await door.stored('fresh_grid', 'active')).toEqual(['first']); + + const again = await door.save('fresh_grid', 'second', { headers: { 'If-None-Match': '*' } }); + expect({ status: again.status, code: again.code }).toEqual({ status: 409, code: 'METADATA_CONFLICT' }); + expect(await door.stored('fresh_grid', 'active')).toEqual(['first']); + }, 60_000); + + it('?mode=draft stages a draft and leaves the active row untouched', async () => { + const door = await boot(); + ok(await door.save('case_grid', 'live')); + + const draft = ok(await door.save('case_grid', 'staged', { query: { mode: 'draft' } })); + expect(draft.state).toBe('draft'); + expect(await door.stored('case_grid', 'active')).toEqual(['live']); + expect(await door.stored('case_grid', 'draft')).toEqual(['staged']); + }, 60_000); + + it('a pin that can never be honoured is refused 400 VALIDATION_ERROR and writes nothing', async () => { + const door = await boot(); + for (const headers of [ + { 'If-Match': 'any-token', 'If-None-Match': '*' }, + { 'If-None-Match': 'W/"an-entity-tag"' }, + ]) { + const refused = await door.save('case_grid', 'never', { headers }); + expect({ status: refused.status, code: refused.code }, JSON.stringify(headers)).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + } + expect(await door.stored('case_grid', 'active')).toEqual([]); + expect(await door.stored('case_grid', 'draft')).toEqual([]); + }, 60_000); + }); +} + +// ── The same refusal, side by side ──────────────────────────────────────────── + +describe('[#22141] the two doors refuse a stale token with the same status, code and sentence', () => { + it('one conflict, two envelopes: 409, METADATA_CONFLICT, and one refusal sentence', async () => { + const answers: Answer[] = []; + for (const [, boot] of DOORS) { + const door = await boot(); + const first = ok(await door.save('case_grid', 'v1')); + ok(await door.save('case_grid', 'v2')); + answers.push(await door.save('case_grid', 'v3', { headers: { 'If-Match': first.version } })); + } + const [rest, catchAllAnswer] = answers; + expect({ status: catchAllAnswer.status, code: catchAllAnswer.code }).toEqual({ status: rest.status, code: rest.code }); + // Each store mints its own version tokens, so the sentences are compared with the token masked. + const masked = (m: string | undefined) => (m ?? '').replace(/hmac-sha256:[0-9a-f]+/g, 'TOKEN'); + expect(masked(catchAllAnswer.message)).toBe(masked(rest.message)); + }, 60_000); +}); + +// ── The dispatcher's fallback writer ────────────────────────────────────────── + +/** + * The dispatcher alone has a second writer: a host whose `protocol` brings no + * `saveMetaItem` falls to the metadata service's `saveItem(type, name, item)`, + * which takes neither a precondition nor a lifecycle. A caller who asked for + * one is refused `501` — the answer `RestServer` gives any save its protocol + * cannot take — never written unguarded or active. + */ +describe('[#22141] the catch-all\'s fallback writer cannot carry a pin or a lifecycle, so asking for one is refused', () => { + const fallbackHost = () => { + const saveItem = vi.fn(async (_type: string, name: string, item: any) => ({ name, label: item?.label })); + return { dispatcher: dispatcherOver({ protocol: {}, metadata: { saveItem } }), saveItem }; + }; + + for (const [asked, opts] of [ + ['If-Match', { headers: { 'If-Match': 'a-token' } }], + ['If-None-Match: *', { headers: { 'If-None-Match': '*' } }], + ['?mode=draft', { query: { mode: 'draft' } }], + ] as const) { + it(`${asked}: 501 NOT_IMPLEMENTED, and the fallback never writes`, async () => { + const { dispatcher, saveItem } = fallbackHost(); + const res = await catchAll(dispatcher, putRequest('case_grid', 'never', opts as SaveOptions)); + expect({ status: res.response?.status, code: codeOf(res.response?.body) }).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); + expect(saveItem).not.toHaveBeenCalled(); + }); + } + + it('control: an unguarded save still reaches the fallback writer', async () => { + const { dispatcher, saveItem } = fallbackHost(); + const res = await catchAll(dispatcher, putRequest('case_grid', 'kept')); + expect(res.response?.status).toBe(200); + expect(saveItem).toHaveBeenCalledTimes(1); + expect(saveItem.mock.calls[0].slice(0, 2)).toEqual(['view', 'case_grid']); + }); +}); From 958841178c39d3146f15da45c7b2a3bcf075f426 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:38:36 +0000 Subject: [PATCH 3/5] chore(changeset): patch @objectstack/runtime and @objectstack/rest for the /meta save preconditions Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .changeset/22141-rest-meta-save-request-options.md | 10 ++++++++++ .changeset/22141-runtime-meta-save-preconditions.md | 11 +++++++++++ 2 files changed, 21 insertions(+) create mode 100644 .changeset/22141-rest-meta-save-request-options.md create mode 100644 .changeset/22141-runtime-meta-save-preconditions.md diff --git a/.changeset/22141-rest-meta-save-request-options.md b/.changeset/22141-rest-meta-save-request-options.md new file mode 100644 index 00000000000..7805290720f --- /dev/null +++ b/.changeset/22141-rest-meta-save-request-options.md @@ -0,0 +1,10 @@ +--- +"@objectstack/rest": patch +--- + +`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-②: no + +- `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. diff --git a/.changeset/22141-runtime-meta-save-preconditions.md b/.changeset/22141-runtime-meta-save-preconditions.md new file mode 100644 index 00000000000..6d249361c74 --- /dev/null +++ b/.changeset/22141-runtime-meta-save-preconditions.md @@ -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 } }`). From bba4677420b013b80e368fa65d3d1d646937f361 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:53:57 +0000 Subject: [PATCH 4/5] 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 --- .../rest/src/meta-draft-read-door-census.test.ts | 12 +++++++----- packages/rest/src/meta-save-request.ts | 7 +++++++ packages/rest/src/rest-server.ts | 11 +++++++++-- .../domains/meta-save-preconditions-parity.test.ts | 5 +++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index ab436f6515c..c837cb40e69 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -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'; @@ -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', diff --git a/packages/rest/src/meta-save-request.ts b/packages/rest/src/meta-save-request.ts index fa777570522..3829bd69d85 100644 --- a/packages/rest/src/meta-save-request.ts +++ b/packages/rest/src/meta-save-request.ts @@ -49,6 +49,13 @@ * `?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'; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9f213471de0..5423cf8634a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -7094,6 +7094,13 @@ export class RestServer { // `@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 } }); @@ -7112,8 +7119,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()) diff --git a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts index d9365b2aea1..96bc9ac10da 100644 --- a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts +++ b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts @@ -273,10 +273,11 @@ for (const [doorName, boot] of DOORS) { it('a pin that can never be honoured is refused 400 VALIDATION_ERROR and writes nothing', async () => { const door = await boot(); - for (const headers of [ + const unhonourable: Array> = [ { 'If-Match': 'any-token', 'If-None-Match': '*' }, { 'If-None-Match': 'W/"an-entity-tag"' }, - ]) { + ]; + for (const headers of unhonourable) { const refused = await door.save('case_grid', 'never', { headers }); expect({ status: refused.status, code: refused.code }, JSON.stringify(headers)).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); } From 6d5f6a5fea58c6bb948c46fc3c2b10000ec1cd7a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:28:41 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(changeset):=20grade=20@objectstack/re?= =?UTF-8?q?st=20minor=20=E2=80=94=20the=20metaSaveRequestOptions=20export?= =?UTF-8?q?=20widens=20its=20public=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-②: yes (widening) for the rest changeset; the runtime changeset stays patch with Clause-②: no. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .changeset/22141-rest-meta-save-request-options.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/22141-rest-meta-save-request-options.md b/.changeset/22141-rest-meta-save-request-options.md index 7805290720f..96c70708257 100644 --- a/.changeset/22141-rest-meta-save-request-options.md +++ b/.changeset/22141-rest-meta-save-request-options.md @@ -1,10 +1,10 @@ --- -"@objectstack/rest": patch +"@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-②: no +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.