diff --git a/.changeset/rest-api-config-parsed-not-cast.md b/.changeset/rest-api-config-parsed-not-cast.md new file mode 100644 index 0000000000..cd31dd81e4 --- /dev/null +++ b/.changeset/rest-api-config-parsed-not-cast.md @@ -0,0 +1,90 @@ +--- +'@objectstack/rest': minor +--- + +**BREAKING (accept-set tightening)**: `RestServer` now parses `config.api` +against `RestApiConfigSchema` at construction instead of casting to it, so a +deployment whose `api` config the spec rejects fails loudly at boot rather than +booting into a structurally broken URL space (#11637). + +The regex was always declared. `packages/spec/src/api/rest-server.zod.ts` +constrains `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1')`, and +`version` is spliced into `getApiBasePath()` — the base of **every** route this +server mounts. Nothing ran it: both hops into `@objectstack/rest` are casts +(`config.api as any` in `rest-api-plugin.ts`, then `as Partial` +in `normalizeConfig`), the plugin declares no `configSchema`, and the kernel's +`PluginConfigValidator` could not have covered it either — `PluginLoader` +invokes its own `validatePluginConfig(metadata)` with **no config argument** and +returns early, and `createRestApiPlugin` closes over its config so the kernel +never receives it. `??` was the only guard left, and `??` substitutes +`null`/`undefined` only. Measured on the pre-fix code: `api.version: ''` +constructed happily and mounted the whole API — `/data`, `/meta`, `/discovery`, +`openapi.json` — under `/api//`. + +**Newly refused, all at `new RestServer(...)` / `createRestApiPlugin().start()`:** + +- `api.version: ''` — the reported case. Refused with + `Invalid string: must match pattern /^[a-zA-Z0-9_\-\.]+$/`. +- `api.version` carrying any character outside `[a-zA-Z0-9_-.]` — `'v1/beta'` + (which spliced an extra path segment into every route), `'v1 beta'`, `'v1%2F'` + and so on. +- A declared key written with the wrong type: `api.enableCrud: 'yes'`, + `api.basePath: 42`, a malformed `api.documentation` / `api.responseFormat`. + +**Deliberately NOT refused** — the narrowing is exactly what the schema +declares, and no more: + +- `api.projectResolution`. The declared enum is + `z.enum(['required', 'optional', 'auto'])`, but the value this platform + actually ships is **`'none'`**: `@objectstack/runtime`'s + `StandaloneStackResult.api` declares the literal type + `{ enableProjectScoping: false; projectResolution: 'none' }`, and `os serve` + forwards it into this config unchanged (`apiConfig.projectResolution ?? 'auto'` + does not fire — `'none'` is not nullish). Three packages disagree about this + key's vocabulary and have done so silently for exactly as long as nothing ran + the schema. Parsing it here would not settle that disagreement, it would turn + every `os serve` boot into a crash, so the key is `.omit()`ed and the + divergence is filed as #11999. Which spelling wins is a contract question + about project-scoping semantics that this seam cannot answer. +- `api.requireAuth`. The retired key (#3963) is `.omit()`ed from the + validation: it keeps the warn-and-ignore posture `rest-api-plugin.ts` gives + it, and `tsc` still refuses it at any typed authoring site. Converting that + warn into a boot failure is #3963's decision to make, not this seam's. +- Keys no schema in `packages/spec` declares, `api.enableSearch` first among + them. The parse is run for its verdict only and its output is **discarded** — + `RestApiConfigSchema` is not `.strict()`, so a non-strict `z.object()` strips + what it does not declare, and consuming the parsed value would have silently + turned search back on for a deployment that turned it off. +- `api.basePath: ''` — a bare `z.string()` with no declared constraint stays + accepted. +- `crud`, `metadata`, `batch` and `routes`. Those sub-objects are still cast, + not parsed, and carry unenforced constraints of their own + (`batch.maxBatchSize: z.number().int().min(1).max(1000)`, the + `routes.nameTransform` enum). Same defect class, filed separately — this + change deliberately puts one narrowing in front of contract review, not five. + +**Migration.** Delete or correct the offending key; the refusal names the path, +the declared rule that rejected it, and why an empty version is not survivable. +A deployment that meant "no version segment" wants `api.apiPath: '/api'`, which +sets the base outright and is unconstrained. + +**In-repo blast radius, measured repo-wide.** The census is mechanical, not a +reading: 173 files scanned, 316 `api: { … }` blocks brace-matched, every scalar +literal written at each of the 14 declared keys parsed against the schema this +seam runs. **Two values are refused, and both are the deliberate `''` cases in +this change's own pin file.** Every other in-repo literal is accepted, including +all four `projectResolution` spellings in use and the 96 fixtures carrying the +retired `api.requireAuth`. Nested `documentation` / `responseFormat` literals +exist only in `packages/spec`'s own schema tests, which never construct a +server. Of the 237 construction sites repo-wide, exactly one feeds computed +values (`os serve`), traced to the typed literal in `@objectstack/runtime` +above. One in-repo pin had to be retired: `rest-openapi-route.test.ts`'s falsy +`api.version` case, which carried its own written instruction to retire if +normalization ever started rejecting it, replaced here by a pin on the refusal. + +⚠️ The first version of this census was scoped to `packages/rest` and missed the +five `packages/cli` e2e boots that go through `os serve`; CI caught it. The +radius that matters is every package that CONSTRUCTS a REST server, not the +package the change lives in. + + diff --git a/packages/rest/src/rest-config-parse-not-cast.test.ts b/packages/rest/src/rest-config-parse-not-cast.test.ts new file mode 100644 index 0000000000..8332b21a77 --- /dev/null +++ b/packages/rest/src/rest-config-parse-not-cast.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11637] The REST server runs the config contract it declares. + * + * `RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) constrains + * `api.version` with `z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which refuses + * `''`. Nothing ever ran it. Both hops into this package were casts — + * `config.api as any` in `rest-api-plugin.ts`, then `as Partial` + * in `RestServer.normalizeConfig` — the plugin declares no `configSchema`, and + * the kernel's `PluginConfigValidator` could not have covered it anyway + * (`PluginLoader.loadPlugin` calls its own `validatePluginConfig(metadata)` + * with NO config argument and returns early, and `createRestApiPlugin` closes + * over its config so the kernel never receives it). `??` was the only guard + * left, and `??` substitutes `null`/`undefined` only. + * + * Measured on the pre-fix code: `api.version: ''` constructed happily and + * `getApiBasePath()` returned `/api/`, so the whole surface — `/data`, `/meta`, + * `/discovery`, `openapi.json` — mounted under a doubled slash, and + * `'v1/beta'` spliced an extra path segment into every route. + * + * ⛔ ANTI-VACUITY. A pin asserting that *the schema* refuses `''` would be + * green before this change too — the schema always refused it. Every pin below + * drives the REAL server construction (and, in §B, the real plugin + * composition), so what it measures is whether the SERVER refuses. + * + * §C is explicitly a set of REGRESSION GUARDS: green before this change and + * green after. They are here to bound the narrowing — to show it refuses + * exactly what the schema declares and nothing this seam invented. + * + * ⚠️ §C is also where the first round's census miss is pinned. That census was + * scoped to this package; the risk surface is EVERY package that constructs a + * REST server, and `packages/cli`'s `os serve` ships + * `projectResolution: 'none'` — a value the declared enum does not contain and + * `@objectstack/runtime` declares as a literal type. Five `packages/cli` e2e + * boots went red in CI. The lesson, written where the next author will hit it: + * the search radius belongs where the CONSUMERS live, not where the change does. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; +import { createRestApiPlugin } from './rest-api-plugin.js'; + +function makeServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), + } as any; +} + +function makeProtocol() { + return { + getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })), + } as any; +} + +/** Construct the real server with `api` as given — the seam under test. */ +function construct(api: Record) { + return new RestServer(makeServer(), makeProtocol(), { api } as any); +} + +// --------------------------------------------------------------------------- +// §A — the server refuses what the schema declares invalid +// --------------------------------------------------------------------------- + +describe('[#11637] §A RestServer construction runs RestApiConfigSchema', () => { + it('refuses `api.version: ""` — the value that mounted the whole API at /api//', () => { + expect(() => construct({ version: '' })).toThrow(/api\.version/); + }); + + it('names the declared contract and the pattern it failed, not a bare "invalid"', () => { + let message = ''; + try { + construct({ version: '' }); + } catch (err: any) { + message = String(err?.message ?? err); + } + // The prescription is the payload: an operator reading this must be + // able to find the rule without reading our source. + expect(message, 'the refusal must name the schema that declares the rule').toContain('RestApiConfigSchema'); + expect(message, "zod's own issue message carries the declared pattern").toContain('must match pattern'); + expect(message, 'and it must say why an empty version is not survivable').toContain('/api//'); + }); + + it('refuses `api.version: "v1/beta"` — a version that splices a path segment into every route', () => { + expect(() => construct({ version: 'v1/beta' })).toThrow(/api\.version/); + }); + + it('refuses a version carrying whitespace', () => { + expect(() => construct({ version: 'v1 beta' })).toThrow(/api\.version/); + }); + + it('refuses a declared key written with the wrong type', () => { + expect(() => construct({ enableCrud: 'yes' as never })).toThrow(/api\.enableCrud/); + }); + + it('appends the version rationale ONLY when `version` is what failed', () => { + // Caught by this change's own ablation: a `projectResolution` refusal + // printed the whole "an empty version mounts the entire API at /api//" + // paragraph, sending the operator to a line they never wrote. + let message = ''; + try { + construct({ enableCrud: 'yes' as never }); + } catch (err: any) { + message = String(err?.message ?? err); + } + expect(message).toContain('api.enableCrud'); + expect( + message, + 'a non-version refusal must not diagnose a key the operator did not write', + ).not.toContain('/api//'); + }); + + it('no constructed server can carry a doubled slash in its mount', () => { + // The defect stated as the operator sees it. Pre-fix this server + // existed and `getApiBasePath()` answered `/api/`; now the + // construction itself is refused, so the broken mount is unreachable. + expect(() => construct({ version: '' })).toThrow(); + const ok = construct({ version: 'v1' }); + expect(ok.getApiBasePath()).toBe('/api/v1'); + expect(ok.getApiBasePath()).not.toContain('//'); + }); +}); + +// --------------------------------------------------------------------------- +// §B — the real plugin composition, i.e. BOTH cast hops +// --------------------------------------------------------------------------- + +function createCtx(services: Record) { + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name in services) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + } as any; +} + +function bootCtx() { + return createCtx({ 'http.server': makeServer(), protocol: makeProtocol() }); +} + +describe('[#11637] §B the refusal survives the plugin path', () => { + it('CONTROL: this ctx really does boot a REST server', async () => { + // Not decoration. `createRestApiPlugin.start()` returns quietly when + // `http.server` or `protocol` is missing, so a rejection below could + // otherwise be attributed to a thin ctx rather than to the config. + // This case is what makes the next one attributable. + const ctx = bootCtx(); + await expect(createRestApiPlugin({ api: { api: { version: 'v1' } } as any }).start!(ctx)).resolves.toBeUndefined(); + expect(ctx.logger.error).not.toHaveBeenCalled(); + }); + + it('rejects `createRestApiPlugin({ api: { api: { version: "" } } }).start()`', async () => { + const ctx = bootCtx(); + await expect( + createRestApiPlugin({ api: { api: { version: '' } } as any }).start!(ctx), + ).rejects.toThrow(/api\.version/); + }); +}); + +// --------------------------------------------------------------------------- +// §C — REGRESSION GUARDS (green BEFORE this change and after) +// --------------------------------------------------------------------------- + +describe('[#11637] §C regression guards — the narrowing is exactly the declared one', () => { + it('a conventional config still boots and still mounts at /api/v1', () => { + expect(construct({}).getApiBasePath()).toBe('/api/v1'); + expect(construct({ version: 'v1' }).getApiBasePath()).toBe('/api/v1'); + }); + + it('accepts every spelling the declared pattern allows', () => { + expect(construct({ version: 'v2' }).getApiBasePath()).toBe('/api/v2'); + expect(construct({ version: '2024-01' }).getApiBasePath()).toBe('/api/2024-01'); + expect(construct({ version: 'v1.2' }).getApiBasePath()).toBe('/api/v1.2'); + expect(construct({ version: 'v1_beta' }).getApiBasePath()).toBe('/api/v1_beta'); + }); + + it('still accepts `basePath: ""` — the schema declares NO constraint there', () => { + // The narrowing follows the contract, it does not extend it. `basePath` + // is a bare `z.string()`, so an empty one stays this seam's business + // to accept (`direct-mount-base-follows-apipath.test.ts` boots one). + expect(construct({ basePath: '', version: 'v1' }).getApiBasePath()).toBe('/v1'); + }); + + it('`apiPath` still overrides the composed base', () => { + expect(construct({ apiPath: '/backend/api/v9' }).getApiBasePath()).toBe('/backend/api/v9'); + }); + + it('KEEPS `enableSearch`, which no schema in packages/spec declares', () => { + // The reason the seam validates but does NOT consume the parsed output. + // `RestApiConfigSchema` is not `.strict()`, so a non-strict `z.object()` + // STRIPS this key (measured). Consuming the parse would silently turn + // search back ON for a deployment that turned it off. + const rest = construct({ version: 'v1', enableSearch: false }); + expect((rest as any).config.api.enableSearch).toBe(false); + }); + + it('KEEPS `projectResolution: "none"` — the value this platform actually ships', () => { + // ⛔ The case CI caught and this file did not. `RestApiConfigSchema` + // declares `z.enum(['required', 'optional', 'auto'])`, but the value + // `os serve` forwards is `'none'`: `@objectstack/runtime`'s + // `StandaloneStackResult.api` DECLARES the literal type + // `{ enableProjectScoping: false; projectResolution: 'none' }`, and + // `serve.ts` passes it through (`?? 'auto'` does not fire — `'none'` is + // not nullish). Parsing this key would turn every `os serve` boot into + // a crash; five packages/cli e2e boots did exactly that before the key + // was `.omit()`ed. Which spelling is right is a contract question about + // project-scoping semantics, filed as #11999 — NOT this seam's to + // settle by refusing the value the platform ships. + expect(() => construct({ enableProjectScoping: false, projectResolution: 'none' })).not.toThrow(); + expect(construct({ projectResolution: 'none' as never }).getApiBasePath()).toBe('/api/v1'); + }); + + it('KEEPS the retired `api.requireAuth` warn-and-ignore posture (#3963)', () => { + // The tombstone is `.omit()`ed from the validation on purpose: #3963 + // chose warn-and-ignore for this key, and converting that into a boot + // failure is that decision's to make. 96 in-repo fixtures still pass it. + expect(() => construct({ requireAuth: false, version: 'v1' })).not.toThrow(); + }); +}); diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index 6957628657..4bd57215c2 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -426,27 +426,20 @@ describe('GET /openapi.json — what `info.version` carries (#11546)', () => { } }); - it('serves a falsy `api.version` as itself rather than falling back to the artifact', async () => { - // The removed `|| enriched.info.version` was reachable, not dead — though - // not because the contract permits `''`. `RestApiConfigSchema` refuses it - // (`z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`); nothing parses this config - // against that schema, so `??` is the only guard and `''` walks past it. - // Measured on the pre-fix code this served the spec package's compile-time - // version — the exact value the old comment said the line existed to keep - // off the wire. + it('refuses a falsy `api.version` at construction — there is no such server to ask [#11637]', () => { + // RETIRED AND REPLACED, on this pin's own instruction ("if normalization + // starts rejecting it, retire the pin"). It used to assert that a server + // built with `version: ''` mounted at `/api/` and published + // `info.version: ''` — observable only because nothing ran + // `RestApiConfigSchema` against this config. #11637 made the seam parse + // instead of cast, so `RestServer` refuses the construction and the + // doubled-slash mount is unreachable. // - // An empty version is a broken deployment either way (the mount doubles its - // slash, below). The point of the pin is that it stays visibly broken - // instead of quietly publishing a different kind of fact. - const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: '' }); - expect( - (rest as any).getApiBasePath(), - 'this pin describes the empty-version mount — if normalization starts rejecting it, retire the pin', - ).toBe('/api/'); - - const artifact = await (rest as any).loadOpenApiSpec(); - const { body } = await serveOpenApiFrom(rest, '/api/'); - expect(body.info.version).toBe(''); - expect(body.info.version).not.toBe(artifact.info.version); + // The fact the old pin protected is unchanged and still covered above: + // there is still NO `|| enriched.info.version` fallback, so a configured + // version is served as itself. What changed is that `''` is no longer a + // configurable version. + expect(() => makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: '' })) + .toThrow(/api\.version/); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 15ea1db67e..74e0b3975b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -64,6 +64,10 @@ import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpoints // envelope declares `code` REQUIRED while the flat classification it re-dresses // legitimately carries none. import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +// [#11637] The DECLARED contract for `config.api`, imported as a VALUE rather +// than a type. Both hops into this package were casts, so this schema had +// never run on any deployment path — see `assertDeclaredApiConfig` below. +import { RestApiConfigSchema } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; // [#9741] Declared request shapes for the meta-read doors below — imported so // each door's request literal is compiled against the spec contract instead of @@ -698,6 +702,21 @@ type NormalizedRestServerConfig = { }; }; +/** + * The declared `api` contract, minus the retired keys whose posture this seam + * does not own (see {@link RestServer.assertDeclaredApiConfig}). + * + * Built on first use, not at module load: `RestApiConfigSchema` is a + * `lazySchema` Proxy whose whole point is deferring allocation until someone + * parses, and calling `.omit()` at module top level would resolve it on every + * import of this file. Cached because `.omit()` allocates a fresh schema and a + * `RestServer` is constructed per boot (and per test). + */ +function buildDeclaredApiConfigSchema() { + return RestApiConfigSchema.omit({ requireAuth: true, projectResolution: true }); +} +let declaredApiConfigSchemaCache: ReturnType | undefined; + /** * RestServer * @@ -2917,10 +2936,113 @@ export class RestServer { return trimmed.length > 0 ? trimmed : undefined; } + /** + * Run the DECLARED contract for `config.api` — the parse this seam used to + * skip. Throws on a configuration `RestApiConfigSchema` rejects. + * + * [#11637] `RestApiConfigSchema` (`@objectstack/spec/api`) constrains this + * object, most load-bearingly: + * + * version: z.string().regex(...).default('v1') + * + * `version` is spliced into `getApiBasePath()` and therefore into the mount + * of EVERY route this server registers. Nothing ran that regex on any + * deployment path: both hops in are casts (`config.api as any` in + * `rest-api-plugin.ts`, then `as Partial` below), and the + * kernel's `PluginConfigValidator` could not have covered it either — the + * plugin declares no `configSchema`, `PluginLoader` calls its own + * `validatePluginConfig(metadata)` with NO config argument and returns + * early ("config validation postponed"), and `createRestApiPlugin` closes + * over its config so the kernel never receives it to validate. `??` was the + * only guard left, and `??` substitutes `null`/`undefined` only: `''` + * walked straight past it and mounted the whole API at `/api//`, and + * `'v1/beta'` spliced an extra path segment into every route. + * + * VALIDATION ONLY — the parsed output is deliberately discarded and the + * normalization below keeps reading the raw input. Two measured reasons: + * + * - `enableSearch` is read below through `as any` and is declared NOWHERE + * in `packages/spec` (zero hits in `packages/spec/src`). + * `RestApiConfigSchema` is not `.strict()`, and a non-strict + * `z.object()` STRIPS keys it does not declare — measured: parsing + * `{ version: 'v1', enableSearch: false }` returns an object with no + * `enableSearch` at all. Consuming the parsed output would therefore + * turn search back ON, silently, for a deployment that turned it off: + * the ADR-0104 silent-strip class that `shared/retired-key.ts` exists to + * prevent. The undeclared key is a defect in its own right, filed + * separately rather than fixed here (`packages/spec` is not this + * change's surface). + * + * - the retired `api.requireAuth` key is `.omit()`ed rather than enforced. + * #3963 retired it with a deliberate warn-and-ignore posture + * (`rest-api-plugin.ts`: "is IGNORED"), chosen in a world where nothing + * parsed this config; converting that into a boot failure is that + * decision's to make, not this seam's, and 96 in-repo fixtures still + * pass the key. `.omit()` is typed against the shape, so the day the + * tombstone ages out of `packages/spec` this line fails `tsc` — the + * drift cannot go silent. + * + * - `api.projectResolution` is `.omit()`ed for a DIFFERENT reason, and it + * is the one CI caught: the declared enum is + * `z.enum(['required', 'optional', 'auto'])`, and the value this + * platform actually ships is `'none'` — produced by + * `@objectstack/runtime`'s standalone stack, whose + * `StandaloneStackResult.api` DECLARES the literal type + * `{ enableProjectScoping: false; projectResolution: 'none' }`, and + * forwarded by `os serve` straight into this config + * (`apiConfig.projectResolution ?? 'auto'` — `'none'` is not nullish, so + * it passes through). Three packages disagree about this key's + * vocabulary, and they have disagreed silently for exactly as long as + * nothing ran the schema. Parsing it here does not settle that + * disagreement, it just turns every `os serve` boot into a crash. + * ⛔ Which spelling is right — teach the enum `'none'`, or migrate the + * runtime onto `'auto'` — is a contract question about project-scoping + * semantics that this seam cannot answer and this card does not own + * (`packages/spec` is `domain:spec`'s surface). Filed as #11999. + * + * The sibling sub-objects (`crud`, `metadata`, `batch`, `routes`) are still + * cast, not parsed, and carry declared constraints of their own + * (`batch.maxBatchSize: z.number().int().min(1).max(1000)`, the + * `routes.nameTransform` enum, ...). Same defect class, filed separately: + * this change deliberately puts ONE narrowing in front of contract review + * rather than five. + */ + private assertDeclaredApiConfig(api: unknown): void { + declaredApiConfigSchemaCache ??= buildDeclaredApiConfigSchema(); + const result = declaredApiConfigSchemaCache.safeParse(api ?? {}); + if (result.success) return; + + const details = result.error.issues + .map((issue) => ` - api.${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('\n'); + // The `version` rationale is appended only when `version` is what + // failed. Measured during this change's own ablation: a + // `projectResolution` refusal printed the whole "an empty version + // mounts the entire API at /api//" paragraph, which reads as a + // diagnosis of a key the operator did not write — worse than no + // rationale, because it sends them to the wrong line of their config. + const versionFailed = result.error.issues.some((issue) => issue.path[0] === 'version'); + throw new Error( + 'REST API configuration is invalid: `api` does not satisfy `RestApiConfigSchema` ' + + '(@objectstack/spec/api), the schema that declares it.\n' + + details + + (versionFailed + ? '\nThis is refused at construction because `api.version` becomes a path segment in ' + + 'EVERY route this server mounts (`getApiBasePath()` = `apiPath ?? ' + + '`${basePath}/${version}``) — an empty version mounts the entire API at `/api//`, ' + + 'and one carrying `/` splices an extra segment into every route.' + : ''), + ); + } + /** * Normalize configuration with defaults */ private normalizeConfig(config: RestServerConfig): NormalizedRestServerConfig { + // [#11637] Parse before the cast, not instead of it: the cast below is + // what makes the rest of this method type-check, and it is only sound + // once the declared contract has actually been run. + this.assertDeclaredApiConfig(config.api); const api = (config.api ?? {}) as Partial; const crud = (config.crud ?? {}) as Partial; const metadata = (config.metadata ?? {}) as Partial;