Skip to content

Commit 495019b

Browse files
authored
fix(rest): enforce the /meta per-type gates on both spellings of the type segment (#3984) (#3985)
Every per-type filter on GET /meta/:type and GET /meta/:type/:name compared req.params.type to a literal SINGULAR name, while the protocol's getMetaItems normalizes singular↔plural and serves either. PD #3 makes plural the canonical REST spelling, so the form a client is most likely to use — /api/v1/meta/books — reached the handler with every gate skipped. Three of those gates are authorization: the ADR-0046 §6.7 book/doc audience (GET /meta/books returned a { permissionSet }-gated book to a caller who does not hold the set; GET /meta/books/admin_guide answered 200 where the singular spelling answers 401), the app RBAC filter that hides privileged apps and gated nav entries, and the dashboard requiresService gate (ADR-0057 D10). The rest are behavioural — doc i18n locale collapse, list-response content strip — and were inconsistent between spellings for the same reason. Each handler now normalizes the type once via RestServer.metaTypeSingular, backed by the same PLURAL_TO_SINGULAR table the protocol uses, so the two spellings of one route cannot diverge and the next per-type gate cannot forget. Found while scoping #3963.
1 parent e5e8b10 commit 495019b

4 files changed

Lines changed: 201 additions & 13 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): the /meta per-type gates are enforced on both spellings of the type segment (#3984)
6+
7+
Every per-type filter on `GET /meta/:type` and `GET /meta/:type/:name` compared
8+
`req.params.type` to a literal SINGULAR name, while the protocol's `getMetaItems`
9+
normalizes singular↔plural and serves either. Prime Directive #3 makes plural the
10+
canonical REST spelling, so the form a client is most likely to use —
11+
`/api/v1/meta/books` — reached the handler with every gate skipped.
12+
13+
Three of those gates are authorization:
14+
15+
- **ADR-0046 §6.7 book / doc audience** (three sites: the list, the single-item
16+
read, and the doc effective-audience union). `GET /meta/books` returned a
17+
`{ permissionSet }`-gated book — an *Admin Guide* — to a caller who does not
18+
hold the set, and `GET /meta/books/admin_guide` answered `200` where the
19+
singular spelling answers `401`. On a publicly-served deployment the same skip
20+
handed an `org` book to an anonymous reader.
21+
- **App RBAC filter** — hides privileged apps (Studio, Setup) and gated nav
22+
entries from callers without the grants. `GET /meta/apps` skipped it.
23+
- **Dashboard `requiresService` gate** (ADR-0057 D10). `GET /meta/dashboards`
24+
skipped it.
25+
26+
The remaining spelling-sensitive branches are behavioural rather than
27+
authorization — doc i18n locale collapse, and the list-response `content` strip —
28+
and were inconsistent between the two spellings for the same reason.
29+
30+
Each handler now normalizes the type ONCE (`RestServer.metaTypeSingular`, backed
31+
by the same `PLURAL_TO_SINGULAR` table the protocol uses) and every gate keys on
32+
that value, so the two spellings of one route can no longer diverge. Found while
33+
scoping #3963.

content/docs/api/metadata-api.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ List all items of a metadata type.
2323

2424
| Parameter | Location | Description |
2525
|:----------|:---------|:------------|
26-
| `type` | path | Metadata type name (e.g. `object`, `view`) |
26+
| `type` | path | Metadata type name, singular or plural — `object` and `objects` address the same type |
27+
28+
Both spellings are accepted and behave **identically**: the same audience gate
29+
(ADR-0046 §6.7 books/docs), the same RBAC filtering of privileged apps, and the
30+
same response shaping apply either way. Metadata type names are singular by
31+
convention (Prime Directive #3) while REST paths are plural, so both forms exist
32+
in the wild.
2733

2834
**Response**: `{ type: "object", items: [{ name: "account", ... }, ...] }`
2935

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Does the ADR-0046 §6.7 audience gate survive the PLURAL spelling of the type
4+
// segment? The three gates in rest-server key on the exact singular
5+
// (`req.params.type === 'book'` / `=== 'doc'`), while the protocol's
6+
// `getMetaItems` normalizes singular↔plural and serves either. Prime Directive
7+
// #3 makes plural the canonical REST spelling, so `/meta/books` is the form a
8+
// client is most likely to use.
9+
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { RestServer } from './rest-server';
12+
13+
const PUBLIC_BOOK = { name: 'manual', label: 'Manual', audience: 'public', groups: [] };
14+
const GATED_BOOK = { name: 'admin_guide', label: 'Admin Guide', audience: { permissionSet: 'crm_admin' }, groups: [] };
15+
16+
function createMockServer() {
17+
return {
18+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
19+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
20+
};
21+
}
22+
23+
function makeRes() {
24+
const res: any = { statusCode: 200, body: undefined };
25+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
26+
res.json = vi.fn((b: any) => { res.body = b; return res; });
27+
res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn();
28+
return res;
29+
}
30+
31+
function setup() {
32+
const protocol: any = {
33+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' } }),
34+
getMetaTypes: vi.fn().mockResolvedValue([]),
35+
// The real implementation normalizes singular↔plural, so BOTH spellings
36+
// resolve to the same items.
37+
getMetaItems: vi.fn(async ({ type }: any) => {
38+
const t = String(type ?? '');
39+
if (t === 'book' || t === 'books') return [PUBLIC_BOOK, GATED_BOOK];
40+
return [];
41+
}),
42+
getMetaItem: vi.fn(async ({ name }: any) => {
43+
if (name === PUBLIC_BOOK.name) return PUBLIC_BOOK;
44+
if (name === GATED_BOOK.name) return GATED_BOOK;
45+
return {};
46+
}),
47+
findData: vi.fn().mockResolvedValue([]),
48+
};
49+
// requireAuth:false so the anonymous caller reaches the handler at all — the
50+
// meta routes are otherwise wrapped in the anonymous gate. The audience gate
51+
// is what is under test, not the auth gate.
52+
const rest = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false } } as any);
53+
rest.registerRoutes();
54+
return { rest, protocol };
55+
}
56+
57+
async function getType(rest: any, type: string) {
58+
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type');
59+
if (!route) throw new Error('meta/:type route not registered');
60+
const res = makeRes();
61+
await route.handler({ method: 'GET', params: { type }, query: {}, body: {} }, res);
62+
return res;
63+
}
64+
65+
async function getItem(rest: any, type: string, name: string) {
66+
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name');
67+
if (!route) throw new Error('meta/:type/:name route not registered');
68+
const res = makeRes();
69+
await route.handler({ method: 'GET', params: { type, name }, query: {}, body: {} }, res);
70+
return res;
71+
}
72+
73+
const names = (body: any) => {
74+
const list = Array.isArray(body) ? body : (body?.items ?? []);
75+
return list.map((b: any) => b?.name).sort();
76+
};
77+
78+
describe('ADR-0046 §6.7 audience gate vs the plural type segment', () => {
79+
it('singular /meta/book hides the permissionSet-gated book from an anonymous caller', async () => {
80+
const { rest } = setup();
81+
const res = await getType(rest, 'book');
82+
83+
expect(res.statusCode).toBe(200);
84+
expect(names(res.body)).toEqual(['manual']);
85+
});
86+
87+
it('plural /meta/books applies the SAME gate', async () => {
88+
const { rest } = setup();
89+
const res = await getType(rest, 'books');
90+
91+
expect(res.statusCode).toBe(200);
92+
// Before the fix this returned ['admin_guide', 'manual'] — the gated book
93+
// leaked because the filter only fires on the singular spelling.
94+
expect(names(res.body)).toEqual(['manual']);
95+
});
96+
97+
it('the single-item read is gated on both spellings', async () => {
98+
const { rest } = setup();
99+
// A `{ permissionSet }`-gated book is 401 for an anonymous reader —
100+
// whichever way the type segment is spelled.
101+
expect((await getItem(rest, 'book', 'admin_guide')).statusCode).toBe(401);
102+
expect((await getItem(rest, 'books', 'admin_guide')).statusCode).toBe(401);
103+
// …and the public one is readable either way.
104+
expect((await getItem(rest, 'book', 'manual')).statusCode).toBe(200);
105+
expect((await getItem(rest, 'books', 'manual')).statusCode).toBe(200);
106+
});
107+
});
108+
109+
describe('the same spelling sensitivity on the other per-type gates', () => {
110+
// The `/meta/:type` handler runs several per-type filters, and every one of
111+
// them keyed on the literal singular. The app one is an RBAC filter (it hides
112+
// privileged apps like Studio / Setup and gated nav entries), so the plural
113+
// spelling skipped an authorization filter, not just a cosmetic one.
114+
it('the app filter runs on /meta/apps too', async () => {
115+
const { rest, protocol } = setup();
116+
protocol.getMetaItems = vi.fn(async ({ type }: any) => {
117+
const t = String(type ?? '');
118+
return t === 'app' || t === 'apps' ? [{ name: 'crm' }] : [];
119+
});
120+
// The filter needs a resolved context to do RBAC work; with none it must
121+
// still take the same branch for both spellings (observable via the
122+
// identical response rather than a leak, since anonymous resolves nothing).
123+
const singular = await getType(rest, 'app');
124+
const plural = await getType(rest, 'apps');
125+
expect(plural.statusCode).toBe(singular.statusCode);
126+
expect(names(plural.body)).toEqual(names(singular.body));
127+
});
128+
});

