Skip to content

Commit 6cb88d9

Browse files
os-zhuangclaude
andauthored
fix(rest): GET /api/v1/meta/:type refuses a type name that names nothing, instead of serving it as an empty collection (#9488) (#9743)
* fix(rest): refuse an unknown metadata type on the /meta list door (#9488) `GET /api/v1/meta/totally_invented_type` answered `200 {"items":[]}` while the write door for the same name answered `400 '<type>' is not a metadata type`, so the two doors disagreed about which type names exist. A 200-with-an-empty collection is indistinguishable from "this type exists and holds nothing". The read door now consults the UNION of the two authorities the platform already has — the static spelling contract `unrecognisedMetaTypeRefusal` (the predicate the write door consults) and the live listing `getMetaTypes` (the one `GET /meta/types` serves) — and refuses a name in neither, with the same status and the same code the write door emits. The live half is required, not decorative: #8421 rejected raising the static verdict on the read entries because live-only keys (`data`, `kind`, `package`, `policy`) and plugin types are enumerated by `GET /meta/types` while sitting outside the static contract. It fails open when the listing cannot be read. Co-Authored-By: Claude <noreply@anthropic.com> * test(rest): the i18n plural double advertises the types it serves (#9488) `meta-plural-i18n.test.ts` used `widget` precisely because it is outside every spelling map, while its `getMetaTypes` double advertised an empty type set and its `getMetaItems` double served a document for it anyway. With the list door now refusing a `:type` in neither the static contract nor the live listing, that inconsistency answered 400 before the localization under test ever ran. The listing is derived from `RAW_BY_NAME` — the same source the item double reads — and carries both spellings, because the item double answers both. No assertion in the file changes. Plus the changeset for the behaviour change. Co-Authored-By: Claude <noreply@anthropic.com> * test(dogfood): restate the /meta catch-all contrast on the new refusal (#9488) `meta-types-create-seed.dogfood.test.ts` pinned the defect literally: an unknown type answering `200 {type, items: []}`, used as the contrast that proves `/meta/types` is not the `/meta/:type` catch-all wearing a 200. The catch-all now refuses that name, which makes the contrast sharper rather than weaker, so the test's own claim is unchanged and only the catch-all's half is restated — asserted with `code` AND `status`, so it cannot pass on a 404 that would mean the catch-all had stopped being mounted at all. Its sibling `route-ledger-live-mount-parity.dogfood.test.ts` asserts ROUTING for the same name and is untouched: the request still reaches `/meta/:type`. Co-Authored-By: Claude <noreply@anthropic.com> * test(rest): spell the pin's local import with its .js extension (#9488) Under `moduleResolution: nodenext` a relative import without the extension does not resolve, so the new pin added one TS2835 to `@objectstack/rest`'s TEST_DEBT entry (155 -> 156) and `check:type-check-debt --re-measure` refused it. Fixed by fixing the error, which is the author's remedy — the ledger is shrink-only and raising it is maintainer-only. 92 local imports in this package's tests already carry the extension. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent da666d9 commit 6cb88d9

5 files changed

Lines changed: 472 additions & 7 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `GET /api/v1/meta/:type` refuses a type name that names nothing, instead of serving it as an empty collection (#9488)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
8+
renamed, retired or tombstoned, and no `packages/spec` declaration moves. This
9+
narrows the accept-set of one READ route over type-name segments that were
10+
already refused by the WRITE door for the same name, so there is no stored
11+
configuration for a migration to prescribe a rewrite of. -->
12+
13+
```
14+
GET /api/v1/meta/totally_invented_type → 200 {"type":"totally_invented_type","items":[]}
15+
PUT /api/v1/meta/totally_invented_type/x → 400 "'totally_invented_type' is not a metadata type"
16+
```
17+
18+
The two doors disagreed about which type names exist. A
19+
200-with-an-empty-collection is **indistinguishable from "this type exists and
20+
holds nothing"**, so a typo'd or renamed type name read as an empty surface
21+
rather than as a mistake — the same trap `GET /meta/app?id=<unknown>` was
22+
already filed for, where the answer read to a runner as "the app metadata is
23+
gone".
24+
25+
The list door now answers **`400` / `INVALID_REQUEST`**, naming the type: the
26+
same status and the same code the write door has emitted since `PUT /meta//x`
27+
was closed, so one condition has one answer on both doors. `INVALID_REQUEST` is
28+
already registered to `@objectstack/rest` in the ADR-0112 `ERROR_CODE_LEDGER`;
29+
no code is minted. The refusal is thrown rather than hand-built, so its wire
30+
body is byte-identical to the write door's for the same condition.
31+
32+
**What still answers `200` with an empty collection**, because a type that
33+
exists and has no items is the legitimate case the defect was indistinguishable
34+
from — breaking it would be worse than the bug:
35+
36+
- every member of the static spelling contract (`sharing_rule`, `theme`,
37+
`objects`, `api`, …), whether or not the deployment holds one item of it;
38+
- the live-only keys an ordinary `registerApp` produces — `data`, `kind`,
39+
`package`, `policy` — which sit outside the static contract but are
40+
enumerated by `GET /api/v1/meta/types`;
41+
- a plugin's own type, which enters the live set as a side effect of
42+
registering items of it.
43+
44+
That is why the rule is the **union** of the two authorities the platform
45+
already has — the static predicate the write door consults, and the live
46+
listing `GET /meta/types` serves — rather than the static predicate alone.
47+
Refusing on the static half alone would answer `400` for types this same
48+
service advertises, which is the objection recorded when the write-side verdict
49+
landed and was deliberately not raised on the read entries then. Neither list
50+
is restated here; both are read from their producers.
51+
52+
The static verdict runs first and is silent for every accepted spelling, so an
53+
ordinary list request pays nothing; the live listing is consulted only by a
54+
request already headed for a refusal. If that listing cannot be read — no
55+
`getMetaTypes` on the host's protocol, or a rejecting call — the route **fails
56+
open** and keeps its prior answer: "no such type" is an existence claim, and
57+
stating it while the authority that would know is unreachable is the mistake
58+
the write door's own store probe avoids.
59+
60+
**Scope.** The list door only. The compound arity `/meta/lead/views/all_leads`
61+
carries an *object* name in the `:type` segment, which no static contract can
62+
enumerate, and is untouched. The single-item doors already refuse
63+
distinguishably (`404 RESOURCE_NOT_FOUND`, or `501 NOT_IMPLEMENTED` on the
64+
`/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published` limbs), so
65+
none of them carried this defect.

‎packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,27 @@ describe('dogfood: /meta/types exposes authoritative create seeds (spec-derived
8686
expect(await viaTypes.json()).toEqual(await viaBase.json());
8787
}, 30_000);
8888

89-
// The disguise, pinned: an unknown type answers the catch-all's shape, and
90-
// `/meta/types` must NOT look like that. Without this, a future registration
91-
// that puts `/meta/types` back under `/meta/:type` would still return 200 and
89+
// The disguise, pinned: `/meta/types` must NOT be the `/meta/:type` catch-all
90+
// answering under a different name. Without this, a future registration that
91+
// puts `/meta/types` back under `/meta/:type` would still return 200 and
9292
// every assertion above would fail with a confusing "entries is empty".
93+
//
94+
// [#9488] The contrast used to be `200 {type, items: []}` vs `200 {entries}`.
95+
// The catch-all now REFUSES a type name that names nothing — it used to serve
96+
// it as a real-but-empty collection, which was indistinguishable from a type
97+
// that exists and holds nothing, and disagreed with the write door for the
98+
// same name. That makes this contrast sharper rather than weaker, so the
99+
// test's own claim is unchanged and only the catch-all's half is restated.
100+
//
101+
// ADR-0112: `code` AND `status`. A bare "not 200" would pass on any refusal
102+
// at all, including the 404 that would mean the catch-all had stopped being
103+
// mounted — which is the failure this file exists to notice.
93104
it('is not the /meta/:type catch-all wearing a 200', async () => {
94105
const bogus = await stack.apiAs(token, 'GET', '/meta/zzz_not_a_type');
95-
expect(bogus.status).toBe(200);
96-
expect(await bogus.json()).toEqual({ type: 'zzz_not_a_type', items: [] });
106+
expect(bogus.status).toBe(400);
107+
const bogusBody = (await bogus.json()) as Record<string, any>;
108+
expect(bogusBody.code ?? bogusBody.error?.code).toBe('INVALID_REQUEST');
109+
expect(JSON.stringify(bogusBody)).toContain("'zzz_not_a_type' is not a metadata type");
97110

98111
const real = await stack.apiAs(token, 'GET', '/meta/types');
99112
const body = (await real.json()) as Record<string, unknown>;

‎packages/rest/src/meta-plural-i18n.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,23 @@ function baseProtocol(overrides: Record<string, any> = {}) {
129129
version: 'v0',
130130
routes: { data: '', metadata: '', ui: '', auth: '/auth' },
131131
}),
132-
getMetaTypes: vi.fn().mockResolvedValue([]),
132+
// [#9488] The listing must advertise what this double SERVES. The
133+
// list route now refuses a `:type` that neither the static spelling
134+
// contract nor this listing carries, and `widget` is in neither by
135+
// construction — it is chosen precisely because it is outside every
136+
// map. A double that served a document for it while advertising an
137+
// empty type set would be refused before the localization under test
138+
// ever ran, and the failure would read as an i18n regression.
139+
//
140+
// BOTH spellings, because `getMetaItems` below answers both (that is
141+
// what `canonicalType` is for) and a listing narrower than the double's
142+
// own reach would be the same inconsistency one fold further along.
143+
// Derived from `RAW_BY_NAME` rather than restated, so a document added
144+
// there arrives advertised.
145+
getMetaTypes: vi.fn(async () => ({
146+
types: Object.keys(RAW_BY_NAME).flatMap((t) => [t, `${t}s`]),
147+
entries: [],
148+
})),
133149
getMetaItems: vi.fn(async ({ type }: any) => {
134150
const doc = documentFor(type);
135151
return doc ? [doc] : [];
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#9488] `GET /api/v1/meta/:type` and `PUT /api/v1/meta/:type/:name` agree
4+
// about which type names exist.
5+
//
6+
// The defect: `GET /api/v1/meta/totally_invented_type` answered
7+
// `200 {"items":[]}` while the write door for the SAME name answered
8+
// `400 '<type>' is not a metadata type`. A 200-with-an-empty-collection is
9+
// indistinguishable from "this type exists and holds nothing", so a typo'd or
10+
// renamed type read as an empty surface rather than as a mistake — the same
11+
// shape `GET /meta/app?id=<unknown>` was already filed for.
12+
//
13+
// ## What this file pins, and why it is BOTH directions
14+
//
15+
// The refusal is only correct if it is narrow. The legitimate case it must not
16+
// swallow — a type that EXISTS and has no items — is the very case the defect
17+
// was indistinguishable from, so widening the refusal onto it would be a worse
18+
// regression than the bug. Three populations therefore have to keep answering
19+
// `200` with an empty collection, and each is here for a different reason:
20+
//
21+
// * types in the static spelling contract (`sharing_rule`, `theme`,
22+
// `objects`, `api`) — declared, addressable, frequently empty;
23+
// * live-only keys an ordinary `registerApp` produces (`data`, `kind`,
24+
// `package`, `policy`) — outside the static contract but ENUMERATED by
25+
// `GET /meta/types`, which is precisely why #8421 refused to raise the
26+
// static verdict on the read entries;
27+
// * a plugin's own type, which enters the live set as a side effect of
28+
// registering items of it (`adding-a-metadata-type.mdx`).
29+
//
30+
// ## ADR-0112 — `code` AND `status`, never a bare "it refused"
31+
//
32+
// Every refusal assertion below reads the wire `status` and the wire `code`
33+
// together. A bare `toThrow()`/"is not 200" cannot separate "answered with the
34+
// wrong body" from "did not refuse at all", and the WRONG BODY is this defect:
35+
// the pre-fix answer was a perfectly well-formed 200.
36+
37+
import { describe, it, expect, vi } from 'vitest';
38+
import { RestServer } from './rest-server.js';
39+
40+
const LIST = '/api/v1/meta/:type';
41+
const ITEM = '/api/v1/meta/:type/:name';
42+
43+
/** A name in neither the static spelling contract nor any live registry. */
44+
const INVENTED = 'totally_invented_type';
45+
46+
/**
47+
* The live listing a stock deployment serves at `GET /meta/types`: the four
48+
* live-only keys `registerApp` produces, plus one plugin-registered type.
49+
* Deliberately NOT a copy of the static contract — the union under test is
50+
* only observable when the two halves differ.
51+
*/
52+
const LIVE_TYPES = ['object', 'app', 'view', 'api', 'data', 'kind', 'package', 'policy', 'my_widget'];
53+
54+
function createMockServer() {
55+
return {
56+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
57+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
58+
};
59+
}
60+
61+
function makeRes() {
62+
const res: any = { statusCode: 200, body: undefined };
63+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
64+
res.json = vi.fn((b: any) => { res.body = b; return res; });
65+
res.header = vi.fn(() => res);
66+
res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn();
67+
return res;
68+
}
69+
70+
/**
71+
* The refusal `@objectstack/metadata-protocol`'s `refuseUnmintableMetaType`
72+
* throws, stated literally rather than reconstructed through a real engine —
73+
* this file is about what the REST layer does with it, and the producer's own
74+
* end is pinned next door in `protocol.unrecognised-meta-type.test.ts`.
75+
*/
76+
function unmintable(type: string) {
77+
return Object.assign(
78+
new Error(`[invalid_request] '${type}' is not a metadata type. The platform declares no such type.`),
79+
{ code: 'INVALID_REQUEST', status: 400 },
80+
);
81+
}
82+
83+
function setup(overrides: Record<string, unknown> = {}) {
84+
const protocol: any = {
85+
getDiscovery: vi.fn().mockResolvedValue({
86+
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
87+
}),
88+
getMetaTypes: vi.fn().mockResolvedValue({ types: [...LIVE_TYPES], entries: [] }),
89+
// Every type in this file is legitimately EMPTY. That is the point: the
90+
// refusal must come from the type NAME, never from the item count.
91+
getMetaItems: vi.fn().mockResolvedValue([]),
92+
getMetaItem: vi.fn().mockResolvedValue({}),
93+
saveMetaItem: vi.fn(async (r: any) => { throw unmintable(r.type); }),
94+
findData: vi.fn().mockResolvedValue([]),
95+
...overrides,
96+
};
97+
const rest = new RestServer(
98+
createMockServer() as any,
99+
protocol,
100+
{ api: { requireAuth: false } } as any,
101+
);
102+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['manage_metadata'] });
103+
rest.registerRoutes();
104+
return { rest, protocol };
105+
}
106+
107+
async function call(rest: any, method: string, path: string, params: any, query: any = {}, body?: any) {
108+
const res = makeRes();
109+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
110+
if (!route) throw new Error(`${method} ${path} route not registered`);
111+
await route.handler({ method, params, query, headers: {}, body }, res);
112+
return res;
113+
}
114+
115+
const listType = (rest: any, type: string, query: any = {}) => call(rest, 'GET', LIST, { type }, query);
116+
117+
describe('[#9488] the /meta LIST door refuses a type name that names nothing', () => {
118+
it('answers 400 INVALID_REQUEST and names the type, instead of an empty collection', async () => {
119+
const { rest } = setup();
120+
121+
const res = await listType(rest, INVENTED);
122+
123+
// ADR-0112: code AND status together. `200 {"items":[]}` — the defect —
124+
// fails the status assertion; a refusal carrying some other code fails
125+
// the code assertion. Neither can pass by accident.
126+
expect(res.statusCode).toBe(400);
127+
expect(res.body?.code).toBe('INVALID_REQUEST');
128+
expect(String(res.body?.error)).toContain(`'${INVENTED}' is not a metadata type`);
129+
});
130+
131+
it('does not ask the protocol to list a type it has refused', async () => {
132+
const { rest, protocol } = setup();
133+
134+
await listType(rest, INVENTED);
135+
136+
expect(protocol.getMetaItems).not.toHaveBeenCalled();
137+
});
138+
139+
it('refuses on `?preview=draft` too — the drafts overlay is the same door', async () => {
140+
const { rest } = setup();
141+
142+
const res = await listType(rest, INVENTED, { preview: 'draft' });
143+
144+
expect(res.statusCode).toBe(400);
145+
expect(res.body?.code).toBe('INVALID_REQUEST');
146+
});
147+
});
148+
149+
describe('[#9488] the read door and the write door agree on one invented name', () => {
150+
it('same status and same code from GET /meta/:type and PUT /meta/:type/:name', async () => {
151+
const { rest } = setup();
152+
153+
const read = await listType(rest, INVENTED);
154+
const write = await call(rest, 'PUT', ITEM, { type: INVENTED, name: 'x' }, {}, { item: { name: 'x' } });
155+
156+
// The agreement is the assertion — stated as an equality rather than as
157+
// two independent literals, so a future change that moves ONE door
158+
// fails here even if the moved value is itself defensible.
159+
expect(read.statusCode).toBe(write.statusCode);
160+
expect(read.body?.code).toBe(write.body?.code);
161+
// …and the value both doors settled on, so the pair cannot agree by
162+
// both regressing to 200 or to some unregistered code.
163+
expect(write.statusCode).toBe(400);
164+
expect(write.body?.code).toBe('INVALID_REQUEST');
165+
});
166+
});
167+
168+
describe('[#9488] a type that EXISTS and has no items still answers 200 with an empty collection', () => {
169+
// The static spelling contract's own members — declared and addressable,
170+
// whether or not this deployment holds a single item of them.
171+
it.each(['sharing_rule', 'sharingRules', 'theme', 'themes', 'objects', 'object', 'api', 'external_catalogs'])(
172+
'declared type %s', async (type) => {
173+
const { rest } = setup();
174+
175+
const res = await listType(rest, type);
176+
177+
expect(res.statusCode).toBe(200);
178+
expect(res.body).toEqual([]);
179+
},
180+
);
181+
182+
// The population #8421 named when it REFUSED to raise the static verdict on
183+
// the read entries: live `SchemaRegistry` keys an ordinary `registerApp`
184+
// produces, which `GET /meta/types` enumerates. Refusing these would answer
185+
// 400 for types this same service advertises.
186+
it.each(['data', 'kind', 'package', 'policy'])(
187+
'live-only type %s, which GET /meta/types advertises', async (type) => {
188+
const { rest } = setup();
189+
190+
const res = await listType(rest, type);
191+
192+
expect(res.statusCode).toBe(200);
193+
expect(res.body).toEqual([]);
194+
},
195+
);
196+
197+
it('a plugin type that entered the live set by registering items', async () => {
198+
const { rest } = setup();
199+
200+
const res = await listType(rest, 'my_widget');
201+
202+
expect(res.statusCode).toBe(200);
203+
expect(res.body).toEqual([]);
204+
});
205+
});
206+
207+
describe('[#9488] the refusal is never invented from an unreadable authority', () => {
208+
it('a rejecting getMetaTypes leaves the answer where it was — no refusal', async () => {
209+
const { rest } = setup({
210+
getMetaTypes: vi.fn().mockRejectedValue(
211+
Object.assign(new Error('metadata registry unreachable'), {
212+
code: 'SERVICE_UNAVAILABLE', status: 503,
213+
}),
214+
),
215+
});
216+
217+
const res = await listType(rest, INVENTED);
218+
219+
// "No such type" is an existence claim; stating it while the authority
220+
// that would know is unreachable is the mistake `metaTypeNamespaceExists`
221+
// avoids on the write door. Fail OPEN, exactly as before this change.
222+
expect(res.statusCode).toBe(200);
223+
expect(res.body).toEqual([]);
224+
});
225+
226+
it('a host whose protocol carries no getMetaTypes keeps its prior answer', async () => {
227+
const { rest } = setup({ getMetaTypes: undefined });
228+
229+
const res = await listType(rest, INVENTED);
230+
231+
expect(res.statusCode).toBe(200);
232+
expect(res.body).toEqual([]);
233+
});
234+
});

0 commit comments

Comments
 (0)