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..96c70708257 --- /dev/null +++ b/.changeset/22141-rest-meta-save-request-options.md @@ -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. 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 } }`). 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-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 new file mode 100644 index 00000000000..3829bd69d85 --- /dev/null +++ b/packages/rest/src/meta-save-request.ts @@ -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>; +} + +/** 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 3bdbd288ea0..530b674d57e 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 }; -} - /** * [#22128] The ONE reading of `?package=` on the `/meta/:type/:name` item doors * — the read (`GET`), the save (`PUT`) and the publish @@ -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. @@ -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()) @@ -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); 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..96bc9ac10da --- /dev/null +++ b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts @@ -0,0 +1,344 @@ +// 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(); + 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' }); + } + 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']); + }); +}); 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') {