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
38 changes: 38 additions & 0 deletions .changeset/endpoint-surfaces-announce-only-served.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/rest': minor
---

The two machine-readable endpoint surfaces announce only the declarations the runtime actually serves

`GET {basePath}/meta/api` and `GET {basePath}/openapi.json` enumerated declared `api` items
through the metadata protocol (ObjectQL SchemaRegistry + `sys_metadata`). Whether a declared
route is SERVED is decided by a different reader — `IMetadataService.matchEndpoint` and the
endpoint matcher behind it, which sees the metadata manager's registry and its registered
loaders. A real boot measured the two disagreeing: an `api` row written through
`PUT /meta/api/{name}` was enumerated by both surfaces — the OpenAPI document publishing it as
a path with `security: []`, i.e. as needing no credentials — while every request to it answered
404.

Both surfaces now ask the matcher, per declaration, and announce only what comes back. An
`/openapi.json` is what SDKs, codegen and AI clients generate from, so an endpoint advertised
there that does not exist propagates into everything built on top of it.

**What changes for you:** an `api` declaration that this runtime will not serve disappears from
both surfaces. That covers a row created by a runtime/Studio metadata write rather than
published from a stack artifact, and one excluded at load by the ADR-0121 publish gates (for
example `authRequired: false` with no armed `rateLimit`). If a declaration you expected has
vanished, it was already answering 404 — the surface has stopped mis-reporting it, and the
server log now names each omitted declaration, its route, and why. Publish it through a gated
path (a stack artifact, or `publishPackage` with the package's `manifest.namespace`) to make it
real. Endpoints declared in a stack artifact are unaffected: they are served, so they are still
listed and still documented in full.

Two surfaces deliberately keep their previous behaviour: `GET /meta/api?preview=draft` answers
"what is pending", which is by construction not the served set, and the single-item
`GET|PUT|DELETE /meta/api/{name}` routes stay reachable so an unserved declaration can still be
inspected and removed.

Hosts that embed `RestServer` directly get a new optional final constructor argument,
`metadataServiceProvider`, resolving the `metadata` service. `rest-api-plugin` wires it; a host
that does not pass it keeps the old enumerate-everything behaviour and logs, once, that the
surfaces can no longer promise they describe only served routes.
1 change: 1 addition & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/service-analytics": "workspace:*",
Expand Down
18 changes: 18 additions & 0 deletions packages/rest/src/openapi-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@
* `/openapi.json` describing its two declared endpoints
* (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`).
*
* ## What it is HANDED (#5224)
*
* Its production caller no longer passes the enumerated `api` items. It passes
* the set the endpoint matcher confirmed it will serve (`served-endpoints.ts`),
* because enumeration and service are two different readers and a real boot
* measured them disagreeing: a row written through `PUT /meta/api/{name}` was
* enumerated, never matched, and published here as a live path with
* `security: []` while every request to it answered 404.
*
* That makes the parse-and-resolve-duplicates pass below DOWNSTREAM of the
* authority rather than a second opinion beside it — with a matcher-narrowed
* input its duplicate branch cannot fire, since the matcher already resolved
* every route to one owner. It is kept because this function is exported and
* unit-tested as a unit: it must still refuse to document a half-valid shape
* when handed raw items directly. What it must never become is the place where
* "will this be served" is decided; that question has one owner, and asking it
* is the caller's job.
*
* The empty-set case is still exact rather than approximate — with nothing to
* add, {@link enrichOpenApiWithEndpoints} returns its input document BY
* REFERENCE — which is what keeps a deployment that declares no endpoint
Expand Down
18 changes: 17 additions & 1 deletion packages/rest/src/rest-api-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RestServerConfig } from '@objectstack/spec/api';
import { registerPackageRoutes } from './package-routes.js';
import { registerExternalDatasourceRoutes } from './external-datasource-routes.js';
import type { PackageService } from '@objectstack/service-package';
import type { IMetadataService } from '@objectstack/spec/contracts';
import { SysImportJob } from '@objectstack/platform-objects/audit';

export interface RestApiPluginConfig {
Expand Down Expand Up @@ -236,6 +237,21 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
} catch { return undefined; }
};

// [#5224] Metadata service resolver — the endpoint matcher behind
// `IMetadataService.matchEndpoint`. The two machine-readable
// endpoint faces (`GET /meta/api`, `GET /openapi.json`) ask it
// whether a declared route is actually served before announcing it,
// so neither can publish an endpoint that answers 404. Returns
// undefined when no `metadata` service is registered; the faces
// then say so loudly rather than inventing a verdict.
const metadataServiceProvider = async (
_environmentId?: string,
): Promise<IMetadataService | undefined> => {
try {
return ctx.getService<IMetadataService>('metadata');
} catch { return undefined; }
};

// Security service resolver — used by the ADR-0090 D5/D9
// /security/suggested-bindings routes and the D6 /security/explain
// route (plugin-security). Returns undefined when plugin-security
Expand Down Expand Up @@ -264,7 +280,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
try { return ctx.getService<any>(name) != null; } catch { return false; }
};
try {
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver);
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider);
restServer.registerRoutes();

ctx.logger.info('REST API successfully registered');
Expand Down
Loading
Loading