diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 7a6a84564ec..0e6edd5a502 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -2136,7 +2136,7 @@ "source": [ "packages/spec/liveness/crud_endpoints.json (the ADR-0049 ledger this item answers: five live `operations.*` switches + `dataPrefix`, two dead keys)", "packages/spec/src/api/rest-server.zod.ts#CrudEndpointsConfigSchema (the declared contract, its defaults and the two retiredKey tombstones)", - "packages/rest/src/rest-server.ts#registerCrudEndpoints (the six mounts and their five gates), (normalizeConfig — the parsed sub-config every consumer reads), (parseDeclaredSubConfig — the construction-time refusal), (registerDiscoveryEndpoints — discovery.routes.data built from dataPrefix), (getRoutes — the route table the mount clauses read)", + "packages/rest/src/rest-server.ts#registerCrudEndpoints (the six mounts and their five gates), (normalizeConfig — the parsed sub-config every consumer reads), (parseDeclaredSubConfig — the construction-time refusal), (getRoutes — the route table the mount clauses read) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.data built from dataPrefix)", "#14691 (the ADR-0049 retirement of patterns/objectParamStyle) · #11984 (parse-and-consume instead of cast) · ADR-0122 (author state) · ADR-0076 D12 (advertise only what is mounted)", "sibling clause: api-backend.api-methods-verb-gate (the per-OBJECT exposure gate this config-level switch must stay distinguishable from) · api-backend.route-ledger-live-parity (the routing-404 control technique)" ], @@ -2383,7 +2383,7 @@ "source": [ "packages/spec/liveness/metadata_endpoints.json (the ADR-0049 ledger this item answers: prefix, enableCache, maskObjectFields and three endpoints.* live, cacheTtl and endpoints.schema dead)", "packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema (the declared contract — four endpoints.* switches whose describe() strings enumerate every mount they gate since #15542/#15854, the ADR-0106 D8 docblock and the two retiredKey tombstones)", - "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath, the four endpoint gates and registerPerItemRoute — the per-item switch spelled as a call so the later members of that face carry it), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (registerDiscoveryEndpoints — discovery.routes.metadata built from prefix), (getRoutes)", + "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath, the four endpoint gates and registerPerItemRoute — the per-item switch spelled as a call so the later members of that face carry it), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (getRoutes) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.metadata built from prefix)", "ADR-0106 D8 (per-caller field masking of served object schemas) · #14691 (the two retirements) · #7526 (the /meta/types ordering fix the `types` switch takes with it) · ADR-0076 D12 · #15542 / #15854 (the switch-radius ruling that created `maintenance` and gave `item` its own writes) · #15543 (why the measured blast radius of that move is zero: no shipped boot path authors a RestServerConfig)", "sibling clause: platform-core.metadata-registry-serving (it drives the served REGISTRY on the default config; this item covers the config that decides what is served and to whom) · platform-core.docs-audience-gate (the audience-gated types that are a cache carve-out here)" ], diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 542f46c6157..97d165b1593 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -295,7 +295,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // #22430 — the API-description endpoints, under the ruling recorded on #22146 // (the API-description endpoints refuse an anonymous caller with 401). { id: 'anonymous-deny-api-description', summary: 'anonymous-deny on the API-description endpoints (the document and its viewer)', state: 'enforced', - enforcement: 'rest/rest-server.ts registerOpenApiEndpoints — BOTH handlers (the document and its viewer page, on every base the server mounts) open with enforceAuth (→ shouldDenyAnonymous) as their first act, ahead of the bundled-artifact load and the protocol resolution, so a refused request does no work and does not reveal whether a document is bundled; an anonymous caller gets the REST seam\'s flat 401 UNAUTHENTICATED, a signed-in caller is served as before, and a session held by the ADR-0069 auth-policy gate gets that gate\'s 403', + enforcement: 'rest/discovery-routes.ts registerOpenApiEndpoints — BOTH handlers (the document and its viewer page, on every base the server mounts) open with enforceAuth (→ shouldDenyAnonymous) as their first act, ahead of the bundled-artifact load and the protocol resolution, so a refused request does no work and does not reveal whether a document is bundled; an anonymous caller gets the REST seam\'s flat 401 UNAUTHENTICATED, a signed-in caller is served as before, and a session held by the ADR-0069 auth-policy gate gets that gate\'s 403', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', // The REST family at ledger granularity. The family is exactly these two // routes and both handlers open with the floor, which is the property a diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 644561d8608..218f758854f 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1628,7 +1628,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 4] Length 4 → 5 (`sharing-routes.ts`), still 0 over it. // [#23050 slice 5] Length 5 → 6 (`analytics-routes.ts`), still 0 over it. // [#23050 slice 6] Length 6 → 7 (`security-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); + // [#23050 slice 7] Length 7 → 8 (`discovery-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER); const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')); for (const probe of [MECHANICS, ROUTES]) { diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index d8285fe50b7..c4bd9c9931a 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -520,10 +520,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' + 'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' + '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' + - '`analytics-routes.ts` and `security-routes.ts`, the modules the bodies of `registerDataActionEndpoints`, ' + - '`registerBatchEndpoints`, `registerApprovalsEndpoints`, `registerSharingEndpoints`, ' + - '`registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, `registerSecurityExplainEndpoints` and ' + - '`registerSecurityEndpoints` moved to; ' + + '`analytics-routes.ts`, `security-routes.ts` and `discovery-routes.ts`, the modules the bodies of ' + + '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + + '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints` and ' + + '`registerOpenApiEndpoints` moved to; ' + 'reachable = those inside registerMetadataEndpoints', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. @@ -717,16 +718,32 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // slice's seam, the same two spellings TWICE, because the module carries // two registrars (two host literals' forwarders, four; two host // interfaces' members, four). No call site was added. + // [#23050 slice 7] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerDiscoveryEndpoints`' and `registerOpenApiEndpoints`' bodies + // (four registration sites, two and two — the API root and `/discovery`, + // `/openapi.json` and `/docs` — all outside `registerMetadataEndpoints`, so + // all four in the blind spot) moved to `discovery-routes.ts`, which joins + // the list (length 7 -> 8) with its own control. + // `this.routeManager.register(` stays 63 over the eight files + // (23 here + 9 + 5 + 7 + 8 + 1 + 6 + 4), `private register*Endpoints(` + // stays 16 (both registrars are still declared here), and `enforceAuth` + // 92 -> 96 is RE-ANCHORED, not relaxed: the OpenAPI pair's two call sites + // moved with their routes and are counted in both readings; the four new + // occurrences are this slice's seam, the two spellings ONCE, because only + // the OpenAPI body reads `enforceAuth` (its host literal's forwarder, two; + // its host interface's member, two) — the discovery document is served to + // anyone, so its literal forwards no `enforceAuth`. No call site was added. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 7, + 'registrar list length': 8, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, 'sharing-routes.ts: this.routeManager.register(': 8, 'analytics-routes.ts: this.routeManager.register(': 1, 'security-routes.ts: this.routeManager.register(': 6, + 'discovery-routes.ts: this.routeManager.register(': 4, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -734,7 +751,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 92, + enforceAuth: 96, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + diff --git a/packages/rest/src/discovery-routes.ts b/packages/rest/src/discovery-routes.ts new file mode 100644 index 00000000000..8bf688c0d6d --- /dev/null +++ b/packages/rest/src/discovery-routes.ts @@ -0,0 +1,721 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 7] The API-description routes of `RestServer` — the discovery + * document (`GET {basePath}` and `GET {basePath}/discovery`) and the OpenAPI + * pair (`GET {basePath}/openapi.json` and its `GET {basePath}/docs` viewer) — + * moved out of `rest-server.ts` whole. Both registrars run once per mounted + * base, so an environment-scoped deployment mounts each family twice. + * + * A MOVE, not a redesign. The two functions below are the bodies of + * `RestServer#registerDiscoveryEndpoints` and + * `RestServer#registerOpenApiEndpoints` as they stood, each re-indented one + * level — except the viewer page's HTML, a template literal whose lines are + * runtime text and sit at column 0, kept byte for byte — the same routes, + * registered in the same order, by the same handlers, answering the same + * bodies. Every path expression (`basePath`, `realBase`, `unscopedBase`) is + * as written. The imports only these bodies read moved with them; the two + * registrars share ONE module because they are the one API-description family + * (`/discovery` advertises the surfaces `/openapi.json` describes), and they + * still share no code, exactly as before. + * + * ## How the moved bodies reach the class + * + * The shape slice 1 set (`data-action-routes.ts`' header carries the full + * reasoning), with slice 2's refinement. The class keeps both private methods + * — the published `dist/*.d.ts` lists their names, docblocks and positions — + * and each body is one host literal ({@link DiscoveryRouteHost}, + * {@link OpenApiRouteHost}) plus `.call(host, basePath)`. A method is handed + * over as a closure that calls it on the server at call time, so a test double + * or spy set on the server is still the one the body reaches. + * + * Each field was checked on its own, not inherited from an earlier slice: + * + * - `routeManager` goes BY VALUE. Both bodies read it only for their + * `register` calls, all of which run while registration runs, and the + * class's one `routeManager` reassignment wraps + * `registerMetadataEndpointsInner`, never these registrars. + * - `config` goes as a GETTER in both literals. Every read of it in either + * body is inside a handler, so it happens at REQUEST time, and the field is + * not `readonly`, so a value copied at registration could part from it. The + * getter reads the field the handler read, when it read it. + * + * ## The one line that is not byte-identical + * + * The OpenAPI body overlays the publisher's identity on the served `info` + * through the class's PRIVATE STATIC `overlayDocumentationInfo`, which it used + * to call as `RestServer.overlayDocumentationInfo(…)`. A private static is + * reachable by name only inside the class, so that spelling does not compile + * here, and making the member public, or moving it, changes the published + * declaration the move must keep byte-identical. So it is reached the way + * every other class member these bodies read is reached: through the host, as + * `this.overlayDocumentationInfo(…)`, a closure on {@link OpenApiRouteHost} + * that calls `RestServer.overlayDocumentationInfo` with the same two + * arguments. The member is pure (its only inputs are its arguments), so the + * receiver is the whole difference. That one call's receiver is the only token + * of either body that changed. + * + * Module-level helpers stay where they were: the one the discovery body reads + * from `rest-server.ts` itself, `DISCOVERY_ROUTE_KEY_TO_SERVICE_SLOTS`, was + * already exported, and is imported from there; it is read only at request + * time, so the two modules importing each other cannot see it uninitialised. + * No export was added anywhere, and the published surface does not move. + */ + +// [#4024] The flag half of the `/discovery` document's `mcp` advertisement: on +// unless `OS_MCP_SERVER_ENABLED=false` opts the environment out. The serveable +// half is the class's `probeMcpServeable`, reached through the host. +import { isMcpServerEnabled } from '@objectstack/types'; +import { handleRouteError, sendThrownError } from './error-response.js'; +import { logError } from './log.js'; +// [#5040 E6] The `api`-metadata endpoints the OpenAPI document folds in. +import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js'; +// [#5588] The built-in route section, produced from this server's own mounted +// routes rather than inherited from the static artifact. +import { buildBuiltinPaths } from './openapi-builtin-paths.js'; +// [#5224] Enumerated is not served: the matcher's own verdict on which declared +// endpoints the document describes. +import { selectServedEndpoints } from './served-endpoints.js'; +import { DISCOVERY_ROUTE_KEY_TO_SERVICE_SLOTS, type RestProtocol, type RestServer } from './rest-server.js'; + +/** + * The `RestServer` members {@link registerDiscoveryEndpoints} reads — every + * one of them, and no other. Each is typed AS the class member (an indexed + * access on the class), so a change to a member's signature on the class is a + * change here too, and naming a member the class does not have is a compile + * error. See the module header for why `config` is a getter. + */ +export interface DiscoveryRouteHost { + readonly config: RestServer['config']; + readonly routeManager: RestServer['routeManager']; + resolveProtocol: RestServer['resolveProtocol']; + probeMcpServeable: RestServer['probeMcpServeable']; + getDirectMountRouteBases: RestServer['getDirectMountRouteBases']; + getMountedEmailRouteBase: RestServer['getMountedEmailRouteBase']; +} + +/** + * The `RestServer` members {@link registerOpenApiEndpoints} reads — every one + * of them, and no other, typed as {@link DiscoveryRouteHost}'s are. Its last + * member is the class's private STATIC `overlayDocumentationInfo`, typed as + * the static it is; the module header says why the body reaches it through the + * host. See the module header for why `config` is a getter. + */ +export interface OpenApiRouteHost { + readonly config: RestServer['config']; + readonly routeManager: RestServer['routeManager']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; + loadOpenApiSpec: RestServer['loadOpenApiSpec']; + getRoutes: RestServer['getRoutes']; + resolveProtocol: RestServer['resolveProtocol']; + resolveEndpointMatchAuthority: RestServer['resolveEndpointMatchAuthority']; + notifyMissingEndpointAuthority: RestServer['notifyMissingEndpointAuthority']; + overlayDocumentationInfo: (typeof RestServer)['overlayDocumentationInfo']; +} + +/** + * Register the discovery endpoints — `RestServer#registerDiscoveryEndpoints`' + * body, called by that method with the server as `this`. The method's own + * docblock, which the published declaration carries, names the route. + */ +export function registerDiscoveryEndpoints(this: DiscoveryRouteHost, basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + const discoveryHandler = async (req: any, res: any) => { + try { + // [#9292] The document describes the environment the REQUEST + // names, not the control plane this server was constructed + // against. `this.protocol` is the host's; ~30 sibling + // handlers in this file obtain theirs from + // `resolveProtocol(environmentId, req)`, and `/discovery` — + // the surface SDKs, codegen and AI clients read (AGENTS.md + // "Route & surface ownership" #4) — was the one that did not. + // + // Measured on a two-kernel host before the fix: the scoped + // route `/environments//discovery` served the HOST's + // document to every environment, so two environments with + // genuinely different kernels received byte-identical + // `capabilities`, `services` and `locale`. Everything below + // this line composes over `discovery`, so the whole document + // followed the wrong kernel — not only the two capability + // keys derived from `engine.transaction` and `searchAll`, + // but every `services` slot, `locale` (from the kernel's own + // i18n service) and the route keys this handler does not + // itself overwrite (`analytics`, `automation`, `ai`, `i18n`, + // `notifications`, `realtime`, `storage`) — an environment's + // real surfaces missing, the host's advertised in their place. + // + // The unscoped route reaches the same resolution and keeps + // its control-plane answer where one is correct: with no + // environment in scope `resolveProtocol` falls through to + // `this.protocol`, so a control-plane boot is unchanged + // while a single-environment boot (step 3, the default + // provider) and a hostname-routed host now answer for the + // kernel that actually serves the request. + // + // The placeholder normalisation is `probeMcpServeable`'s, + // for its reason (#9120): an unsubstituted route pattern is + // the ABSENCE of an id, not an id, and the shared entry + // point short-circuits on any truthy explicit value — so + // `getOrCreate(':environmentId')` would go looking for a + // kernel named after the pattern. + const routeParam: string | undefined = req.params?.environmentId; + const environmentId = isScoped && routeParam !== ':environmentId' + ? routeParam + : undefined; + const protocol = await this.resolveProtocol(environmentId, req); + const discovery = await protocol.getDiscovery(); + + // [#11292] `version` is the PRODUCER's, and is deliberately + // NOT overwritten here. `DiscoverySchema` declares the field + // under "System Identity", grouped with `name` and + // `environment` — the "what server is this" question, settled + // by the #10993 ruling, landed by commits 98ea3443f and 376c70f98. + // + // This line used to read `discovery.version = + // this.config.api.version`, which is a different fact + // entirely: `normalizeConfig()` defaults it to `'v1'` and the + // SAME value builds the mounted path (`${basePath}/${version}` + // → `/api/v1`), so `GET /api/v1/discovery` answered with the + // path segment the caller had just typed to get there. On the + // one producer most clients actually hit, the identity field + // carried no identity. + // + // It also masked the producer. `getDiscovery()` derives the + // value from `OS_RUNTIME_VERSION` (commit 376c70f98) — the same stamp + // `/health` and the runtime dispatcher's own `/discovery` + // read (#10993, commit 98ea3443f) — so after #11297 this overwrote a + // value that already AGREED with the other producer, turning + // one answer back into two dialects of one field. + // + // The API-version fact is not lost: every entry in `routes` + // below is prefixed with the mounted base path, which is + // built from `api.version`. It is recoverable from the same + // document, in the field that means it. + + // Substitute the resolved environmentId into the advertised routes so + // clients can consume them verbatim (e.g. /api/v1/environments/abc/data). + const realBase = isScoped + ? basePath.replace(':environmentId', req.params?.environmentId ?? ':environmentId') + : basePath; + + if (discovery.routes) { + // Ensure routes match the actual mounted paths + if (this.config.api.enableCrud) { + discovery.routes.data = `${realBase}${this.config.crud.dataPrefix}`; + } + + if (this.config.api.enableMetadata) { + discovery.routes.metadata = `${realBase}${this.config.metadata.prefix}`; + } + + if (this.config.api.enableUi) { + discovery.routes.ui = `${realBase}/ui`; + } + + // MCP (Streamable HTTP) is a default-on core capability — + // advertise it unless OS_MCP_SERVER_ENABLED=false opts the + // env out, so the objectui Integrations page surfaces the + // connect card. The /mcp route is mounted bare (not + // project-scoped), so point at the unscoped base. This + // `/discovery` (served by @objectstack/rest) is separate + // from the dispatcher's getDiscoveryInfo — both must + // advertise `mcp` on the same terms. + // + // Enabled is NOT the same as serveable (#4024). The flag + // alone used to gate this, on the reasoning that `os serve` + // auto-loads plugin-mcp from the same flag. But that + // lockstep belongs to the CLI: `@objectstack/rest` has no + // `@objectstack/mcp` dependency, mounts no /mcp route and + // performs no auto-load, so an embedder that skips + // plugin-mcp had `mcp` advertised here while the route + // 501'd — the `declared ≠ enforced` failure #3369 forbids. + // A `null` probe means we genuinely cannot tell; keep the + // old flag-only answer there rather than hiding a working + // endpoint (fail-open, ADR-0057 D10) — the dispatcher's own + // discovery is service-aware and stays authoritative. + const mcpServeable = await this.probeMcpServeable(req); + if (isMcpServerEnabled() && mcpServeable !== false) { + const unscopedBase = isScoped + ? basePath.replace(/\/(environments|projects)\/:environmentId$/, '') + : basePath; + discovery.routes.mcp = `${unscopedBase}/mcp`; + } else { + delete discovery.routes.mcp; + } + + // Align auth route with the versioned base path if present. + // Auth is a control-plane concern, so use the unscoped base. + // + // [#16538] The strip names BOTH spellings, exactly as the MCP + // sibling above does. It used to name only the retired + // `/projects/:environmentId`, while `isScoped` — the condition + // guarding this very branch — keys on `/environments/:environmentId` + // alone. So the replace could never match where it ran: it returned + // `basePath` unchanged and a scoped `/discovery` advertised + // `/api/v1/environments/:environmentId/auth`, keeping both the scope + // this comment says to drop and a literal, unsubstituted route + // parameter. Pinned in `discovery-per-request-protocol.test.ts`. + if (discovery.routes.auth) { + const unscopedBase = isScoped + ? basePath.replace(/\/(environments|projects)\/:environmentId$/, '') + : basePath; + discovery.routes.auth = `${unscopedBase}/auth`; + } + + // [#6633] Direct-mount surfaces — the mounted ⇒ advertised + // half of ADR-0076 D12. `routes.packages` and + // `routes.datasources` are PROJECTIONS of the recorded + // direct mounts (#5822): the advertised base is read off + // the very route arrays the registrars iterated to mount, + // so advertisement and mounting derive from one fact and + // cannot drift. Since commit fec784863 those registrars mount at + // this server's own `getApiBasePath()` — the single + // base — so an `apiPath` deployment advertises + // `{apiPath}/packages` and `{apiPath}/datasources`. + // That move landed with NO edit in this block, which is + // exactly the property #6633 was built to provide. + // + // A boot that mounted nothing advertises nothing: the + // protocol's service-presence `packages` entry is + // deleted rather than left to promise a 404 — this + // server knows the mount fact, which is strictly better + // knowledge than service presence. [#14503] The package + // registrar's ONE route (`POST {base}/packages/publish`) + // mounts on every boot since #7563, so `routes.packages` + // is advertised on every boot at THIS server's base; the + // family's reads and delete are served by the runtime + // dispatcher's `/packages` domain, the single + // implementation. (While the base was keyed on the + // registrar's own `GET {base}/packages` copy — never + // mounted on a stock boot, where the `package` service + // registers after this plugin starts — a stock boot + // advertised no `routes.packages` at all.) + const direct = this.getDirectMountRouteBases( + isScoped ? (req.params?.environmentId ?? ':environmentId') : undefined, + ); + if (direct.packages) discovery.routes.packages = direct.packages; + else delete discovery.routes.packages; + if (direct.datasources) discovery.routes.datasources = direct.datasources; + else delete discovery.routes.datasources; + + // [#6714] Email surface — same mounted ⇒ advertised + // discipline, over the RouteManager recording: + // `registerEmailEndpoints` registers + // `POST {base}/email/send` at THIS server's base (it + // follows `apiPath`), and the advertisement is a + // projection of that recorded row — never recomputed — + // so the SDK's `getRoute('email')` follows the real + // mount instead of the `/api/v1` convention the client + // used to hard-code (a live 404 on any `apiPath` + // deployment). Not mounted ⇒ not advertised. + const emailBase = this.getMountedEmailRouteBase( + isScoped ? (req.params?.environmentId ?? ':environmentId') : undefined, + ); + if (emailBase) discovery.routes.email = emailBase; + else delete discovery.routes.email; + + // [#16674] Bring the OTHER half of the document in line + // with the same mounted paths. Everything above rewrites + // `discovery.routes.*`; `discovery.services.*.route` is + // the same address stated per slot, and it was never + // brought along -- so a deployment that moved a prefix + // got a document that contradicted itself, and the + // `services` half pointed at a path with nothing on it. + // + // A PROJECTION of the finished `routes` map, key by key + // (`DISCOVERY_ROUTE_KEY_TO_SERVICE_SLOTS` above), so the + // two halves cannot state different answers whatever a + // future substitution does to `routes`. The three guards + // are what keep a DEFAULT deployment byte-identical -- + // the whole point of the fix is that it moves only the + // values that are already wrong: + // + // 1. only a slot the producer actually emitted, and + // 2. only one that already declares a `route` -- a + // route-less slot (`cache`/`queue`/`job`, an + // unmounted `realtime` channel) must not GAIN a + // route key here: "no HTTP surface" is a fact this + // handler does not get to overwrite (#4318, D12), + // and inventing the key would also reorder the + // entry for every reader diffing the document; + // 3. only from a route key that survived the pass -- + // a deleted or absent `routes.X` leaves the slot + // alone rather than blanking it. Withdrawing an + // advertisement is the ADR-0076 D12 question + // #4318 owns, not this card's. + // + // On a stock boot every write here assigns the string + // that was already there, which is why the default + // document does not move by a byte. + const advertisedServices = discovery.services as + | Record + | undefined; + if (advertisedServices) { + const mountedRoutes = discovery.routes as unknown as Record; + for (const [routeKey, slots] of Object.entries(DISCOVERY_ROUTE_KEY_TO_SERVICE_SLOTS)) { + const mounted = mountedRoutes[routeKey]; + if (typeof mounted !== 'string' || mounted.length === 0) continue; + for (const slot of slots) { + const entry = advertisedServices[slot]; + if (!entry || typeof entry.route !== 'string') continue; + entry.route = mounted; + } + } + } + } + + // Cross-object atomic batch capability (#3298). `declared === + // enforced`: advertise it only when THIS server actually mounts + // the `/batch` route (`api.enableBatch`, gated in + // registerBatchEndpoints) AND the runtime engine can honour a + // transaction (the protocol derived that from `engine.transaction`). + // AND-ing the two keeps us from advertising an endpoint that would + // 404 (batch disabled) or 501 (engine without `transaction()`), so + // a client can safely drop its non-atomic fallback on `true`. + const caps = ((discovery as any).capabilities ??= {}) as Record< + string, + { enabled: boolean; description?: string } + >; + const runtimeSupportsTx = !!caps.transactionalBatch?.enabled; + caps.transactionalBatch = { + enabled: runtimeSupportsTx && this.config.api.enableBatch !== false, + description: + 'Atomic cross-object batch endpoint (POST {basePath}/batch): all-or-nothing ' + + 'create/update/delete across objects in one transaction, with intra-batch ' + + '{ $ref: } parent references (ADR-0034).', + }; + + // [#7541] Global search — the same two-layer AND, for the + // same reason. The protocol answered whether IT can serve a + // search (`typeof searchAll === 'function'`, the predicate + // `registerSearchEndpoints` 501s on); this server answers + // whether it MOUNTED the route at all (`api.enableSearch`, + // the flag gated in registerRoutes). A deployment that opts + // out gets a 404, so advertising the protocol's `true` + // unqualified would re-open the declared ≠ enforced gap one + // layer up from the one this issue closed. Neither half is a + // fallback for a wrong bit: each layer states the fact only + // it knows, and `enabled` is their conjunction. + // + // 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, + }; + + // Attach scoping metadata so clients can detect dual-mode routing. + (discovery as any).scoping = { + enabled: this.config.api.enableProjectScoping, + resolution: this.config.api.projectResolution, + scoped: isScoped, + environmentId: isScoped ? req.params?.environmentId : undefined, + }; + + res.json(discovery); + } catch (error: any) { + handleRouteError(res, error); + } + }; + + // Register at basePath (e.g. /api/v1) + this.routeManager.register({ + method: 'GET', + path: basePath, + handler: discoveryHandler, + metadata: { + summary: 'Get API discovery information', + tags: ['discovery'], + }, + }); + + // Register at basePath/discovery (e.g. /api/v1/discovery) + this.routeManager.register({ + method: 'GET', + path: `${basePath}/discovery`, + handler: discoveryHandler, + metadata: { + summary: 'Get API discovery information', + tags: ['discovery'], + }, + }); +} + +/** + * Register the OpenAPI document and its viewer — `RestServer#registerOpenApiEndpoints`' + * body, called by that method with the server as `this`. The method's own + * docblock, which the published declaration carries, names the two routes and + * the request-time enrichment of the document. + */ +export function registerOpenApiEndpoints(this: OpenApiRouteHost, basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + + // [#22430] Both API-description endpoints — the document and its + // viewer — refuse an anonymous caller with the shared anonymous-deny + // 401, the same body the `/data` and `/meta` routes answer (ADR-0056 D2 + // is default-deny, and no ADR-0138 D2 door class names these two). The + // refusal is each handler's FIRST act: before the bundled artifact is + // loaded and before any protocol is resolved, so a refused request + // costs no work and its answer does not even say whether a document is + // bundled here. A signed-in caller is served exactly as before, under + // the same ADR-0069 auth-policy gate every other protected route + // applies. The viewer page fetches the document from the browser, and + // that request carries the browser's session, so a signed-in browser is + // served on both endpoints. + + const openApiHandler = async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + + const spec = await this.loadOpenApiSpec(); + if (!spec) { + res.status?.(503); + res.json({ + error: { code: 'OPENAPI_UNAVAILABLE', message: 'OpenAPI spec is not bundled with this runtime.' }, + }); + return; + } + + // Clone shallowly so per-request mutations (server URL, + // expanded paths) don't bleed into the cached base spec. + let enriched: any = { ...spec, servers: [...(spec.servers ?? [])] }; + + // 1) Override servers[0] with the actual request origin so + // "Try it" works straight from the docs viewer. + const host = req.headers?.host ?? req.headers?.['host']; + const proto = (req.headers?.['x-forwarded-proto'] as string) + || (req.protocol as string) + || 'http'; + if (host) { + enriched.servers = [ + { url: `${proto}://${host}`, description: 'Current server' }, + ...(spec.servers ?? []), + ]; + } + + // 2) Produce the built-in route section from this server's own + // route table and DISCARD whatever the static artifact + // carried (#5588, maintainer ruling C). Every row here is a + // route the router will match: same table, read at request + // time, so the prefix follows `apiPath`, the verbs are the + // registered ones, and a route that is not mounted cannot be + // described. `getRoutes()` is the whole surface — since + // #5822 that includes the direct-mount registrars' routes, + // but only the ones this boot actually mounted and reported. + // The filtering to THIS base (and away from the project- + // scoped mirror, which gets its own document) is + // `buildBuiltinPaths`'s. + const builtin = buildBuiltinPaths(this.getRoutes(), basePath); + enriched.paths = builtin.paths; + // The tag list describes that same section, so it is produced + // with it rather than inherited from the artifact — otherwise a + // document whose operations carry rest's tags would advertise + // spec's (`CRUD`/`Metadata`/`Discovery`, which nothing uses). + enriched.tags = builtin.tags; + + // Metadata-driven enrichment (steps 3 and 4) reads through one + // resolved protocol, but each step carries its own `try`: they + // describe different surfaces, and a failure to enumerate one + // must not silently blank the other. + let protocol: RestProtocol | undefined; + try { + protocol = await this.resolveProtocol(environmentId, req); + } catch { + // Enrichment is best-effort — never fail the spec serve. + } + + // 3) Expand `{object}` path placeholders into concrete + // routes for every registered data object. Falls back + // silently if discovery isn't available. Since #5588 the + // templates it expands are the real ones (`/api/v1/data/ + // {object}` and its siblings), not the phantom `/api/ + // {object}`. + try { + const items = await protocol?.getMetaItems?.({ type: 'object' }).catch(() => null) as any; + const objects: string[] = Array.isArray(items?.items) + ? items.items.map((i: any) => i?.name).filter(Boolean) + : Array.isArray(items) + ? items.map((i: any) => i?.name).filter(Boolean) + : []; + if (objects.length > 0 && enriched.paths) { + const expanded: Record = {}; + for (const [p, def] of Object.entries(enriched.paths)) { + if (p.includes('{object}')) { + // Keep the template under x-template for tooling + // that wants the generic shape, and emit one + // concrete copy per registered object. + expanded[p] = { ...(def as object), 'x-template': true }; + for (const obj of objects) { + expanded[p.replace('{object}', obj)] = def; + } + } else { + expanded[p] = def; + } + } + enriched.paths = expanded; + } + } catch { + // Enrichment is best-effort — never fail the spec serve. + } + + // 4) Fold in the endpoints declared as `api` metadata (#5040 + // E6). Same enumeration root as the `{object}` expansion + // above — this document is the ONE documentation face for + // `/openapi.json`, which this package alone serves (#5078, + // ADR-0076): declared endpoints join it here rather than + // growing a second generator somewhere else. + // + // Since the E7 flip a non-empty `apis:` publishes, so this + // enumeration returns real declarations on a deployment that + // has them and the document grows a path entry per endpoint. + // Where nothing is declared the enumeration is empty and + // `enrichOpenApiWithEndpoints` hands `enriched` straight + // back, byte for byte. + try { + const apiResult = await protocol?.getMetaItems?.({ type: 'api' }); + const apiItems: unknown[] = Array.isArray((apiResult as any)?.items) + ? (apiResult as any).items + : Array.isArray(apiResult) ? apiResult as unknown[] : []; + const endpointLogger = { + error: (message: string, meta?: unknown) => + meta === undefined ? logError(message) : logError(message, meta), + }; + + // [#5224] Enumerated is not served. This document is what + // SDKs, codegen and AI clients build clients FROM, so it + // describes only the declarations the endpoint matcher will + // actually answer — asked of the matcher itself, the sole + // holder of that verdict. A stored row the matcher cannot + // see used to arrive here and be published as a real path, + // `security: []` and all, while every request to it 404'd. + let documentable: unknown[] = apiItems; + const authority = apiItems.length > 0 + ? await this.resolveEndpointMatchAuthority( + isScoped ? req.params?.environmentId : undefined, + req, + ) + : undefined; + if (apiItems.length > 0 && !authority) { + this.notifyMissingEndpointAuthority('GET /openapi.json'); + } else if (authority) { + // The matcher's OWN parsed endpoint is documented, not + // the stored JSON: schema defaults are materialized on + // it (most importantly `authRequired`), so the document + // describes the value the runtime acts on rather than a + // re-parse of the same row on a second code path. + documentable = (await selectServedEndpoints(apiItems, authority, endpointLogger)) + .map((s) => s.endpoint); + } + + enriched = enrichOpenApiWithEndpoints(enriched, documentable, endpointLogger); + } catch (err: any) { + // A store that cannot be read must not take the document + // down with it — but say so, because a silently endpoint- + // less document looks exactly like a correct one. + logError('[REST] openapi.json endpoint enrichment skipped:', err?.message ?? err); + } + + // 5) `info`: the artifact's, with the publisher's identity laid + // over it (#20294, ruling B on #20359). `packages/spec` + // produces the block (`build-openapi.ts`, pinned by + // `openapi-self-consistency.test.ts`); the host may sign it + // with the identity members of `api.documentation` — + // `title`, `description`, `termsOfService`, and `contact` / + // `license` each replaced whole. Nothing authored serves the + // artifact's `info` byte for byte, so the served document and + // the published `@objectstack/spec/openapi.json` export still + // state the same fact about every field nobody signed + // (#11646's invariant, now the unset case). The same closure + // serves this base and its environment-scoped twin, so both + // doors carry the overlay. The helper returns a NEW object: + // `enriched.info` is still the cached artifact's own `info` + // here (the clone above is shallow), and writing into it + // would leak one request's overlay into every later one. + // + // `info.version` is NOT publisher identity, and nothing here + // writes it: it stays the artifact's — the protocol version + // (#11646) — and `api.documentation.version` is a retired + // tombstone the construction-time parse already refused. The + // API version identifier this deployment declares + // (`api.version`, which `normalizeConfig` defaults to `'v1'`) + // lives where it is observable, in the mount + // `${basePath}/${version}` -> `/api/v1`. The runtime version + // is answered by `{basePath}/discovery` and `/health`, derived + // from `OS_RUNTIME_VERSION` (#10993, commit 376c70f98, #11292). OpenAPI + // 3.1 defines this field as "the version of the OpenAPI + // document (which is distinct from the OpenAPI Specification + // version or the API implementation version)" — the document + // being served IS the artifact, so its version is the + // artifact's. + enriched.info = this.overlayDocumentationInfo(enriched.info, this.config.api.documentation); + + res.json(enriched); + } catch (error: any) { + logError('[REST] openapi.json error:', error); + sendThrownError(res, error); + } + }; + + this.routeManager.register({ + method: 'GET', + path: `${basePath}/openapi.json`, + handler: openApiHandler, + metadata: { + summary: 'OpenAPI 3.1 specification (machine-readable)', + tags: ['openapi'], + }, + }); + + // Scalar HTML viewer — single inline page that loads the spec from + // the sibling /openapi.json endpoint. No build-time bundling, no + // server-side render cost. + this.routeManager.register({ + method: 'GET', + path: `${basePath}/docs`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + + // Resolve the openapi.json URL relative to the current + // request so the docs page works for any host / scoped + // base path (e.g. /api/v1 vs /api/v1/environments/abc). + const reqPath: string = req.path || req.url || `${basePath}/docs`; + // Strip the trailing /docs to get the API base. + const apiBase = reqPath.replace(/\/docs\/?$/, ''); + const specUrl = `${apiBase}/openapi.json`; + const html = ` + + + + +ObjectStack API Docs + + + + + +`; + if (res.setHeader) res.setHeader('content-type', 'text/html; charset=utf-8'); + if (res.send) res.send(html); + else if (res.body) res.body = html; + else res.json?.(html); + } catch (error: any) { + // Only the identity read above can throw here (a + // permission-store outage), which keeps its declared answer. + logError('[REST] docs viewer error:', error); + sendThrownError(res, error); + } + }, + metadata: { + summary: 'Interactive API docs (Scalar viewer)', + tags: ['openapi'], + }, + }); +} diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index b313e9721f0..22794983124 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -97,6 +97,12 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * [#23050 slice 6] …and `registerSecurityExplainEndpoints`' and * `registerSecurityEndpoints`' bodies to `security-routes.ts`, with their five * (two and three) — the second module on the list to carry TWO registrars. + * + * [#23050 slice 7] …and `registerDiscoveryEndpoints`' and + * `registerOpenApiEndpoints`' bodies to `discovery-routes.ts`, with their two + * (none and two) — the third module to carry TWO registrars, and the first + * moved registrar whose body reads no `resolveExecCtx` at all (the discovery + * document resolves no caller). */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -109,14 +115,16 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * moved registrar (`registerDataActionEndpoints`, `registerBatchEndpoints`, * `registerApprovalsEndpoints`, `registerSharingEndpoints`, * `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, - * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`) hands - * its moved body forwards each member to the server with - * `(...a) => this.(...a)`, so a moved site's stack shows that forwarder - * above the site itself. The site is the first censused frame that is not one. - * Found by their spelling, and §2 pins ONE per moved REGISTRAR — one per moved - * module, plus {@link EXTRA_MOVED_REGISTRARS} — so a respelt forwarder — or a - * module moved without joining `SITE_FILES` — is a control failure there rather - * than every moved site silently attributed to it. + * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, + * `registerOpenApiEndpoints`) hands its moved body forwards each member to the + * server with `(...a) => this.(...a)`, so a moved site's stack shows + * that forwarder above the site itself. The site is the first censused frame + * that is not one. Found by their spelling, and §2 pins ONE per moved REGISTRAR + * whose body reads the seam — one per moved module, plus + * {@link EXTRA_MOVED_REGISTRARS}, less {@link MOVED_REGISTRARS_READING_NO_EXECCTX} + * — so a respelt forwarder — or a module moved without joining `SITE_FILES` — + * is a control failure there rather than every moved site silently attributed + * to it. */ const FORWARDER_LINES = SOURCE.split('\n').flatMap((l, i) => l.includes('resolveExecCtx: (...a) => this.resolveExecCtx(...a),') ? [i + 1] : []); @@ -127,10 +135,28 @@ const FORWARDERS = new Set(FORWARDER_LINES.map((line) => siteKey('rest-server.ts * own host literal and so through its own forwarder. * * [#23050 slice 6] `security-routes.ts` carries two as well, on the same terms. + * + * [#23050 slice 7] So does `discovery-routes.ts` (the discovery and OpenAPI + * bodies), each handed over by its own host literal — but see + * {@link MOVED_REGISTRARS_READING_NO_EXECCTX}: only one of its two literals + * carries the forwarder. */ const EXTRA_MOVED_REGISTRARS: Readonly>> = { 'sharing-routes.ts': 1, 'security-routes.ts': 1, + 'discovery-routes.ts': 1, +}; +/** + * [#23050 slice 7] The moved registrars whose bodies read no `resolveExecCtx`, + * so whose host literals hand over no forwarder — measured by the AST walk of + * the body, not assumed. `registerDiscoveryEndpoints`' body resolves no caller + * (the discovery document is served to anyone), so its host literal names no + * `resolveExecCtx` member; its module's other registrar, the OpenAPI pair, + * reads it twice and carries the forwarder. A body that starts reading the + * seam gains a forwarder, and §2's exact count reds until this entry goes. + */ +const MOVED_REGISTRARS_READING_NO_EXECCTX: Readonly>> = { + 'discovery-routes.ts': 1, }; /** A stack frame in any censused file, as `::`. */ @@ -616,13 +642,27 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // members, four). Eight forwarders now, one per moved registrar: // (7 - 1) + 2, the second module carrying two registrars counted in // `EXTRA_MOVED_REGISTRARS`. - expect(SITE_FILES).toHaveLength(7); + // + // [#23050 slice 7] 62 sites UNCHANGED / 109 → 113 mentions — the same MOVE. + // `registerOpenApiEndpoints`' body left for `discovery-routes.ts` with its + // two BARE sites (the document and its viewer), and + // `registerDiscoveryEndpoints`' with none: 25 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + // = 62 sites. The four new mentions are this slice's seam, the two + // spellings ONCE (one host literal's forwarder, two; one host + // interface's member, two): `rest-server.ts` stays at 57 (two sites + // out, one forwarder's two mentions in). Nine forwarders now: + // (8 - 1) + 3 - 1 — the third module carrying two registrars counted in + // `EXTRA_MOVED_REGISTRARS`, and the discovery registrar, which reads no + // `resolveExecCtx`, in `MOVED_REGISTRARS_READING_NO_EXECCTX`. + expect(SITE_FILES).toHaveLength(8); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(109); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(113); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); - expect(extraMovedRegistrars).toBe(2); - expect(FORWARDER_LINES, 'one host-literal resolveExecCtx forwarder per moved registrar: one was respelt or removed, or a module moved without joining SITE_FILES') - .toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars); + expect(extraMovedRegistrars).toBe(3); + const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0); + expect(registrarsReadingNoExecCtx).toBe(1); + expect(FORWARDER_LINES, 'one host-literal resolveExecCtx forwarder per moved registrar whose body reads it: one was respelt or removed, or a module moved without joining SITE_FILES') + .toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars - registrarsReadingNoExecCtx); }); it('the split is 16 locally caught / 46 bare — NOT 12 / 46, which does not add to 62', () => { diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index bd13662e699..91382350b93 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -248,8 +248,9 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // never its file, so no ledger row changed: 8 = 6 + 0 + 0 + 0 + 0 + 2, // length 5 → 6. [#23050 slice 6] The two security bodies // (`security-routes.ts`) read no draft: 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0, - // length 6 → 7. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); + // length 6 → 7. [#23050 slice 7] Nor do the discovery and OpenAPI bodies + // (`discovery-routes.ts`): 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0, length 7 → 8. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/openapi-builtin-paths.ts b/packages/rest/src/openapi-builtin-paths.ts index ea9318675fd..b28c0f801f6 100644 --- a/packages/rest/src/openapi-builtin-paths.ts +++ b/packages/rest/src/openapi-builtin-paths.ts @@ -29,8 +29,9 @@ * owns — `components.schemas`, `info`, `securitySchemes`. * * This module is the rest half. It reads the routes the server has actually - * mounted and returns the `paths` section for them; `rest-server.ts` installs - * it in place of whatever the static artifact carried, so the stale section + * mounted and returns the `paths` section for them; the OpenAPI document's + * handler (`discovery-routes.ts` since #23050 slice 7, `rest-server.ts` before) + * installs it in place of whatever the static artifact carried, so the stale section * cannot leak through even while the spec-side generation is still there * (leg 2 = #5744, blocked by this one). Discarding rather than merging is the * point: a serve-time merge with a wrong static section republishes the wrong @@ -236,8 +237,9 @@ export interface BuiltinPathsResult { /** * Build the built-in section for one base path from the routes mounted under it. * - * Pure: no clock, no I/O, no server. `rest-server.ts` hands it - * `routeManager.getAll()` at request time; the tests hand it fixtures. + * Pure: no clock, no I/O, no server. The OpenAPI document's handler + * (`discovery-routes.ts` since #23050 slice 7, `rest-server.ts` before) hands + * it `routeManager.getAll()` at request time; the tests hand it fixtures. */ export function buildBuiltinPaths( routes: readonly BuiltinRouteFact[], diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 182adf2a1a6..89ae597a490 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -140,6 +140,11 @@ interface CensusEntry { * (`security-routes.ts`) build no `query:` literal either — the explain doors * hand the security service a parsed explain request, and the suggestion list * a `status` / `packageId` pair read off `req.query`, never a `query:` slot. + * + * [#23050 slice 7] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0: the discovery and + * OpenAPI bodies (`discovery-routes.ts`) build no `query:` literal either — + * they read the protocol's discovery document and its `getMetaItems` listings + * by `type`, never through a `query:` slot. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 1, @@ -149,6 +154,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // widening unnoticed. [#23050 slice 4] Length 4 → 5, the sum unchanged. // [#23050 slice 5] Length 5 → 6, the sum unchanged. // [#23050 slice 6] Length 6 → 7, the sum unchanged. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); + // [#23050 slice 7] Length 7 → 8, the sum unchanged. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index bc0b1e39f71..fa6e8df786b 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -160,6 +160,11 @@ describe('rest-server.ts docblock position', () => { * `respondError` and `delegableHandler` blocks, and the suggested-binding * family's `respondError` block), which `rest-server.ts` therefore no longer * holds (167 → 164 there, still far above its floor). + * + * [#23050 slice 7] `discovery-routes.ts` carries five: the module header, two + * host interfaces' blocks and two functions' (it holds two registrars' bodies). + * Neither the discovery body nor the OpenAPI body held a TSDoc block of its + * own, so `rest-server.ts` keeps every block it had (164). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -168,6 +173,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -198,9 +204,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 4] 4 + 3 + 3 + 8 = 18 over four, length 4 → 5. // [#23050 slice 5] 4 + 3 + 3 + 8 + 3 = 21 over five, length 5 → 6. // [#23050 slice 6] 4 + 3 + 3 + 8 + 3 + 8 = 29 over six, length 6 → 7. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); + // [#23050 slice 7] 4 + 3 + 3 + 8 + 3 + 8 + 5 = 34 over seven, length 7 → 8. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(29); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(34); }); }); diff --git a/packages/rest/src/rest-server-query-number-census.test.ts b/packages/rest/src/rest-server-query-number-census.test.ts index 93f392334a8..250ca27ede9 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -322,8 +322,10 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // length 4 → 5. [#23050 slice 5] Nor does the analytics dataset body // (`analytics-routes.ts`): 9 = 8 + 1 + 0 + 0 + 0 + 0, length 5 → 6. // [#23050 slice 6] Nor do the two security bodies (`security-routes.ts`): - // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0, length 6 → 7. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0, length 6 → 7. [#23050 slice 7] Nor do + // the discovery and OpenAPI bodies (`discovery-routes.ts`): + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0, length 7 → 8. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 7a75b2bc026..0094ef9c013 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -41,6 +41,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'analytics-routes.ts', // [#23050 slice 6] `registerSecurityExplainEndpoints`' and `registerSecurityEndpoints`' bodies. 'security-routes.ts', + // [#23050 slice 7] `registerDiscoveryEndpoints`' and `registerOpenApiEndpoints`' bodies. + 'discovery-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 4867a14302b..8f098de155e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -30,7 +30,8 @@ import { // write-response rules, moved with that door to `batch-routes.ts` (#23050 slice 2). } from '@objectstack/core'; import { - isMcpServerEnabled, + // `isMcpServerEnabled`, the flag half of the `/discovery` document's `mcp` advertisement, + // moved with `registerDiscoveryEndpoints`' body to `discovery-routes.ts` (#23050 slice 7). // `looksLikeInternalErrorLeak`, `declaresServerFault` and `INTERNAL_ERROR_MESSAGE`, the // analytics dataset door's fault-withhold reads, moved with that door to // `analytics-routes.ts` (#23050 slice 5). @@ -331,6 +332,13 @@ import { type SecurityExplainRouteHost, type SecurityRouteHost, } from './security-routes.js'; +// [#23050 slice 7] The discovery and OpenAPI routes' bodies moved to one module — see its header. +import { + registerDiscoveryEndpoints, + registerOpenApiEndpoints, + type DiscoveryRouteHost, + type OpenApiRouteHost, +} from './discovery-routes.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; // [#18386] `?template=true` on the export door: the import template's column // rule, request reading and workbook. See the module header. @@ -342,11 +350,11 @@ import { templateText, type TemplateProjectionSource, } from './import-template.js'; -import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js'; -import { buildBuiltinPaths } from './openapi-builtin-paths.js'; +// `enrichOpenApiWithEndpoints` (#5040 E6), `buildBuiltinPaths` (#5588) and +// `selectServedEndpoints` (#5224), read only by the OpenAPI document's handler, moved with +// `registerOpenApiEndpoints`' body to `discovery-routes.ts` (#23050 slice 7). import { isEndpointMatchAuthority, - selectServedEndpoints, type EndpointMatchAuthority, } from './served-endpoints.js'; @@ -366,7 +374,8 @@ import { // `security-routes.ts` (#23050 slice 6). boundedDeclaredRefusalMessage, declaredHttpStatus, - sendThrownError, + // `sendThrownError`, the OpenAPI document's and viewer's catch-all answer, moved with + // `registerOpenApiEndpoints`' body to `discovery-routes.ts` (#23050 slice 7). sendDeclaredFault, sendFieldVisibilityFault, handleRouteError, @@ -849,7 +858,8 @@ export function readDeclaredQueryNumber( * * `/discovery` states the same fact twice -- `routes.X` (the flat convenience * map) and `services.Y.route` (the per-slot entry) -- and the discovery handler - * below rewrites only the first half to the paths this server actually mounts. + * (in `discovery-routes.ts` since #23050 slice 7, which imports this table) + * rewrites only the first half to the paths this server actually mounts. * Measured on this file's own composition harness with * `crud: { dataPrefix: '/objects' }`: `routes.data` answered `/api/v1/objects` * while `services.data.route` still answered `/api/v1/data`, a path with @@ -4911,328 +4921,20 @@ export class RestServer { * Register discovery endpoints */ private registerDiscoveryEndpoints(basePath: string): void { - const isScoped = basePath.includes('/environments/:environmentId'); - const discoveryHandler = async (req: any, res: any) => { - try { - // [#9292] The document describes the environment the REQUEST - // names, not the control plane this server was constructed - // against. `this.protocol` is the host's; ~30 sibling - // handlers in this file obtain theirs from - // `resolveProtocol(environmentId, req)`, and `/discovery` — - // the surface SDKs, codegen and AI clients read (AGENTS.md - // "Route & surface ownership" #4) — was the one that did not. - // - // Measured on a two-kernel host before the fix: the scoped - // route `/environments//discovery` served the HOST's - // document to every environment, so two environments with - // genuinely different kernels received byte-identical - // `capabilities`, `services` and `locale`. Everything below - // this line composes over `discovery`, so the whole document - // followed the wrong kernel — not only the two capability - // keys derived from `engine.transaction` and `searchAll`, - // but every `services` slot, `locale` (from the kernel's own - // i18n service) and the route keys this handler does not - // itself overwrite (`analytics`, `automation`, `ai`, `i18n`, - // `notifications`, `realtime`, `storage`) — an environment's - // real surfaces missing, the host's advertised in their place. - // - // The unscoped route reaches the same resolution and keeps - // its control-plane answer where one is correct: with no - // environment in scope `resolveProtocol` falls through to - // `this.protocol`, so a control-plane boot is unchanged - // while a single-environment boot (step 3, the default - // provider) and a hostname-routed host now answer for the - // kernel that actually serves the request. - // - // The placeholder normalisation is `probeMcpServeable`'s, - // for its reason (#9120): an unsubstituted route pattern is - // the ABSENCE of an id, not an id, and the shared entry - // point short-circuits on any truthy explicit value — so - // `getOrCreate(':environmentId')` would go looking for a - // kernel named after the pattern. - const routeParam: string | undefined = req.params?.environmentId; - const environmentId = isScoped && routeParam !== ':environmentId' - ? routeParam - : undefined; - const protocol = await this.resolveProtocol(environmentId, req); - const discovery = await protocol.getDiscovery(); - - // [#11292] `version` is the PRODUCER's, and is deliberately - // NOT overwritten here. `DiscoverySchema` declares the field - // under "System Identity", grouped with `name` and - // `environment` — the "what server is this" question, settled - // by the #10993 ruling, landed by commits 98ea3443f and 376c70f98. - // - // This line used to read `discovery.version = - // this.config.api.version`, which is a different fact - // entirely: `normalizeConfig()` defaults it to `'v1'` and the - // SAME value builds the mounted path (`${basePath}/${version}` - // → `/api/v1`), so `GET /api/v1/discovery` answered with the - // path segment the caller had just typed to get there. On the - // one producer most clients actually hit, the identity field - // carried no identity. - // - // It also masked the producer. `getDiscovery()` derives the - // value from `OS_RUNTIME_VERSION` (commit 376c70f98) — the same stamp - // `/health` and the runtime dispatcher's own `/discovery` - // read (#10993, commit 98ea3443f) — so after #11297 this overwrote a - // value that already AGREED with the other producer, turning - // one answer back into two dialects of one field. - // - // The API-version fact is not lost: every entry in `routes` - // below is prefixed with the mounted base path, which is - // built from `api.version`. It is recoverable from the same - // document, in the field that means it. - - // Substitute the resolved environmentId into the advertised routes so - // clients can consume them verbatim (e.g. /api/v1/environments/abc/data). - const realBase = isScoped - ? basePath.replace(':environmentId', req.params?.environmentId ?? ':environmentId') - : basePath; - - if (discovery.routes) { - // Ensure routes match the actual mounted paths - if (this.config.api.enableCrud) { - discovery.routes.data = `${realBase}${this.config.crud.dataPrefix}`; - } - - if (this.config.api.enableMetadata) { - discovery.routes.metadata = `${realBase}${this.config.metadata.prefix}`; - } - - if (this.config.api.enableUi) { - discovery.routes.ui = `${realBase}/ui`; - } - - // MCP (Streamable HTTP) is a default-on core capability — - // advertise it unless OS_MCP_SERVER_ENABLED=false opts the - // env out, so the objectui Integrations page surfaces the - // connect card. The /mcp route is mounted bare (not - // project-scoped), so point at the unscoped base. This - // `/discovery` (served by @objectstack/rest) is separate - // from the dispatcher's getDiscoveryInfo — both must - // advertise `mcp` on the same terms. - // - // Enabled is NOT the same as serveable (#4024). The flag - // alone used to gate this, on the reasoning that `os serve` - // auto-loads plugin-mcp from the same flag. But that - // lockstep belongs to the CLI: `@objectstack/rest` has no - // `@objectstack/mcp` dependency, mounts no /mcp route and - // performs no auto-load, so an embedder that skips - // plugin-mcp had `mcp` advertised here while the route - // 501'd — the `declared ≠ enforced` failure #3369 forbids. - // A `null` probe means we genuinely cannot tell; keep the - // old flag-only answer there rather than hiding a working - // endpoint (fail-open, ADR-0057 D10) — the dispatcher's own - // discovery is service-aware and stays authoritative. - const mcpServeable = await this.probeMcpServeable(req); - if (isMcpServerEnabled() && mcpServeable !== false) { - const unscopedBase = isScoped - ? basePath.replace(/\/(environments|projects)\/:environmentId$/, '') - : basePath; - discovery.routes.mcp = `${unscopedBase}/mcp`; - } else { - delete discovery.routes.mcp; - } - - // Align auth route with the versioned base path if present. - // Auth is a control-plane concern, so use the unscoped base. - // - // [#16538] The strip names BOTH spellings, exactly as the MCP - // sibling above does. It used to name only the retired - // `/projects/:environmentId`, while `isScoped` — the condition - // guarding this very branch — keys on `/environments/:environmentId` - // alone. So the replace could never match where it ran: it returned - // `basePath` unchanged and a scoped `/discovery` advertised - // `/api/v1/environments/:environmentId/auth`, keeping both the scope - // this comment says to drop and a literal, unsubstituted route - // parameter. Pinned in `discovery-per-request-protocol.test.ts`. - if (discovery.routes.auth) { - const unscopedBase = isScoped - ? basePath.replace(/\/(environments|projects)\/:environmentId$/, '') - : basePath; - discovery.routes.auth = `${unscopedBase}/auth`; - } - - // [#6633] Direct-mount surfaces — the mounted ⇒ advertised - // half of ADR-0076 D12. `routes.packages` and - // `routes.datasources` are PROJECTIONS of the recorded - // direct mounts (#5822): the advertised base is read off - // the very route arrays the registrars iterated to mount, - // so advertisement and mounting derive from one fact and - // cannot drift. Since commit fec784863 those registrars mount at - // this server's own `getApiBasePath()` — the single - // base — so an `apiPath` deployment advertises - // `{apiPath}/packages` and `{apiPath}/datasources`. - // That move landed with NO edit in this block, which is - // exactly the property #6633 was built to provide. - // - // A boot that mounted nothing advertises nothing: the - // protocol's service-presence `packages` entry is - // deleted rather than left to promise a 404 — this - // server knows the mount fact, which is strictly better - // knowledge than service presence. [#14503] The package - // registrar's ONE route (`POST {base}/packages/publish`) - // mounts on every boot since #7563, so `routes.packages` - // is advertised on every boot at THIS server's base; the - // family's reads and delete are served by the runtime - // dispatcher's `/packages` domain, the single - // implementation. (While the base was keyed on the - // registrar's own `GET {base}/packages` copy — never - // mounted on a stock boot, where the `package` service - // registers after this plugin starts — a stock boot - // advertised no `routes.packages` at all.) - const direct = this.getDirectMountRouteBases( - isScoped ? (req.params?.environmentId ?? ':environmentId') : undefined, - ); - if (direct.packages) discovery.routes.packages = direct.packages; - else delete discovery.routes.packages; - if (direct.datasources) discovery.routes.datasources = direct.datasources; - else delete discovery.routes.datasources; - - // [#6714] Email surface — same mounted ⇒ advertised - // discipline, over the RouteManager recording: - // `registerEmailEndpoints` registers - // `POST {base}/email/send` at THIS server's base (it - // follows `apiPath`), and the advertisement is a - // projection of that recorded row — never recomputed — - // so the SDK's `getRoute('email')` follows the real - // mount instead of the `/api/v1` convention the client - // used to hard-code (a live 404 on any `apiPath` - // deployment). Not mounted ⇒ not advertised. - const emailBase = this.getMountedEmailRouteBase( - isScoped ? (req.params?.environmentId ?? ':environmentId') : undefined, - ); - if (emailBase) discovery.routes.email = emailBase; - else delete discovery.routes.email; - - // [#16674] Bring the OTHER half of the document in line - // with the same mounted paths. Everything above rewrites - // `discovery.routes.*`; `discovery.services.*.route` is - // the same address stated per slot, and it was never - // brought along -- so a deployment that moved a prefix - // got a document that contradicted itself, and the - // `services` half pointed at a path with nothing on it. - // - // A PROJECTION of the finished `routes` map, key by key - // (`DISCOVERY_ROUTE_KEY_TO_SERVICE_SLOTS` above), so the - // two halves cannot state different answers whatever a - // future substitution does to `routes`. The three guards - // are what keep a DEFAULT deployment byte-identical -- - // the whole point of the fix is that it moves only the - // values that are already wrong: - // - // 1. only a slot the producer actually emitted, and - // 2. only one that already declares a `route` -- a - // route-less slot (`cache`/`queue`/`job`, an - // unmounted `realtime` channel) must not GAIN a - // route key here: "no HTTP surface" is a fact this - // handler does not get to overwrite (#4318, D12), - // and inventing the key would also reorder the - // entry for every reader diffing the document; - // 3. only from a route key that survived the pass -- - // a deleted or absent `routes.X` leaves the slot - // alone rather than blanking it. Withdrawing an - // advertisement is the ADR-0076 D12 question - // #4318 owns, not this card's. - // - // On a stock boot every write here assigns the string - // that was already there, which is why the default - // document does not move by a byte. - const advertisedServices = discovery.services as - | Record - | undefined; - if (advertisedServices) { - const mountedRoutes = discovery.routes as unknown as Record; - for (const [routeKey, slots] of Object.entries(DISCOVERY_ROUTE_KEY_TO_SERVICE_SLOTS)) { - const mounted = mountedRoutes[routeKey]; - if (typeof mounted !== 'string' || mounted.length === 0) continue; - for (const slot of slots) { - const entry = advertisedServices[slot]; - if (!entry || typeof entry.route !== 'string') continue; - entry.route = mounted; - } - } - } - } - - // Cross-object atomic batch capability (#3298). `declared === - // enforced`: advertise it only when THIS server actually mounts - // the `/batch` route (`api.enableBatch`, gated in - // registerBatchEndpoints) AND the runtime engine can honour a - // transaction (the protocol derived that from `engine.transaction`). - // AND-ing the two keeps us from advertising an endpoint that would - // 404 (batch disabled) or 501 (engine without `transaction()`), so - // a client can safely drop its non-atomic fallback on `true`. - const caps = ((discovery as any).capabilities ??= {}) as Record< - string, - { enabled: boolean; description?: string } - >; - const runtimeSupportsTx = !!caps.transactionalBatch?.enabled; - caps.transactionalBatch = { - enabled: runtimeSupportsTx && this.config.api.enableBatch !== false, - description: - 'Atomic cross-object batch endpoint (POST {basePath}/batch): all-or-nothing ' - + 'create/update/delete across objects in one transaction, with intra-batch ' - + '{ $ref: } parent references (ADR-0034).', - }; - - // [#7541] Global search — the same two-layer AND, for the - // same reason. The protocol answered whether IT can serve a - // search (`typeof searchAll === 'function'`, the predicate - // `registerSearchEndpoints` 501s on); this server answers - // whether it MOUNTED the route at all (`api.enableSearch`, - // the flag gated in registerRoutes). A deployment that opts - // out gets a 404, so advertising the protocol's `true` - // unqualified would re-open the declared ≠ enforced gap one - // layer up from the one this issue closed. Neither half is a - // fallback for a wrong bit: each layer states the fact only - // it knows, and `enabled` is their conjunction. - // - // 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, - }; - - // Attach scoping metadata so clients can detect dual-mode routing. - (discovery as any).scoping = { - enabled: this.config.api.enableProjectScoping, - resolution: this.config.api.projectResolution, - scoped: isScoped, - environmentId: isScoped ? req.params?.environmentId : undefined, - }; - - res.json(discovery); - } catch (error: any) { - handleRouteError(res, error); - } - }; - - // Register at basePath (e.g. /api/v1) - this.routeManager.register({ - method: 'GET', - path: basePath, - handler: discoveryHandler, - metadata: { - summary: 'Get API discovery information', - tags: ['discovery'], - }, - }); - - // Register at basePath/discovery (e.g. /api/v1/discovery) - this.routeManager.register({ - method: 'GET', - path: `${basePath}/discovery`, - handler: discoveryHandler, - metadata: { - summary: 'Get API discovery information', - tags: ['discovery'], - }, - }); + // [#23050 slice 7] The body moved to `discovery-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `DiscoveryRouteHost`, and that module's header for why + // `config` is a getter). + const readConfig = () => this.config; + const host: DiscoveryRouteHost = { + get config() { return readConfig(); }, + routeManager: this.routeManager, + resolveProtocol: (...a) => this.resolveProtocol(...a), + probeMcpServeable: (...a) => this.probeMcpServeable(...a), + getDirectMountRouteBases: (...a) => this.getDirectMountRouteBases(...a), + getMountedEmailRouteBase: (...a) => this.getMountedEmailRouteBase(...a), + }; + registerDiscoveryEndpoints.call(host, basePath); } /** @@ -5321,273 +5023,25 @@ export class RestServer { * artifact's. */ private registerOpenApiEndpoints(basePath: string): void { - const isScoped = basePath.includes('/environments/:environmentId'); - - // [#22430] Both API-description endpoints — the document and its - // viewer — refuse an anonymous caller with the shared anonymous-deny - // 401, the same body the `/data` and `/meta` routes answer (ADR-0056 D2 - // is default-deny, and no ADR-0138 D2 door class names these two). The - // refusal is each handler's FIRST act: before the bundled artifact is - // loaded and before any protocol is resolved, so a refused request - // costs no work and its answer does not even say whether a document is - // bundled here. A signed-in caller is served exactly as before, under - // the same ADR-0069 auth-policy gate every other protected route - // applies. The viewer page fetches the document from the browser, and - // that request carries the browser's session, so a signed-in browser is - // served on both endpoints. - - const openApiHandler = async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - - const spec = await this.loadOpenApiSpec(); - if (!spec) { - res.status?.(503); - res.json({ - error: { code: 'OPENAPI_UNAVAILABLE', message: 'OpenAPI spec is not bundled with this runtime.' }, - }); - return; - } - - // Clone shallowly so per-request mutations (server URL, - // expanded paths) don't bleed into the cached base spec. - let enriched: any = { ...spec, servers: [...(spec.servers ?? [])] }; - - // 1) Override servers[0] with the actual request origin so - // "Try it" works straight from the docs viewer. - const host = req.headers?.host ?? req.headers?.['host']; - const proto = (req.headers?.['x-forwarded-proto'] as string) - || (req.protocol as string) - || 'http'; - if (host) { - enriched.servers = [ - { url: `${proto}://${host}`, description: 'Current server' }, - ...(spec.servers ?? []), - ]; - } - - // 2) Produce the built-in route section from this server's own - // route table and DISCARD whatever the static artifact - // carried (#5588, maintainer ruling C). Every row here is a - // route the router will match: same table, read at request - // time, so the prefix follows `apiPath`, the verbs are the - // registered ones, and a route that is not mounted cannot be - // described. `getRoutes()` is the whole surface — since - // #5822 that includes the direct-mount registrars' routes, - // but only the ones this boot actually mounted and reported. - // The filtering to THIS base (and away from the project- - // scoped mirror, which gets its own document) is - // `buildBuiltinPaths`'s. - const builtin = buildBuiltinPaths(this.getRoutes(), basePath); - enriched.paths = builtin.paths; - // The tag list describes that same section, so it is produced - // with it rather than inherited from the artifact — otherwise a - // document whose operations carry rest's tags would advertise - // spec's (`CRUD`/`Metadata`/`Discovery`, which nothing uses). - enriched.tags = builtin.tags; - - // Metadata-driven enrichment (steps 3 and 4) reads through one - // resolved protocol, but each step carries its own `try`: they - // describe different surfaces, and a failure to enumerate one - // must not silently blank the other. - let protocol: RestProtocol | undefined; - try { - protocol = await this.resolveProtocol(environmentId, req); - } catch { - // Enrichment is best-effort — never fail the spec serve. - } - - // 3) Expand `{object}` path placeholders into concrete - // routes for every registered data object. Falls back - // silently if discovery isn't available. Since #5588 the - // templates it expands are the real ones (`/api/v1/data/ - // {object}` and its siblings), not the phantom `/api/ - // {object}`. - try { - const items = await protocol?.getMetaItems?.({ type: 'object' }).catch(() => null) as any; - const objects: string[] = Array.isArray(items?.items) - ? items.items.map((i: any) => i?.name).filter(Boolean) - : Array.isArray(items) - ? items.map((i: any) => i?.name).filter(Boolean) - : []; - if (objects.length > 0 && enriched.paths) { - const expanded: Record = {}; - for (const [p, def] of Object.entries(enriched.paths)) { - if (p.includes('{object}')) { - // Keep the template under x-template for tooling - // that wants the generic shape, and emit one - // concrete copy per registered object. - expanded[p] = { ...(def as object), 'x-template': true }; - for (const obj of objects) { - expanded[p.replace('{object}', obj)] = def; - } - } else { - expanded[p] = def; - } - } - enriched.paths = expanded; - } - } catch { - // Enrichment is best-effort — never fail the spec serve. - } - - // 4) Fold in the endpoints declared as `api` metadata (#5040 - // E6). Same enumeration root as the `{object}` expansion - // above — this document is the ONE documentation face for - // `/openapi.json`, which this package alone serves (#5078, - // ADR-0076): declared endpoints join it here rather than - // growing a second generator somewhere else. - // - // Since the E7 flip a non-empty `apis:` publishes, so this - // enumeration returns real declarations on a deployment that - // has them and the document grows a path entry per endpoint. - // Where nothing is declared the enumeration is empty and - // `enrichOpenApiWithEndpoints` hands `enriched` straight - // back, byte for byte. - try { - const apiResult = await protocol?.getMetaItems?.({ type: 'api' }); - const apiItems: unknown[] = Array.isArray((apiResult as any)?.items) - ? (apiResult as any).items - : Array.isArray(apiResult) ? apiResult as unknown[] : []; - const endpointLogger = { - error: (message: string, meta?: unknown) => - meta === undefined ? logError(message) : logError(message, meta), - }; - - // [#5224] Enumerated is not served. This document is what - // SDKs, codegen and AI clients build clients FROM, so it - // describes only the declarations the endpoint matcher will - // actually answer — asked of the matcher itself, the sole - // holder of that verdict. A stored row the matcher cannot - // see used to arrive here and be published as a real path, - // `security: []` and all, while every request to it 404'd. - let documentable: unknown[] = apiItems; - const authority = apiItems.length > 0 - ? await this.resolveEndpointMatchAuthority( - isScoped ? req.params?.environmentId : undefined, - req, - ) - : undefined; - if (apiItems.length > 0 && !authority) { - this.notifyMissingEndpointAuthority('GET /openapi.json'); - } else if (authority) { - // The matcher's OWN parsed endpoint is documented, not - // the stored JSON: schema defaults are materialized on - // it (most importantly `authRequired`), so the document - // describes the value the runtime acts on rather than a - // re-parse of the same row on a second code path. - documentable = (await selectServedEndpoints(apiItems, authority, endpointLogger)) - .map((s) => s.endpoint); - } - - enriched = enrichOpenApiWithEndpoints(enriched, documentable, endpointLogger); - } catch (err: any) { - // A store that cannot be read must not take the document - // down with it — but say so, because a silently endpoint- - // less document looks exactly like a correct one. - logError('[REST] openapi.json endpoint enrichment skipped:', err?.message ?? err); - } - - // 5) `info`: the artifact's, with the publisher's identity laid - // over it (#20294, ruling B on #20359). `packages/spec` - // produces the block (`build-openapi.ts`, pinned by - // `openapi-self-consistency.test.ts`); the host may sign it - // with the identity members of `api.documentation` — - // `title`, `description`, `termsOfService`, and `contact` / - // `license` each replaced whole. Nothing authored serves the - // artifact's `info` byte for byte, so the served document and - // the published `@objectstack/spec/openapi.json` export still - // state the same fact about every field nobody signed - // (#11646's invariant, now the unset case). The same closure - // serves this base and its environment-scoped twin, so both - // doors carry the overlay. The helper returns a NEW object: - // `enriched.info` is still the cached artifact's own `info` - // here (the clone above is shallow), and writing into it - // would leak one request's overlay into every later one. - // - // `info.version` is NOT publisher identity, and nothing here - // writes it: it stays the artifact's — the protocol version - // (#11646) — and `api.documentation.version` is a retired - // tombstone the construction-time parse already refused. The - // API version identifier this deployment declares - // (`api.version`, which `normalizeConfig` defaults to `'v1'`) - // lives where it is observable, in the mount - // `${basePath}/${version}` -> `/api/v1`. The runtime version - // is answered by `{basePath}/discovery` and `/health`, derived - // from `OS_RUNTIME_VERSION` (#10993, commit 376c70f98, #11292). OpenAPI - // 3.1 defines this field as "the version of the OpenAPI - // document (which is distinct from the OpenAPI Specification - // version or the API implementation version)" — the document - // being served IS the artifact, so its version is the - // artifact's. - enriched.info = RestServer.overlayDocumentationInfo(enriched.info, this.config.api.documentation); - - res.json(enriched); - } catch (error: any) { - logError('[REST] openapi.json error:', error); - sendThrownError(res, error); - } + // [#23050 slice 7] The body moved to `discovery-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `OpenApiRouteHost`, and that module's header for why + // `config` is a getter and why the private static + // `overlayDocumentationInfo` is handed over too). + const readConfig = () => this.config; + const host: OpenApiRouteHost = { + get config() { return readConfig(); }, + routeManager: this.routeManager, + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), + loadOpenApiSpec: (...a) => this.loadOpenApiSpec(...a), + getRoutes: (...a) => this.getRoutes(...a), + resolveProtocol: (...a) => this.resolveProtocol(...a), + resolveEndpointMatchAuthority: (...a) => this.resolveEndpointMatchAuthority(...a), + notifyMissingEndpointAuthority: (...a) => this.notifyMissingEndpointAuthority(...a), + overlayDocumentationInfo: (...a) => RestServer.overlayDocumentationInfo(...a), }; - - this.routeManager.register({ - method: 'GET', - path: `${basePath}/openapi.json`, - handler: openApiHandler, - metadata: { - summary: 'OpenAPI 3.1 specification (machine-readable)', - tags: ['openapi'], - }, - }); - - // Scalar HTML viewer — single inline page that loads the spec from - // the sibling /openapi.json endpoint. No build-time bundling, no - // server-side render cost. - this.routeManager.register({ - method: 'GET', - path: `${basePath}/docs`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - - // Resolve the openapi.json URL relative to the current - // request so the docs page works for any host / scoped - // base path (e.g. /api/v1 vs /api/v1/environments/abc). - const reqPath: string = req.path || req.url || `${basePath}/docs`; - // Strip the trailing /docs to get the API base. - const apiBase = reqPath.replace(/\/docs\/?$/, ''); - const specUrl = `${apiBase}/openapi.json`; - const html = ` - - - - -ObjectStack API Docs - - - - - -`; - if (res.setHeader) res.setHeader('content-type', 'text/html; charset=utf-8'); - if (res.send) res.send(html); - else if (res.body) res.body = html; - else res.json?.(html); - } catch (error: any) { - // Only the identity read above can throw here (a - // permission-store outage), which keeps its declared answer. - logError('[REST] docs viewer error:', error); - sendThrownError(res, error); - } - }, - metadata: { - summary: 'Interactive API docs (Scalar viewer)', - tags: ['openapi'], - }, - }); + registerOpenApiEndpoints.call(host, basePath); } /** diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index c9341579e5d..d8e2dfe0f6b 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -348,7 +348,10 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // for that slice's forwarder. // [#23050 slice 6] 6 → 7 (`security-routes.ts`): 55 and 70, +1 each // per new host-literal forwarder (two registrars, two forwarders). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); + // [#23050 slice 7] 7 → 8 (`discovery-routes.ts`): 56 and 71, +1 each + // for the OpenAPI host literal's forwarder (the discovery body reads + // neither term, so its literal forwards neither). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index 20f0a4ba523..21aaa839b9f 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1116,7 +1116,11 @@ describe('[#13214] §4 preconditions', () => { // [#23050 slice 6] 6 → 7 (`security-routes.ts`): 55 and 70, +1 each // per new host-literal forwarder (two registrars, two forwarders); the // ownership guard still 1. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(7); + // [#23050 slice 7] 7 → 8 (`discovery-routes.ts`): 56 and 71, +1 each + // for the OpenAPI host literal's forwarder (the discovery body reads + // neither term, so its literal forwards neither); the ownership guard + // still 1. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json b/scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json new file mode 100644 index 00000000000..d60e20bea73 --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__discovery-routes.ts.json @@ -0,0 +1,9 @@ +{ + "file": "packages/rest/src/discovery-routes.ts", + "adrs": [ + "ADR-0076", + "ADR-0056", + "ADR-0138" + ], + "invariant": "The API-description REST doors, moved here whole from `rest-server.ts` (#23050 slice 7): the discovery document (`GET {base}` and `GET {base}/discovery`) and the OpenAPI pair (`GET {base}/openapi.json` and its `GET {base}/docs` viewer), each mounted once per base. ADR-0076 — `/discovery` advertises only what this server actually mounted, and mounts what it advertises (its D12): each `routes.*` entry is rewritten to the mounted path, the direct-mount and email entries are projections of the recorded mounts, `services.*.route` is a projection of the finished `routes` map, and `transactionalBatch` and `search` are the conjunction of the protocol's capability and this server's own mount flag; and this package is the SOLE owner of `/openapi.json`, whose built-in section is produced from this server's own route table rather than inherited from the static artifact. ADR-0056 — both API-description doors are default-deny: each handler's first act refuses an anonymous caller with the shared anonymous-deny 401 (its D2), and ADR-0138's closed list of anonymous doors names neither (its D2). An advertised route that is not mounted, a second owner of the OpenAPI document, or an API-description door that answers before the anonymous refusal is the drift these records forbid; the bodies' own comments carry each citation." +} diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index f02f80e2ee8..ea415567222 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -356,6 +356,14 @@ const MODULES = { // `error` body (every arm there emits the nested `{ error: { code, message } }` // through its family's one emitter), so this count does not move and that // module declares 0 below; the sum is unchanged (17 + 13 + 8 + 1 + 0 + 0 + 0 = 39). + // + // 17 → 17 (#23050 slice 7): the discovery and OpenAPI bodies left this file + // for `discovery-routes.ts` carrying NO literal string `error` body (the + // discovery handler's one refusal path is the shared `handleRouteError`, and + // the OpenAPI document's one hand-built refusal is the nested + // `{ error: { code, message } }`), so this count does not move and that + // module declares 0 below; the sum is unchanged + // (17 + 13 + 8 + 1 + 0 + 0 + 0 + 0 = 39). stringError: 17, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared @@ -422,9 +430,14 @@ const MODULES = { // // 17 → 17 (#23050 slice 6): the security-explain and suggested-binding // bodies left this file for `security-routes.ts` carrying NO `{ code, error }` - // body — the two arms #7981 and #8073 converted are the ones counted above + // body — the four arms #7981 and #8073 converted are the ones counted above // as 77 → 73 — so this count does not move and that module declares 0 below; // the sum is unchanged (17 + 15 + 8 + 9 + 4 + 1 + 0 = 54). + // + // 17 → 17 (#23050 slice 7): the discovery and OpenAPI bodies left this file + // for `discovery-routes.ts` carrying NO `{ code, error }` body, so this + // count does not move and that module declares 0 below; the sum is + // unchanged (17 + 15 + 8 + 9 + 4 + 1 + 0 + 0 = 54). siblingCode: 17, }, @@ -571,6 +584,25 @@ const MODULES = { stringError: 0, siblingCode: 0, }, + + // [#23050 slice 7] `RestServer#registerDiscoveryEndpoints`' and + // `#registerOpenApiEndpoints`' bodies — the discovery document (the API root + // and `/discovery`) and the OpenAPI pair (`/openapi.json` and the `/docs` + // viewer) — moved out of `rest-server.ts` whole, into ONE module, on exactly + // slice 1's terms above: a MOVE, no wire answer changed, so it inherits + // `dialectOnly` and the two dialect facts, and no write-site count. Both + // counts were measured on the moved file and equal what left `rest-server.ts` + // in the same PR (stringError 17 → 17, siblingCode 17 → 17: none), so each + // sum is unchanged. Both only ever tick down. Discovered by the `*-routes.ts` + // convention and declared in the same PR that created the module, so the gap + // never exists. + 'packages/rest/src/discovery-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 7 and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 0, + siblingCode: 0, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */