From f0f77809f4a4ca47f45564c3a688739477579fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:48:22 +0000 Subject: [PATCH 1/4] fix(rest): the six by-name /meta doors outside the member ruling declare closed query sets PUT /meta/:type/:name and GET .../references, /layers, /audit, /diff and /published each open with refuseUnknownQueryParams over a set measured from their read points, so ?member= (and any other name they do not read) is a located 400 instead of being dropped. The unknown-parameter message says "This endpoint takes no query parameters." for an empty set. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/rest/src/query-allowlist.ts | 10 +- .../rest-server-meta-door-closed-sets.test.ts | 219 ++++++++++++++++++ packages/rest/src/rest-server.ts | 93 ++++++++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 packages/rest/src/rest-server-meta-door-closed-sets.test.ts diff --git a/packages/rest/src/query-allowlist.ts b/packages/rest/src/query-allowlist.ts index b6f46a92fd2..046412f5dd1 100644 --- a/packages/rest/src/query-allowlist.ts +++ b/packages/rest/src/query-allowlist.ts @@ -147,6 +147,11 @@ * caller who guessed wrong can fix the request from the response alone, * without reading our source or our docs. * + * A route whose closed set is EMPTY reads no query parameter at all (#23072: + * `GET /meta/:type/:name/references` and `/published`), and the second half + * says so in words: `Supported parameters: .` would read as a truncated + * answer, where the remedy is to send none. + * * @param unknown the unrecognised names, in the order they should be * reported (the caller sorts them for determinism) * @param supported every name this route accepts, sorted @@ -158,9 +163,12 @@ export function unknownQueryParamMessage( const subject = unknown.length === 1 ? `The "${unknown[0]}" query parameter is not supported by this endpoint.` : `The query parameters ${unknown.map(n => `"${n}"`).join(', ')} are not supported by this endpoint.`; + const accepted = supported.length === 0 + ? 'This endpoint takes no query parameters.' + : `Supported parameters: ${supported.join(', ')}.`; return `${subject} This endpoint will not silently ignore a parameter it does not ` + `understand — an ignored filter is indistinguishable from one that matched everything. ` - + `Supported parameters: ${supported.join(', ')}.`; + + accepted; } /** diff --git a/packages/rest/src/rest-server-meta-door-closed-sets.test.ts b/packages/rest/src/rest-server-meta-door-closed-sets.test.ts new file mode 100644 index 00000000000..b71e099432d --- /dev/null +++ b/packages/rest/src/rest-server-meta-door-closed-sets.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23072] The by-name `/meta` doors OUTSIDE ruling 6116165299's list each + * declare a closed query-parameter set (AGENTS.md route rule 5), so the bundle + * member selector `member` — honoured by the five doors in the ruling's list + * (`rest-server-meta-member-selector.test.ts`, this card's CONTROL) — is + * refused here with a located 400 instead of being dropped. + * + * Before, none of the six declared a set: `PUT /meta/email_template/x?member=zh-CN` + * with an `en-US` body wrote `en-US` and answered 200, and `/diff`, `/layers`, + * `/audit`, `/published` and `/references` answered the canonical member to a + * request that named another — plausible answers about the wrong row, with no + * status, header or field to tell them apart. + * + * Both halves per door, as the rule asks: + * - REFUSAL: `member`, and any other name the door does not read — status, + * the NESTED `error.code`, the located message, and the protocol never + * called (for `PUT`: nothing saved); + * - PRESERVATION: every name the door reads still reaches the protocol, and + * the request it received is asserted whole — never merely "still 200". + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension. +import { + RestServer, + META_ITEM_SAVE_PARAMS, + META_ITEM_REFERENCES_PARAMS, + META_ITEM_LAYERS_PARAMS, + META_ITEM_AUDIT_PARAMS, + META_ITEM_DIFF_PARAMS, + META_ITEM_PUBLISHED_PARAMS, +} from './rest-server.js'; +import { unknownQueryParamMessage } from './query-allowlist.js'; + +const META = '/api/v1/meta'; +const ITEM = `${META}/:type/:name`; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + _headers: {} as Record, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(function (this: any) { return this; }), + write: vi.fn(function (this: any) { return true; }), + end: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(function (this: any, k: string, v: string) { this._headers[k] = v; return this; }), + }; + return res; +} + +const EN = { name: 'acme.welcome', label: 'Welcome', locale: 'en-US', subject: 'en', bodyHtml: '

en

' }; + +/** A RestServer over a stubbed protocol whose six doors each answer something servable. */ +function boot() { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue({ items: [] }), + getMetaItem: vi.fn().mockResolvedValue({ type: 'email_template', name: 'acme.welcome', item: EN, lock: 'none' }), + getMetaItemLayered: vi.fn().mockResolvedValue({ + type: 'email_template', name: 'acme.welcome', code: null, overlay: EN, overlayScope: 'env', effective: EN, + }), + saveMetaItem: vi.fn().mockResolvedValue({ success: true, version: 'v1', seq: 1 }), + findReferencesToMeta: vi.fn().mockResolvedValue({ references: [] }), + auditMetaItem: vi.fn().mockResolvedValue({ events: [] }), + diffMetaItem: vi.fn().mockResolvedValue({ + type: 'email_template', name: 'acme.welcome', fromVersion: 1, toVersion: 2, added: [], removed: [], changed: [], + }), + }; + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false }, metadata: { enableCache: false } } as any, + ); + // `isSystem` clears the capability and authoring gates that run BEFORE the + // query is read, so every request reaches the rule it is named after. + (rest as any).resolveExecCtx = async () => ({ isSystem: true, userId: 'u1' }); + rest.registerRoutes(); + + const drive = async (method: string, path: string, query: Record, body: unknown = {}) => { + const found = (rest as any).getRoutes().find((r: any) => r.method === method && r.path === path); + if (!found) throw new Error(`route not registered: ${method} ${path}`); + const res = mockRes(); + await found.handler({ + method, path, params: { type: 'email_template', name: 'acme.welcome' }, query, headers: {}, body, + } as any, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + return { protocol, drive }; +} + +/** The six doors: the route, its declared set, the protocol method it reaches and a body it accepts. */ +const DOORS = [ + { door: 'PUT item (save)', method: 'PUT', path: ITEM, set: META_ITEM_SAVE_PARAMS, call: 'saveMetaItem', body: EN }, + { door: 'GET /references', method: 'GET', path: `${ITEM}/references`, set: META_ITEM_REFERENCES_PARAMS, call: 'findReferencesToMeta', body: {} }, + { door: 'GET /layers', method: 'GET', path: `${ITEM}/layers`, set: META_ITEM_LAYERS_PARAMS, call: 'getMetaItemLayered', body: {} }, + { door: 'GET /audit', method: 'GET', path: `${ITEM}/audit`, set: META_ITEM_AUDIT_PARAMS, call: 'auditMetaItem', body: {} }, + { door: 'GET /diff', method: 'GET', path: `${ITEM}/diff`, set: META_ITEM_DIFF_PARAMS, call: 'diffMetaItem', body: {} }, + { door: 'GET /published', method: 'GET', path: `${ITEM}/published`, set: META_ITEM_PUBLISHED_PARAMS, call: 'getMetaItemLayered', body: {} }, +] as const; + +describe('[#23072] each by-name /meta door outside the ruling\'s list declares a closed query set', () => { + it('the measured sets — none carries the member selector', () => { + expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package']); + expect([...META_ITEM_REFERENCES_PARAMS]).toEqual([]); + expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package']); + expect([...META_ITEM_AUDIT_PARAMS]).toEqual(['limit']); + expect([...META_ITEM_DIFF_PARAMS].sort()).toEqual(['from', 'fromVersion', 'to', 'toVersion']); + expect([...META_ITEM_PUBLISHED_PARAMS]).toEqual([]); + for (const { set } of DOORS) expect(set).not.toContain('member'); + }); + + for (const { door, method, path, set, call, body } of DOORS) { + it(`${door}: REFUSAL — \`?member=\` is a located 400 naming it, and the protocol is never called`, async () => { + const { protocol, drive } = boot(); + const answer = await drive(method, path, { member: 'zh-CN' }, body); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe(unknownQueryParamMessage(['member'], [...set].sort())); + expect(protocol[call], 'the door answered before the protocol').not.toHaveBeenCalled(); + }); + + it(`${door}: REFUSAL — an unrecognised name is refused the same way`, async () => { + const { protocol, drive } = boot(); + const answer = await drive(method, path, { memeber: 'zh-CN' }, body); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe(unknownQueryParamMessage(['memeber'], [...set].sort())); + expect(protocol[call]).not.toHaveBeenCalled(); + }); + } + + it('PUT: the card\'s case — `?member=zh-CN` beside an `en-US` body saves nothing', async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { member: 'zh-CN', mode: 'draft' }, EN); + expect(answer.status).toBe(400); + expect(answer.body?.error?.message).toMatch(/^The "member" query parameter is not supported by this endpoint\./); + expect(answer.body?.error?.message).toContain('Supported parameters: force, mode, package.'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('a door that reads no parameter says so, rather than listing nothing', async () => { + const { drive } = boot(); + for (const path of [`${ITEM}/references`, `${ITEM}/published`]) { + const answer = await drive('GET', path, { member: 'zh-CN' }); + expect(answer.body?.error?.message, path).toMatch(/This endpoint takes no query parameters\.$/); + expect(answer.body?.error?.message, path).not.toContain('Supported parameters'); + } + }); +}); + +describe('[#23072] PRESERVATION — every name each door reads still reaches its protocol method', () => { + it('PUT item: `force`, `package` and `mode` reach `saveMetaItem`', async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { force: 'true', package: 'com.acme.mail', mode: 'draft' }, EN); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.saveMetaItem.mock.calls[0][0]).toEqual({ + type: 'email_template', name: 'acme.welcome', item: EN, writeFace: 'meta-envelope', + mode: 'draft', actor: 'u1', force: true, packageId: 'com.acme.mail', + }); + }); + + it('GET /references: no parameter, the type and the name reach `findReferencesToMeta`', async () => { + const { protocol, drive } = boot(); + const answer = await drive('GET', `${ITEM}/references`, {}); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.findReferencesToMeta.mock.calls[0][0]).toEqual({ type: 'email_template', name: 'acme.welcome' }); + }); + + it('GET /layers: `package` reaches `getMetaItemLayered`', async () => { + const { protocol, drive } = boot(); + const answer = await drive('GET', `${ITEM}/layers`, { package: 'com.acme.mail' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItemLayered.mock.calls[0][0]).toEqual({ + type: 'email_template', name: 'acme.welcome', packageId: 'com.acme.mail', + }); + }); + + it('GET /audit: `limit` reaches `auditMetaItem`', async () => { + const { protocol, drive } = boot(); + const answer = await drive('GET', `${ITEM}/audit`, { limit: '5' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.auditMetaItem.mock.calls[0][0]).toEqual({ type: 'email_template', name: 'acme.welcome', limit: 5 }); + }); + + it('GET /diff: both bounds reach `diffMetaItem`, under either spelling', async () => { + for (const query of [{ from: '1', to: '2' }, { fromVersion: '1', toVersion: '2' }]) { + const { protocol, drive } = boot(); + const answer = await drive('GET', `${ITEM}/diff`, query); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.diffMetaItem.mock.calls[0][0], JSON.stringify(query)).toEqual({ + type: 'email_template', name: 'acme.welcome', fromVersion: 1, toVersion: 2, + }); + } + }); + + it('GET /published: no parameter, the overlay is read and served', async () => { + const { protocol, drive } = boot(); + const answer = await drive('GET', `${ITEM}/published`, {}); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItemLayered.mock.calls[0][0]).toEqual({ type: 'email_template', name: 'acme.welcome' }); + expect(answer.body).toEqual(EN); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d5a5f618619..9a64fac24d3 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -700,6 +700,78 @@ export const META_ITEM_PUBLISH_PARAMS: readonly string[] = ['package', META_ITEM */ export const META_ITEM_ROLLBACK_PARAMS: readonly string[] = ['toVersion', META_ITEM_MEMBER_PARAM]; +// [#23072] The by-name `/meta` doors OUTSIDE ruling 6116165299's list, each +// with the closed set measured from its own read points (AGENTS.md route rule +// 5). None carries {@link META_ITEM_MEMBER_PARAM}: the protocol request behind +// each declares no member selector, so `?member=` is refused with the located +// 400 like any other name the door does not read — never dropped, which +// answered the canonical member (or, on `PUT`, wrote the body's) to a request +// that named another. + +/** + * [#23072] The closed set of `PUT {metaPath}/:type/:name` (the save). + * **Measured** from the handler and the helper it calls: `force` (the + * destructive-change acknowledgement), `package` (the saved row's binding, + * {@link metaItemPackageBinding}) and `mode` — read a frame down, by + * `metaSaveRequestOptions` (`./meta-save-request.ts`), beside the `If-Match` / + * `If-None-Match` headers it reads. The capability, walled-catalog and actor + * gates read the session, not the query. The same three names the door's + * multiplicity guard declares. + * + * ⛔ No member selector: the save is BODY-addressed (ruling 6116165299) — the + * member it writes is named by the body's own discriminator field + * (`email_template`'s `locale`), and `SaveMetaItemRequest` declares no + * selector. A `?member=zh-CN` beside an `en-US` body is refused, not read. + */ +export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mode']; + +/** + * [#23072] The closed set of `GET {metaPath}/:type/:name/references`: EMPTY. + * **Measured**: the handler reads no query parameter, and neither does the + * helper it calls (`resolveProtocol` reads the route, the hostname and the + * `X-Environment-Id` header); `findReferencesToMeta` is asked of the type and + * the name alone. A reference names an item, never one member of a bundle, so + * the reverse sweep has no member to select. + */ +export const META_ITEM_REFERENCES_PARAMS: readonly string[] = []; + +/** + * [#23072] The closed set of `GET {metaPath}/:type/:name/layers`. + * **Measured**: `package`, read a frame down by `serveMetaItemLayered` (the + * layered read's package scope, {@link metaItemPackageBinding}); the masker and + * the read gate read the session. The layered view shows the canonical + * member's layers, as the deprecated `?layers=` spelling's refusal of a member + * on the item read already says. + */ +export const META_ITEM_LAYERS_PARAMS: readonly string[] = ['package']; + +/** + * [#23072] The closed set of `GET {metaPath}/:type/:name/audit`. + * **Measured**: `limit`, read through its declaration + * (`AuditMetaItemRequestSchema.limit`, `readDeclaredQueryNumber`); the + * authoring-door and per-caller refusals read the session. + */ +export const META_ITEM_AUDIT_PARAMS: readonly string[] = ['limit']; + +/** + * [#23072] The closed set of `GET {metaPath}/:type/:name/diff`. + * **Measured**: the two bounds under both spellings the door honours — `from` + * and `to`, and the `fromVersion` / `toVersion` the door falls back to when the + * short name is absent — each read through `readDeclaredQueryNumber`. The same + * four names the door's multiplicity guard declares; the authoring-door and + * per-caller refusals read the session. + */ +export const META_ITEM_DIFF_PARAMS: readonly string[] = ['from', 'fromVersion', 'to', 'toVersion']; + +/** + * [#23072] The closed set of `GET {metaPath}/:type/:name/published`: EMPTY. + * **Measured**: the handler reads no query parameter, and neither do the + * helpers it calls (`resolveObjectMasker`, `resolveProtocol`, + * `resolveMetadataService`, the read gate); the published document is asked of + * the type and the name alone. + */ +export const META_ITEM_PUBLISHED_PARAMS: readonly string[] = []; + /** * [#22877] THE reading of the bundle member selector on every by-name `/meta` * door, as the spread each door's protocol request takes: `{ member }` when @@ -6100,6 +6172,9 @@ export class RestServer { path: `${metaPath}/:type/:name/references`, handler: async (req: any, res: any) => { try { + // [#23072] Recognition first, over the measured closed + // set — empty: the sweep reads no query parameter. + if (refuseUnknownQueryParams(req, res, META_ITEM_REFERENCES_PARAMS)) return; const environmentId = isScoped ? req.params?.environmentId : undefined; const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).findReferencesToMeta !== 'function') { @@ -6207,6 +6282,10 @@ export class RestServer { path: `${metaPath}/:type/:name/layers`, handler: async (req: any, res: any) => { try { + // [#23072] Recognition first, over the measured closed + // set; `serveMetaItemLayered` keeps the multiplicity + // rule it shares with the deprecated `?layers=` spelling. + if (refuseUnknownQueryParams(req, res, META_ITEM_LAYERS_PARAMS)) return; const environmentId = isScoped ? req.params?.environmentId : undefined; const p = await this.resolveProtocol(environmentId, req); // [ADR-0106 D2/D5] The dedicated path is its own @@ -7075,6 +7154,13 @@ export class RestServer { // for why that one is sharp) and UNWRAPS one occurrence // encoded as an array, so `?mode=` reaches the mapping as // the string it reads. + // + // [#23072] …and RECOGNITION before it, over the measured + // closed set: a name this door does not read — `member` + // first among them, the save being body-addressed — is a + // located 400 here, before anything is written, never + // dropped while the body's own member is saved. + if (refuseUnknownQueryParams(req, res, META_ITEM_SAVE_PARAMS)) return; if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return; const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query }); if (!saveOptions.ok) { @@ -7563,6 +7649,8 @@ export class RestServer { } // [#6877] Same `Number(...)` → `NaN` → dropped-limit shape as // the history twin above. + // [#23072] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_AUDIT_PARAMS)) return; if (refuseRepeatedQueryParams(req, res, ['limit'])) return; // [#20139] Parsed through its DECLARATION // (`AuditMetaItemRequestSchema.limit`, `z.number().optional()`), @@ -7991,6 +8079,8 @@ export class RestServer { // [#6877] A repeated `?from=` became `NaN`, the spreads below // omitted the bound, and the door quietly diffed a different // pair of versions and answered 200. + // [#23072] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_DIFF_PARAMS)) return; if (refuseRepeatedQueryParams(req, res, ['from', 'fromVersion', 'to', 'toVersion'])) return; // [#20139] The same drop for a SINGLE unreadable value: the // `parseV` helper that stood here answered `undefined` for @@ -8299,6 +8389,9 @@ export class RestServer { path: `${metaPath}/:type/:name/published`, handler: async (req: any, res: any) => { try { + // [#23072] Recognition first, over the measured closed + // set — empty: this door reads no query parameter. + if (refuseUnknownQueryParams(req, res, META_ITEM_PUBLISHED_PARAMS)) return; const environmentId = isScoped ? req.params?.environmentId : undefined; const type = String(req.params?.type ?? ''); // [commit 7986d973f] No `section` fold: this route has one arity. From 52b332b6d5ef5b750f534f14fd2356c2a137e355 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:49:37 +0000 Subject: [PATCH 2/4] chore(changeset): the six /meta doors' closed query sets Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/23072-meta-doors-closed-sets.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/23072-meta-doors-closed-sets.md diff --git a/.changeset/23072-meta-doors-closed-sets.md b/.changeset/23072-meta-doors-closed-sets.md new file mode 100644 index 00000000000..518ca741a74 --- /dev/null +++ b/.changeset/23072-meta-doors-closed-sets.md @@ -0,0 +1,17 @@ +--- +'@objectstack/rest': patch +--- + +Six by-name `/meta` doors now refuse a query parameter they do not read, with a located `400 VALIDATION_ERROR`, instead of dropping it. Each read the names it knew and ignored the rest, so a request carrying the bundle member selector `?member=` — which `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` accept — was answered for the canonical member with a `200`, and a save wrote the body's member. + +Clause-②: no + +What each door accepts, and what to do when it refuses: + +- `PUT /meta/:type/:name` accepts `force`, `package` and `mode`. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body (an `email_template`'s `locale`) and drop `?member=`. +- `GET /meta/:type/:name/layers` accepts `package`. It shows the canonical member's layers. Read one member with `GET /meta/:type/:name?member=…`. +- `GET /meta/:type/:name/audit` accepts `limit`. The trail is the item's, with every member's attempts in it. Drop `?member=`. +- `GET /meta/:type/:name/diff` accepts `from`, `to`, `fromVersion` and `toVersion`. It compares the canonical member's versions. Read one member's lineage with `GET /meta/:type/:name/history?member=…`. +- `GET /meta/:type/:name/published` and `GET /meta/:type/:name/references` accept no query parameter. Send none. Their refusal ends "This endpoint takes no query parameters." where the other doors list the names they accept. + +The refusal body is the nested envelope the five member doors already answer for a name they do not know, `{ error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter. A request that sends only names the door reads is answered exactly as before. The `@objectstack/client` SDK and the Studio metadata client send only accepted names. From d813364338e8cf0bd674c93bcd2d4c5865f2b594 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:09:15 +0000 Subject: [PATCH 3/4] test(rest): the PUT door's query-smuggled organization id is now a located 400 The census caller: the pin sent an inert ?organizationId= that PUT never read; the door's closed set refuses it, so the pin splits into the body half (still 200, nothing reaches the save) and the query half (400, nothing written). Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../src/meta-item-save-capability-gate.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/meta-item-save-capability-gate.test.ts b/packages/rest/src/meta-item-save-capability-gate.test.ts index 84e77a0ef3c..74987614fe6 100644 --- a/packages/rest/src/meta-item-save-capability-gate.test.ts +++ b/packages/rest/src/meta-item-save-capability-gate.test.ts @@ -367,18 +367,29 @@ describe('ADR-0131 D6 — PUT /meta/:type/:name: an organization admin\'s metada expect('organizationId' in stack.saveMetaItem.mock.calls[0][0]).toBe(false); }); - it('query/body-smuggled organization ids do not reach the write either', async () => { + it('a body-smuggled organization id does not reach the write either', async () => { const stack = bootDoor({ userId: 'u_author', systemPermissions: ['manage_metadata'], tenantId: ORG }); const write = await stack.put( 'view', 'org_grid', { name: 'org_grid', organization_id: 'org_b', organizationId: 'org_b' }, - { organizationId: 'org_b' }, ); expect(write.res.statusCode).toBe(200); // The save request is built field by field from named `req` values. expect('organizationId' in stack.saveMetaItem.mock.calls[0][0]).toBe(false); }); + // [#23072] The query half used to be the same 200 with the name dropped. + // The door now declares its closed set (`force`, `package`, `mode`), so a + // query-smuggled organization id is refused before anything is written. + it('a query-smuggled organization id is refused with a located 400, and nothing is written', async () => { + const stack = bootDoor({ userId: 'u_author', systemPermissions: ['manage_metadata'], tenantId: ORG }); + const write = await stack.put('view', 'org_grid', { name: 'org_grid' }, { organizationId: 'org_b' }); + expect(write.res.statusCode).toBe(400); + expect(write.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(write.body?.error?.message).toMatch(/^The "organizationId" query parameter is not supported by this endpoint\./); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + it('control: `manage_metadata` with no active organization still saves a view env-wide, as before', async () => { const stack = bootDoor({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); const write = await stack.put('view', 'org_grid', { name: 'org_grid' }); From 49994b3335af34873f8698075c393b0b6ae13305 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:05:24 +0000 Subject: [PATCH 4/4] chore(changeset): the six /meta doors' closed sets are a breaking accept-set narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grade `@objectstack/rest` `minor`, carry the `fix(rest)!:` summary line, declare `Clause-②: no (narrowing)`, state the ADR-0087 disposition `not-required (no-migration-prescription)` with its facts, and add the launch-window BREAKING line. The per-door remedies are unchanged. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/23072-meta-doors-closed-sets.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.changeset/23072-meta-doors-closed-sets.md b/.changeset/23072-meta-doors-closed-sets.md index 518ca741a74..cc0a67f5821 100644 --- a/.changeset/23072-meta-doors-closed-sets.md +++ b/.changeset/23072-meta-doors-closed-sets.md @@ -1,10 +1,16 @@ --- -'@objectstack/rest': patch +'@objectstack/rest': minor --- +fix(rest)!: `PUT` and the five by-name `/meta` sub-doors declare closed query sets, so `?member=` is refused with a located `400 VALIDATION_ERROR`, never dropped + Six by-name `/meta` doors now refuse a query parameter they do not read, with a located `400 VALIDATION_ERROR`, instead of dropping it. Each read the names it knew and ignored the rest, so a request carrying the bundle member selector `?member=` — which `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` accept — was answered for the canonical member with a `200`, and a save wrote the body's member. -Clause-②: no +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. What each door accepts, and what to do when it refuses: