@@ -10,6 +10,7 @@ import { RouteManager } from './route-manager.js';
1010import { RestServerConfig , RestApiConfig , CrudEndpointsConfig , MetadataEndpointsConfig , BatchEndpointsConfig , RouteGenerationConfig } from '@objectstack/spec/api' ;
1111import { DataProtocol , MetadataProtocol } from '@objectstack/spec/api' ;
1212import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security' ;
13+ import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared' ;
1314import type { DroppedFieldsEvent } from '@objectstack/spec/data' ;
1415import type { ISecurityService } from '@objectstack/spec/contracts' ;
1516import {
@@ -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