From b3357c6ea9cacababff70330f5442af6ebc9342b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 17:26:13 +0000 Subject: [PATCH] fix(spec,rest): declare api.enableSearch, delete the three as-any config reads (#11983) - RestApiConfigSchema declares enableSearch: z.boolean().default(true) beside enableOpenApi, so the deployment-wide search opt-out survives its own contract's parse instead of being stripped by the non-strict object (the measured ADR-0104 silent-strip trap). - packages/rest normalizeConfig drops all three as-any reads (enableOpenApi stale residue, enableSearch newly declared, metadata.maskObjectFields already declared); the normalized api.enableSearch is a required boolean and both read sites drop their now-dead ?? true. - Spec pins: materialized default, parse-survival of the opt-out, cast-free authorability. Stale #11637 pin prose updated. - Regenerated: authorable-surface/api.json, authorable-defaults/api.json, content/docs/references/api/rest-server.mdx. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 --- .changeset/declare-live-rest-config-keys.md | 33 +++++++++++++ content/docs/references/api/rest-server.mdx | 2 + .../src/rest-config-parse-not-cast.test.ts | 14 ++++-- packages/rest/src/rest-server.ts | 48 +++++++++---------- packages/spec/authorable-defaults/api.json | 1 + packages/spec/authorable-surface/api.json | 1 + packages/spec/src/api/rest-server.test.ts | 34 +++++++++++++ packages/spec/src/api/rest-server.zod.ts | 15 ++++++ 8 files changed, 118 insertions(+), 30 deletions(-) create mode 100644 .changeset/declare-live-rest-config-keys.md diff --git a/.changeset/declare-live-rest-config-keys.md b/.changeset/declare-live-rest-config-keys.md new file mode 100644 index 0000000000..653779f70f --- /dev/null +++ b/.changeset/declare-live-rest-config-keys.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': patch +'@objectstack/rest': patch +--- + +fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983) + +`api.enableSearch` was a live REST config key with no declared seat: +`RestServer.normalizeConfig` read it through `(api as any)` and honoured it +(`enableSearch: false` really unmounted the search endpoints), but no schema in +`packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`, +its own parse **stripped** the key — measured: +`RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an +object with no `enableSearch` property at all — so any consumer of the parsed +config silently got search turned back on for a deployment that turned it off +(the ADR-0104 silent-strip class). It also forced #11637's construction-time +parse to be validation-only, discarding the parsed value. + +- `RestApiConfigSchema` now declares + `enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the + runtime's existing default. The opt-out now survives the key's own + contract's parse (pinned), and a TypeScript author can write + `api: { enableSearch: false }` without a cast. +- `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the + newly declared `enableSearch`, the already-declared + `metadata.maskObjectFields` (its declared seat landed separately; the cast + was stale), and the long-declared `enableOpenApi` (stale residue from + before its declaration). `NormalizedRestServerConfig.api.enableSearch` is + now a required boolean like its siblings. + +No runtime behavior changes: defaults are identical (`enableSearch` on, +masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from +cast-reachable to declared = enforced. diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 9eed88945f..c43706a5de 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -227,6 +227,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableBatch** | `boolean` | optional (default: `true`) | Enable batch operation endpoints | | **enableDiscovery** | `boolean` | optional (default: `true`) | Enable API discovery endpoint | | **enableOpenApi** | `boolean` | optional (default: `true`) | Enable OpenAPI 3.1 spec & docs viewer endpoints | +| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | | **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -282,6 +283,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableBatch** | `boolean` | optional (default: `true`) | Enable batch operation endpoints | | **enableDiscovery** | `boolean` | optional (default: `true`) | Enable API discovery endpoint | | **enableOpenApi** | `boolean` | optional (default: `true`) | Enable OpenAPI 3.1 spec & docs viewer endpoints | +| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | | **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/packages/rest/src/rest-config-parse-not-cast.test.ts b/packages/rest/src/rest-config-parse-not-cast.test.ts index 8332b21a77..05283eb994 100644 --- a/packages/rest/src/rest-config-parse-not-cast.test.ts +++ b/packages/rest/src/rest-config-parse-not-cast.test.ts @@ -192,11 +192,15 @@ describe('[#11637] §C regression guards — the narrowing is exactly the declar 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. + it('KEEPS `enableSearch` — declared since #11983, and the opt-out survives normalization', () => { + // Historically the reason the seam validates but does NOT consume the + // parsed output: this key had no declared seat, so the non-strict + // `z.object()` STRIPPED it (measured), and consuming the parse would + // silently have turned search back ON for a deployment that turned it + // off. #11983 declared it (`RestApiConfigSchema.enableSearch`, default + // `true`), so the parse now preserves it too — this pin remains as the + // end-to-end guarantee that the deployment-wide opt-out reaches the + // normalized config, whichever way the seam reads it. const rest = construct({ version: 'v1', enableSearch: false }); expect((rest as any).config.api.enableSearch).toBe(false); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8ac62c8093..f6d095335a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -651,7 +651,7 @@ type NormalizedRestServerConfig = { enableBatch: boolean; enableDiscovery: boolean; enableOpenApi: boolean; - enableSearch?: boolean; + enableSearch: boolean; enableProjectScoping: boolean; projectResolution: 'required' | 'optional' | 'auto'; documentation: RestApiConfig['documentation']; @@ -2964,17 +2964,14 @@ export class RestServer { * 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). + * - `enableSearch` USED to be the silent-strip trap here: it was read + * below through `as any` and declared nowhere in `packages/spec`, so + * this non-strict `z.object()` stripped it and consuming the parsed + * output would have turned search back ON for a deployment that turned + * it off (the ADR-0104 class `shared/retired-key.ts` exists to + * prevent). #11983 gave it a declared seat + * (`RestApiConfigSchema.enableSearch`, default `true`), so the parse + * now preserves it — but the discard stays, for the omitted keys: * * - the retired `api.requireAuth` key is `.omit()`ed rather than enforced. * #3963 retired it with a deliberate warn-and-ignore posture @@ -3062,8 +3059,8 @@ export class RestServer { enableUi: api.enableUi ?? true, enableBatch: api.enableBatch ?? true, enableDiscovery: api.enableDiscovery ?? true, - enableOpenApi: (api as any).enableOpenApi ?? true, - enableSearch: (api as any).enableSearch ?? true, + enableOpenApi: api.enableOpenApi ?? true, + enableSearch: api.enableSearch ?? true, enableProjectScoping: api.enableProjectScoping ?? false, projectResolution: api.projectResolution ?? 'auto', documentation: api.documentation, @@ -3090,15 +3087,14 @@ export class RestServer { enableCache: metadata.enableCache ?? true, cacheTtl: metadata.cacheTtl ?? 3600, // [ADR-0106 D8] Default ON — masking is the platform default and - // ships with the current major. Read through `as any` for the - // same reason `api.enableOpenApi` / `api.enableSearch` above are: - // `MetadataEndpointsConfigSchema` lives in `packages/spec` and - // giving this key a declared seat there is a separate change. + // ships with the current major. The key has a declared seat + // (`MetadataEndpointsConfigSchema.maskObjectFields` in + // `packages/spec`), so this is a typed read. // `isObjectSchemaMaskingEnabled` also honours the // `OS_ALLOW_UNMASKED_OBJECT_METADATA` escape hatch, which is the // knob the runtime `/metadata` dispatcher shares (it has no REST // config to read). - maskObjectFields: isObjectSchemaMaskingEnabled((metadata as any).maskObjectFields), + maskObjectFields: isObjectSchemaMaskingEnabled(metadata.maskObjectFields), endpoints: { types: metadata.endpoints?.types ?? true, items: metadata.endpoints?.items ?? true, @@ -3174,7 +3170,7 @@ export class RestServer { if (this.config.api.enableDiscovery) { this.registerDiscoveryEndpoints(bp); } - if (this.config.api.enableOpenApi ?? true) { + if (this.config.api.enableOpenApi) { this.registerOpenApiEndpoints(bp); } if (this.config.api.enableMetadata) { @@ -3183,7 +3179,7 @@ export class RestServer { if (this.config.api.enableUi) { this.registerUiEndpoints(bp); } - if (this.config.api.enableSearch ?? true) { + if (this.config.api.enableSearch) { this.registerSearchEndpoints(bp); } this.registerEmailEndpoints(bp); @@ -3512,11 +3508,13 @@ export class RestServer { // fallback for a wrong bit: each layer states the fact only // it knows, and `enabled` is their conjunction. // - // The flag is read with the mount's own `?? true` spelling - // rather than the equivalent `!== false` — same predicate, - // same characters, so the two cannot be edited apart. + // The flag is the NORMALIZED boolean (defaulted in + // `normalizeConfig`, declared seat in + // `RestApiConfigSchema.enableSearch` since #11983) — the + // same field the mount in `registerRoutes` reads, so the + // two cannot be edited apart. caps.search = { - enabled: !!caps.search?.enabled && (this.config.api.enableSearch ?? true), + enabled: !!caps.search?.enabled && this.config.api.enableSearch, }; // Attach scoping metadata so clients can detect dual-mode routing. diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index ba5f9f98b9..3ca93e6313 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -163,6 +163,7 @@ "api/RestApiConfig:enableMetadata = true", "api/RestApiConfig:enableOpenApi = true", "api/RestApiConfig:enableProjectScoping = false", + "api/RestApiConfig:enableSearch = true", "api/RestApiConfig:enableUi = true", "api/RestApiConfig:projectResolution = \"auto\"", "api/RestApiConfig:version = \"v1\"", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index ecad60c646..12435da942 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1407,6 +1407,7 @@ "api/RestApiConfig:enableMetadata", "api/RestApiConfig:enableOpenApi", "api/RestApiConfig:enableProjectScoping", + "api/RestApiConfig:enableSearch", "api/RestApiConfig:enableUi", "api/RestApiConfig:projectResolution", "api/RestApiConfig:requireAuth [RETIRED]", diff --git a/packages/spec/src/api/rest-server.test.ts b/packages/spec/src/api/rest-server.test.ts index 4f9be3792f..88b974dcb4 100644 --- a/packages/spec/src/api/rest-server.test.ts +++ b/packages/spec/src/api/rest-server.test.ts @@ -80,6 +80,40 @@ describe('RestApiConfigSchema', () => { expect(config.enableDiscovery).toBe(false); }); + it('[#11983] enableSearch defaults to true — search is ON unless opted out', () => { + // Pinning the MATERIALIZED default (not just the declaration) is what makes + // a later `.optional()` — which would hand `undefined` to the REST layer — + // fail here rather than silently change the mount decision. + const config = RestApiConfigSchema.parse({}); + + expect(config.enableSearch).toBe(true); + }); + + it('[#11983] enableSearch: false is the declared deployment-wide opt-out, and it SURVIVES the parse', () => { + // Before this key had a declared seat, this exact parse was the measured + // trap: `RestApiConfigSchema` is not `.strict()`, so it STRIPPED the + // undeclared key and any consumer of the parsed output silently got search + // turned back on (the ADR-0104 silent-strip class). This is the pin that + // says the opt-out now round-trips through the key's own contract. + const optedOut = RestApiConfigSchema.parse({ version: 'v1', enableSearch: false }); + + expect(optedOut.enableSearch).toBe(false); + expect(Object.prototype.hasOwnProperty.call(optedOut, 'enableSearch')).toBe(true); + + // Explicit `true` is a real answer too, not a no-op the parse discards. + expect(RestApiConfigSchema.parse({ enableSearch: true }).enableSearch).toBe(true); + }); + + it('[#11983] enableSearch is authorable without a cast, and is a boolean (compile-time)', () => { + // The declaration's REASON for existing: `objectstack.config.ts` authors + // the key by name and `packages/rest`'s `normalizeConfig` reads it. Both go + // through this input type, so this is the pin that says the key no longer + // needs `(api as any)` to be reachable. + const authored: RestApiConfig = { enableSearch: false }; + const readAsBoolean: boolean | undefined = authored.enableSearch; + expect(readAsBoolean).toBe(false); + }); + describe('Documentation Configuration', () => { it('should accept basic documentation config', () => { const config = RestApiConfigSchema.parse({ diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 15a23ac2e1..f24ece6d90 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -96,6 +96,21 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ */ enableOpenApi: z.boolean().default(true).describe('Enable OpenAPI 3.1 spec & docs viewer endpoints'), + /** + * Deployment-wide switch for the structured-search surface. `false` skips + * mounting the search endpoints entirely (`registerSearchEndpoints` is never + * called, so the routes 404), and the discovery capability block reports + * `search.enabled: false` regardless of what the underlying protocol could + * serve — declared and enforced at the mount, not advertised past it. + * + * Before this key had a declared seat the REST layer honoured it anyway, + * reading its config raw through a cast — and this schema (a non-strict + * `z.object()`) STRIPPED it, so any config that was parsed and then consumed + * silently turned search back on. Declared here so the opt-out survives its + * own contract's parse. + */ + enableSearch: z.boolean().default(true).describe('Enable structured search endpoints (deployment-wide search opt-out)'), + /** * Enable project-scoped routing (/api/v1/environments/:environmentId/data/...) * When true, all data/meta/AI APIs are scoped under /environments/:environmentId