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
90 changes: 90 additions & 0 deletions .changeset/rest-api-config-parsed-not-cast.md
Original file line numberDiff line numberDiff line change
@@ -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<RestApiConfig>`
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.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed or renamed — no spec key, export or config field changes spelling, and `RestApiConfigSchema` itself is untouched. What changes is that the schema already declaring `api.version` is finally executed at the consumption seam, so `objectstack migrate meta` has no mechanical rewrite to list: a config carrying `version: ''` or `'v1/beta'` states an intent (which path segment did you mean?) that no conversion can decide for the author, and the refusal text names the fix at the call site. -->
226 changes: 226 additions & 0 deletions packages/rest/src/rest-config-parse-not-cast.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<RestApiConfig>`
* 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<string, unknown>) {
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<string, unknown>) {
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();
});
});
35 changes: 14 additions & 21 deletions packages/rest/src/rest-openapi-route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/);
});
});
Loading
Loading