packages/rest/src/rest-server.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RouteManager } from './route-manager.js';
1010
import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
1111
import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api';
1212
import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
13+
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
1314
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
1415
import type { ISecurityService } from '@objectstack/spec/contracts';
1516
import {
@@ -1412,6 +1413,25 @@ export class RestServer {
14121413
}
14131414
}
14141415

1416+
/**
1417+
* Canonical SINGULAR form of the `:type` path segment.
1418+
*
1419+
* The metadata routes accept either spelling — the protocol's `getMetaItems`
1420+
* normalizes singular↔plural and serves both — and Prime Directive #3 makes
1421+
* PLURAL the canonical REST spelling (`/api/v1/meta/books`). So every gate
1422+
* keyed on the type must compare against the normalized form. The three
1423+
* ADR-0046 §6.7 audience gates below each tested `req.params.type === 'book'`
1424+
* literally, which meant `GET /meta/books` served the list with the gate
1425+
* never running: a `{ permissionSet }`-gated book (an *Admin Guide*) came
1426+
* back to a caller who does not hold the set, and an `org` book came back to
1427+
* an anonymous reader on a publicly-served deployment. Same route, gate
1428+
* enforced on one spelling of it.
1429+
*/
1430+
private static metaTypeSingular(type: unknown): string {
1431+
const t = typeof type === 'string' ? type : '';
1432+
return PLURAL_TO_SINGULAR[t] ?? t;
1433+
}
1434+
14151435
/** Whether any of these books carries a `{ permissionSet }` audience. */
14161436
private static anyPermissionSetAudience(books: readonly any[]): boolean {
14171437
return books.some(
@@ -2568,7 +2588,7 @@ export class RestServer {
25682588
// objectql implementation actually returns the raw
25692589
// array. Handle both shapes defensively.
25702590
let visible: any = items;
2571-
if (req.params.type === 'app') {
2591+
if (RestServer.metaTypeSingular(req.params.type) === 'app') {
25722592
const raw = items as unknown;
25732593
const list: any[] | null = Array.isArray(raw)
25742594
? (raw as any[])
@@ -2595,7 +2615,7 @@ export class RestServer {
25952615

25962616
// ADR-0057 D10: gate dashboard widgets by `requiresService`
25972617
// the same way app nav entries are gated above.
2598-
if (req.params.type === 'dashboard') {
2618+
if (RestServer.metaTypeSingular(req.params.type) === 'dashboard') {
25992619
const raw = visible as unknown;
26002620
const list: any[] | null = Array.isArray(raw)
26012621
? (raw as any[])
@@ -2623,7 +2643,7 @@ export class RestServer {
26232643
// excluded. Runtime `shared` / `personal` views
26242644
// (sys_view_definition) are merged client-side via the
26252645
// generic data API.
2626-
if (req.params.type === 'view' && req.query?.object) {
2646+
if (RestServer.metaTypeSingular(req.params.type) === 'view' && req.query?.object) {
26272647
const obj = String(req.query.object);
26282648
const raw = visible as unknown;
26292649
const list: any[] | null = Array.isArray(raw)
@@ -2645,7 +2665,7 @@ export class RestServer {
26452665
// callers see only `public` books; `{ permissionSet }`-gated
26462666
// books require the caller to hold the named set (resolved
26472667
// through the security service; unresolvable → fail closed).
2648-
if (req.params.type === 'book') {
2668+
if (RestServer.metaTypeSingular(req.params.type) === 'book') {
26492669
const raw = visible as unknown;
26502670
const list = RestServer.metaItemsArray(raw);
26512671
if (list.length > 0) {
@@ -2664,7 +2684,7 @@ export class RestServer {
26642684
// claim it; unclaimed docs default to `org`). Runs on the
26652685
// raw items (before locale collapse) so `_packageId`
26662686
// provenance is still present for membership scoping.
2667-
if (req.params.type === 'doc') {
2687+
if (RestServer.metaTypeSingular(req.params.type) === 'doc') {
26682688
const raw = visible as unknown;
26692689
const list = RestServer.metaItemsArray(raw);
26702690
if (list.length > 0) {
@@ -2705,7 +2725,7 @@ export class RestServer {
27052725
// ADR-0046 i18n: collapse each doc to the request
27062726
// locale (localized label/description, `translations`
27072727
// map dropped) before the content-strip step below.
2708-
if (req.params.type === 'doc') {
2728+
if (RestServer.metaTypeSingular(req.params.type) === 'doc') {
27092729
const locale = this.extractLocale(req);
27102730
const { resolveDocLocale } = await import('@objectstack/spec/system');
27112731
const raw = visible as unknown;
@@ -2727,7 +2747,7 @@ export class RestServer {
27272747
// name + label. `?include=content` opts back in; the
27282748
// single-item GET /meta/doc/:name always returns the
27292749
// full body.
2730-
if (req.params.type === 'doc' && req.query?.include !== 'content') {
2750+
if (RestServer.metaTypeSingular(req.params.type) === 'doc' && req.query?.include !== 'content') {
27312751
const raw = visible as unknown;
27322752
const list: any[] | null = Array.isArray(raw)
27332753
? (raw as any[])
@@ -2926,7 +2946,7 @@ export class RestServer {
29262946
// viewers of the same app schema. Drafts also
29272947
// bypass cache: the cache is keyed on the
29282948
// published checksum and drafts are out-of-band.
2929-
const isAppType = req.params.type === 'app';
2949+
const isAppType = RestServer.metaTypeSingular(req.params.type) === 'app';
29302950
const isDraftRead = typeof req.query?.state === 'string'
29312951
&& req.query.state.toLowerCase() === 'draft';
29322952
// ADR-0033/0037 — `?preview=draft` overlays a pending
@@ -3043,7 +3063,7 @@ export class RestServer {
30433063
// ADR-0057 D10: gate dashboard widgets by `requiresService`
30443064
// (mirrors the app-nav gate above) so the console never
30453065
// renders a tile bound to an absent optional service.
3046-
if (req.params.type === 'dashboard' && visible) {
3066+
if (RestServer.metaTypeSingular(req.params.type) === 'dashboard' && visible) {
30473067
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
30483068
const registered = await this.resolveRegisteredServices((ctx as any)?.__kernel, [visible]);
30493069
const serviceGate = registered ? (n: string) => registered.has(n) : undefined;
@@ -3056,13 +3076,14 @@ export class RestServer {
30563076
// it, unclaimed → org). 401 for anonymous, 403 for an
30573077
// authenticated non-holder; fail closed when holdings
30583078
// cannot be resolved (ADR-0049).
3059-
if ((req.params.type === 'book' || req.params.type === 'doc') && visible) {
3079+
const audienceGatedType = RestServer.metaTypeSingular(req.params.type);
3080+
if ((audienceGatedType === 'book' || audienceGatedType === 'doc') && visible) {
30603081
const { audienceAllows, docAudienceAllows, resolveDocAudiences } =
30613082
await import('@objectstack/spec/system');
30623083
const target = isMetaEnvelope(visible) ? (visible as any).item : visible;
30633084
let caller: { authenticated: boolean; permissionSets?: string[] };
30643085
let allowed: boolean;
3065-
if (req.params.type === 'book') {
3086+
if (audienceGatedType === 'book') {
30663087
caller = await this.resolveAudienceCaller(environmentId, req, {
30673088
needPermissionSets: RestServer.anyPermissionSetAudience([target]),
30683089
});
@@ -3104,7 +3125,7 @@ export class RestServer {
31043125
// ADR-0046 i18n: collapse the doc to the request
31053126
// locale (label/description/content) and drop the
31063127
// `translations` map so consumers get one body.
3107-
if (req.params.type === 'doc' && visible) {
3128+
if (audienceGatedType === 'doc' && visible) {
31083129
const locale = this.extractLocale(req);
31093130
const { resolveDocLocale } = await import('@objectstack/spec/system');
31103131
visible = isMetaEnvelope(visible)

0 commit comments

Comments
 (0)