Skip to content

Commit 7c0d0c3

Browse files
os-zhuangclaude
andauthored
fix(spec,rest): declare api.enableSearch, delete the three as-any config reads (#11983) (#12605)
- 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. Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23d52f9 commit 7c0d0c3

8 files changed

Lines changed: 118 additions & 30 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/rest': patch
4+
---
5+
6+
fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983)
7+
8+
`api.enableSearch` was a live REST config key with no declared seat:
9+
`RestServer.normalizeConfig` read it through `(api as any)` and honoured it
10+
(`enableSearch: false` really unmounted the search endpoints), but no schema in
11+
`packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`,
12+
its own parse **stripped** the key — measured:
13+
`RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an
14+
object with no `enableSearch` property at all — so any consumer of the parsed
15+
config silently got search turned back on for a deployment that turned it off
16+
(the ADR-0104 silent-strip class). It also forced #11637's construction-time
17+
parse to be validation-only, discarding the parsed value.
18+
19+
- `RestApiConfigSchema` now declares
20+
`enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the
21+
runtime's existing default. The opt-out now survives the key's own
22+
contract's parse (pinned), and a TypeScript author can write
23+
`api: { enableSearch: false }` without a cast.
24+
- `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the
25+
newly declared `enableSearch`, the already-declared
26+
`metadata.maskObjectFields` (its declared seat landed separately; the cast
27+
was stale), and the long-declared `enableOpenApi` (stale residue from
28+
before its declaration). `NormalizedRestServerConfig.api.enableSearch` is
29+
now a required boolean like its siblings.
30+
31+
No runtime behavior changes: defaults are identical (`enableSearch` on,
32+
masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from
33+
cast-reachable to declared = enforced.

content/docs/references/api/rest-server.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
227227
| **enableBatch** | `boolean` | optional (default: `true`) | Enable batch operation endpoints |
228228
| **enableDiscovery** | `boolean` | optional (default: `true`) | Enable API discovery endpoint |
229229
| **enableOpenApi** | `boolean` | optional (default: `true`) | Enable OpenAPI 3.1 spec & docs viewer endpoints |
230+
| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) |
230231
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
231232
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
232233
| **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);
282283
| **enableBatch** | `boolean` | optional (default: `true`) | Enable batch operation endpoints |
283284
| **enableDiscovery** | `boolean` | optional (default: `true`) | Enable API discovery endpoint |
284285
| **enableOpenApi** | `boolean` | optional (default: `true`) | Enable OpenAPI 3.1 spec & docs viewer endpoints |
286+
| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) |
285287
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
286288
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
287289
| **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. |

packages/rest/src/rest-config-parse-not-cast.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,15 @@ describe('[#11637] §C regression guards — the narrowing is exactly the declar
192192
expect(construct({ apiPath: '/backend/api/v9' }).getApiBasePath()).toBe('/backend/api/v9');
193193
});
194194

195-
it('KEEPS `enableSearch`, which no schema in packages/spec declares', () => {
196-
// The reason the seam validates but does NOT consume the parsed output.
197-
// `RestApiConfigSchema` is not `.strict()`, so a non-strict `z.object()`
198-
// STRIPS this key (measured). Consuming the parse would silently turn
199-
// search back ON for a deployment that turned it off.
195+
it('KEEPS `enableSearch` — declared since #11983, and the opt-out survives normalization', () => {
196+
// Historically the reason the seam validates but does NOT consume the
197+
// parsed output: this key had no declared seat, so the non-strict
198+
// `z.object()` STRIPPED it (measured), and consuming the parse would
199+
// silently have turned search back ON for a deployment that turned it
200+
// off. #11983 declared it (`RestApiConfigSchema.enableSearch`, default
201+
// `true`), so the parse now preserves it too — this pin remains as the
202+
// end-to-end guarantee that the deployment-wide opt-out reaches the
203+
// normalized config, whichever way the seam reads it.
200204
const rest = construct({ version: 'v1', enableSearch: false });
201205
expect((rest as any).config.api.enableSearch).toBe(false);
202206
});

packages/rest/src/rest-server.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,7 @@ type NormalizedRestServerConfig = {
651651
enableBatch: boolean;
652652
enableDiscovery: boolean;
653653
enableOpenApi: boolean;
654-
enableSearch?: boolean;
654+
enableSearch: boolean;
655655
enableProjectScoping: boolean;
656656
projectResolution: 'required' | 'optional' | 'auto';
657657
documentation: RestApiConfig['documentation'];
@@ -2964,17 +2964,14 @@ export class RestServer {
29642964
* VALIDATION ONLY — the parsed output is deliberately discarded and the
29652965
* normalization below keeps reading the raw input. Two measured reasons:
29662966
*
2967-
* - `enableSearch` is read below through `as any` and is declared NOWHERE
2968-
* in `packages/spec` (zero hits in `packages/spec/src`).
2969-
* `RestApiConfigSchema` is not `.strict()`, and a non-strict
2970-
* `z.object()` STRIPS keys it does not declare — measured: parsing
2971-
* `{ version: 'v1', enableSearch: false }` returns an object with no
2972-
* `enableSearch` at all. Consuming the parsed output would therefore
2973-
* turn search back ON, silently, for a deployment that turned it off:
2974-
* the ADR-0104 silent-strip class that `shared/retired-key.ts` exists to
2975-
* prevent. The undeclared key is a defect in its own right, filed
2976-
* separately rather than fixed here (`packages/spec` is not this
2977-
* change's surface).
2967+
* - `enableSearch` USED to be the silent-strip trap here: it was read
2968+
* below through `as any` and declared nowhere in `packages/spec`, so
2969+
* this non-strict `z.object()` stripped it and consuming the parsed
2970+
* output would have turned search back ON for a deployment that turned
2971+
* it off (the ADR-0104 class `shared/retired-key.ts` exists to
2972+
* prevent). #11983 gave it a declared seat
2973+
* (`RestApiConfigSchema.enableSearch`, default `true`), so the parse
2974+
* now preserves it — but the discard stays, for the omitted keys:
29782975
*
29792976
* - the retired `api.requireAuth` key is `.omit()`ed rather than enforced.
29802977
* #3963 retired it with a deliberate warn-and-ignore posture
@@ -3062,8 +3059,8 @@ export class RestServer {
30623059
enableUi: api.enableUi ?? true,
30633060
enableBatch: api.enableBatch ?? true,
30643061
enableDiscovery: api.enableDiscovery ?? true,
3065-
enableOpenApi: (api as any).enableOpenApi ?? true,
3066-
enableSearch: (api as any).enableSearch ?? true,
3062+
enableOpenApi: api.enableOpenApi ?? true,
3063+
enableSearch: api.enableSearch ?? true,
30673064
enableProjectScoping: api.enableProjectScoping ?? false,
30683065
projectResolution: api.projectResolution ?? 'auto',
30693066
documentation: api.documentation,
@@ -3090,15 +3087,14 @@ export class RestServer {
30903087
enableCache: metadata.enableCache ?? true,
30913088
cacheTtl: metadata.cacheTtl ?? 3600,
30923089
// [ADR-0106 D8] Default ON — masking is the platform default and
3093-
// ships with the current major. Read through `as any` for the
3094-
// same reason `api.enableOpenApi` / `api.enableSearch` above are:
3095-
// `MetadataEndpointsConfigSchema` lives in `packages/spec` and
3096-
// giving this key a declared seat there is a separate change.
3090+
// ships with the current major. The key has a declared seat
3091+
// (`MetadataEndpointsConfigSchema.maskObjectFields` in
3092+
// `packages/spec`), so this is a typed read.
30973093
// `isObjectSchemaMaskingEnabled` also honours the
30983094
// `OS_ALLOW_UNMASKED_OBJECT_METADATA` escape hatch, which is the
30993095
// knob the runtime `/metadata` dispatcher shares (it has no REST
31003096
// config to read).
3101-
maskObjectFields: isObjectSchemaMaskingEnabled((metadata as any).maskObjectFields),
3097+
maskObjectFields: isObjectSchemaMaskingEnabled(metadata.maskObjectFields),
31023098
endpoints: {
31033099
types: metadata.endpoints?.types ?? true,
31043100
items: metadata.endpoints?.items ?? true,
@@ -3174,7 +3170,7 @@ export class RestServer {
31743170
if (this.config.api.enableDiscovery) {
31753171
this.registerDiscoveryEndpoints(bp);
31763172
}
3177-
if (this.config.api.enableOpenApi ?? true) {
3173+
if (this.config.api.enableOpenApi) {
31783174
this.registerOpenApiEndpoints(bp);
31793175
}
31803176
if (this.config.api.enableMetadata) {
@@ -3183,7 +3179,7 @@ export class RestServer {
31833179
if (this.config.api.enableUi) {
31843180
this.registerUiEndpoints(bp);
31853181
}
3186-
if (this.config.api.enableSearch ?? true) {
3182+
if (this.config.api.enableSearch) {
31873183
this.registerSearchEndpoints(bp);
31883184
}
31893185
this.registerEmailEndpoints(bp);
@@ -3512,11 +3508,13 @@ export class RestServer {
35123508
// fallback for a wrong bit: each layer states the fact only
35133509
// it knows, and `enabled` is their conjunction.
35143510
//
3515-
// The flag is read with the mount's own `?? true` spelling
3516-
// rather than the equivalent `!== false` — same predicate,
3517-
// same characters, so the two cannot be edited apart.
3511+
// The flag is the NORMALIZED boolean (defaulted in
3512+
// `normalizeConfig`, declared seat in
3513+
// `RestApiConfigSchema.enableSearch` since #11983) — the
3514+
// same field the mount in `registerRoutes` reads, so the
3515+
// two cannot be edited apart.
35183516
caps.search = {
3519-
enabled: !!caps.search?.enabled && (this.config.api.enableSearch ?? true),
3517+
enabled: !!caps.search?.enabled && this.config.api.enableSearch,
35203518
};
35213519

35223520
// Attach scoping metadata so clients can detect dual-mode routing.

packages/spec/authorable-defaults/api.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"api/RestApiConfig:enableMetadata = true",
164164
"api/RestApiConfig:enableOpenApi = true",
165165
"api/RestApiConfig:enableProjectScoping = false",
166+
"api/RestApiConfig:enableSearch = true",
166167
"api/RestApiConfig:enableUi = true",
167168
"api/RestApiConfig:projectResolution = \"auto\"",
168169
"api/RestApiConfig:version = \"v1\"",

packages/spec/authorable-surface/api.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,7 @@
14071407
"api/RestApiConfig:enableMetadata",
14081408
"api/RestApiConfig:enableOpenApi",
14091409
"api/RestApiConfig:enableProjectScoping",
1410+
"api/RestApiConfig:enableSearch",
14101411
"api/RestApiConfig:enableUi",
14111412
"api/RestApiConfig:projectResolution",
14121413
"api/RestApiConfig:requireAuth [RETIRED]",

packages/spec/src/api/rest-server.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,40 @@ describe('RestApiConfigSchema', () => {
8080
expect(config.enableDiscovery).toBe(false);
8181
});
8282

83+
it('[#11983] enableSearch defaults to true — search is ON unless opted out', () => {
84+
// Pinning the MATERIALIZED default (not just the declaration) is what makes
85+
// a later `.optional()` — which would hand `undefined` to the REST layer —
86+
// fail here rather than silently change the mount decision.
87+
const config = RestApiConfigSchema.parse({});
88+
89+
expect(config.enableSearch).toBe(true);
90+
});
91+
92+
it('[#11983] enableSearch: false is the declared deployment-wide opt-out, and it SURVIVES the parse', () => {
93+
// Before this key had a declared seat, this exact parse was the measured
94+
// trap: `RestApiConfigSchema` is not `.strict()`, so it STRIPPED the
95+
// undeclared key and any consumer of the parsed output silently got search
96+
// turned back on (the ADR-0104 silent-strip class). This is the pin that
97+
// says the opt-out now round-trips through the key's own contract.
98+
const optedOut = RestApiConfigSchema.parse({ version: 'v1', enableSearch: false });
99+
100+
expect(optedOut.enableSearch).toBe(false);
101+
expect(Object.prototype.hasOwnProperty.call(optedOut, 'enableSearch')).toBe(true);
102+
103+
// Explicit `true` is a real answer too, not a no-op the parse discards.
104+
expect(RestApiConfigSchema.parse({ enableSearch: true }).enableSearch).toBe(true);
105+
});
106+
107+
it('[#11983] enableSearch is authorable without a cast, and is a boolean (compile-time)', () => {
108+
// The declaration's REASON for existing: `objectstack.config.ts` authors
109+
// the key by name and `packages/rest`'s `normalizeConfig` reads it. Both go
110+
// through this input type, so this is the pin that says the key no longer
111+
// needs `(api as any)` to be reachable.
112+
const authored: RestApiConfig = { enableSearch: false };
113+
const readAsBoolean: boolean | undefined = authored.enableSearch;
114+
expect(readAsBoolean).toBe(false);
115+
});
116+
83117
describe('Documentation Configuration', () => {
84118
it('should accept basic documentation config', () => {
85119
const config = RestApiConfigSchema.parse({

packages/spec/src/api/rest-server.zod.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,21 @@ export const RestApiConfigSchema = lazySchema(() => z.object({
9696
*/
9797
enableOpenApi: z.boolean().default(true).describe('Enable OpenAPI 3.1 spec & docs viewer endpoints'),
9898

99+
/**
100+
* Deployment-wide switch for the structured-search surface. `false` skips
101+
* mounting the search endpoints entirely (`registerSearchEndpoints` is never
102+
* called, so the routes 404), and the discovery capability block reports
103+
* `search.enabled: false` regardless of what the underlying protocol could
104+
* serve — declared and enforced at the mount, not advertised past it.
105+
*
106+
* Before this key had a declared seat the REST layer honoured it anyway,
107+
* reading its config raw through a cast — and this schema (a non-strict
108+
* `z.object()`) STRIPPED it, so any config that was parsed and then consumed
109+
* silently turned search back on. Declared here so the opt-out survives its
110+
* own contract's parse.
111+
*/
112+
enableSearch: z.boolean().default(true).describe('Enable structured search endpoints (deployment-wide search opt-out)'),
113+
99114
/**
100115
* Enable project-scoped routing (/api/v1/environments/:environmentId/data/...)
101116
* When true, all data/meta/AI APIs are scoped under /environments/:environmentId

0 commit comments

Comments
 (0)