Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/declare-live-rest-config-keys.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions content/docs/references/api/rest-server.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand DownExpand Up@@ -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. |
Expand Down
14 changes: 9 additions & 5 deletions packages/rest/src/rest-config-parse-not-cast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
});
Expand Down
48 changes: 23 additions & 25 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'];
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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,
Expand All@@ -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,
Expand DownExpand Up@@ -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) {
Expand All@@ -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);
Expand DownExpand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-defaults/api.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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\"",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]",
Expand Down
34 changes: 34 additions & 0 deletions packages/spec/src/api/rest-server.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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({
Expand Down
15 changes: 15 additions & 0 deletions packages/spec/src/api/rest-server.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading