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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/23072-meta-doors-closed-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@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 (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, package export or stored shape is added, removed, renamed or re-shaped. The six closed sets are module constants that `@objectstack/rest`'s entry does not re-export, and no stored row or package source changes, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is six runtime doors' query accept sets (`PUT /meta/:type/:name`, `/references`, `/layers`, `/audit`, `/diff`, `/published`): a query parameter those doors dropped now answers a located `400 VALIDATION_ERROR`, and the remedy is in the request a caller sends to that door, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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:

- `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.
15 changes: 13 additions & 2 deletions packages/rest/src/meta-item-save-capability-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
10 changes: 9 additions & 1 deletion packages/rest/src/query-allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down
219 changes: 219 additions & 0 deletions packages/rest/src/rest-server-meta-door-closed-sets.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
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: '<p>en</p>' };

/** 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<string, unknown>, 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);
});
});
Loading
Loading