diff --git a/.changeset/discovery-surface-schema-authoritative.md b/.changeset/discovery-surface-schema-authoritative.md new file mode 100644 index 0000000000..2083595e02 --- /dev/null +++ b/.changeset/discovery-surface-schema-authoritative.md @@ -0,0 +1,47 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/runtime": minor +--- + +feat(spec,runtime,metadata-protocol)!: one schema for both discovery producers — `capabilities` canonical, `features`/`endpoints` retired, `scoping` declared (#4828) + +`/discovery` is a machine-readable surface, but nothing compared what the two +producers emit against what `packages/spec` declares. The only schema the +protocol layer referenced was `GetDiscoveryResponseSchema` — +`DiscoverySchema.partial().required({version}).extend({apiName})` — so +`.partial()` hid every missing REQUIRED key while zod's default unknown-key +strip hid every UNDECLARED emitted one. The two producers then drifted in +opposite directions through the same blind spot. + +`DiscoverySchema` is now authoritative for producers, and each producer package +carries a `discovery-schema-conformance.test.ts` that parses its LIVE shape +against it and checks its emitted key set against the protocol schema's shape. + +**Breaking for anyone reading the dispatcher's `/.well-known/objectstack` body:** + +- `features` → **`capabilities`**, the name `DiscoverySchema` has always + declared, in the declared `{ enabled }` shape. The same flags survive. This + fixes a real defect: the SDK's `client.capabilities` getter reads + `discoveryInfo.capabilities`, so against a dispatcher-served host it returned + `undefined` for every flag while the answers sat one key away under `features`. +- `endpoints` — **removed**. It duplicated `routes` verbatim as a + "backward compatibility" alias; a consumer census across `objectstack`, + `objectui` and `cloud` found no reader. Use `routes`. +- `environment` is now **mapped** into its declared enum instead of passing + `NODE_ENV` through raw (`test` → `development`, `staging` → `sandbox`, + unrecognized → `development`, never `production` on a guess). `NODE_ENV=test` + and `staging` previously advertised values outside the declared enum. + +**Additive elsewhere:** + +- `DiscoverySchema` declares `scoping` (optional) — the environment-scoping + posture the REST endpoint has always emitted and `packages/client` has always + consumed, now part of the contract instead of an undeclared extra. +- The REST `/discovery` body gains the required `name` / `environment` / + `locale`, so it can satisfy `DiscoverySchema` at all. `locale` is derived from + the registered i18n service, the same way the dispatcher derives it. +- `name` is canonical on both producers. `apiName` remains as a deprecated alias + carrying the identical value and is **scheduled for removal in protocol 18**. +- New exports: `DiscoveryEnvironmentSchema`, `DiscoveryEnvironment`, + `resolveDiscoveryEnvironment`. diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 6f970d4f83..165d15a09c 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -21,8 +21,9 @@ The **HTTP API** defines how ObjectStack maps data operations to RESTful HTTP en Before making any API calls, clients should request a discovery endpoint to learn about available services. **Two endpoints answer that question, and in a stack that mounts -`@objectstack/rest` they do not return the same shape** — they are built by different -packages. Read the one that matches your composition; do not mix their fields. +`@objectstack/rest` they are built by different packages** — so they carry different +values (and different envelopes), even though both now satisfy the same `DiscoverySchema` +(#4828). Read the one that matches your composition. ### `GET /api/v1` (and `GET /api/v1/discovery`) @@ -43,11 +44,18 @@ Host: api.acme.com ```json { "version": "v1", + "name": "ObjectStack API", "apiName": "ObjectStack API", + "environment": "development", "routes": { "data": "/api/v1/data", "metadata": "/api/v1/meta" }, + "locale": { + "default": "en", + "supported": ["en"], + "timezone": "UTC" + }, "services": { "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "objectql" }, "data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "objectql" }, @@ -68,24 +76,32 @@ Host: api.acme.com } ``` -Three things about this body are worth stating explicitly, because they are what the -`/.well-known/objectstack` document below does *not* share: +Three things about this body are worth stating explicitly: - **`version` is the configured API version, not a product version.** The handler overwrites the protocol's value with `api.version` — the same string that forms the path segment (`"v1"`). It is never a semantic version like `2.1.0`. -- **There is no `name`, `environment` or `locale` here.** Those are dispatcher fields - (see below). A client that initialises i18n from `locale` must read - `/.well-known/objectstack`, not this response. +- **`name` is canonical; `apiName` is a deprecated alias with the same value.** Both are + emitted today so clients pinned to the old spelling keep working; `apiName` is removed + in **protocol 18** (#4828). Read `name`. - **`scoping` is added by the REST server**, so clients can detect dual-mode routing; `environmentId` is present only on the environment-scoped mount (`/api/v1/environments/:environmentId/...`). + +**Both discovery documents now satisfy one schema (#4828).** They used to diverge on the +required identity fields — this response omitted `name` / `environment` / `locale` +entirely, and the dispatcher document below spelled the capability map `features` while +this one spelled it `capabilities`. Both producers are now checked against +`DiscoverySchema` in CI, so a client can read the same keys from either. `environment` is +always one of `production` / `sandbox` / `development` — never a raw `NODE_ENV`. + + Disabled/uninstalled route keys are omitted from `routes` entirely rather than set to `null`; check `services` to tell "not installed" apart from "installed but not yet mounted -here." `capabilities` is a flat map of platform feature flags (`comments`, `automation`, -`cron`, `search`, `export`, `chunkedUpload`, `transactionalBatch`), each derived from what -is actually registered — never hardcoded. See +here." `capabilities` maps each platform capability (`comments`, `automation`, `cron`, +`search`, `export`, `chunkedUpload`, `transactionalBatch`) to a `{ "enabled": … }` +descriptor, each derived from what is actually registered — never hardcoded. See [API → Discovery](/docs/api#discovery) for the field-by-field reference. ### `GET /.well-known/objectstack` @@ -119,14 +135,14 @@ Host: api.acme.com "ui": "/api/v1/ui", "i18n": "/api/v1/i18n" }, - "features": { - "search": false, - "websockets": false, - "files": false, - "analytics": false, - "ai": false, - "notifications": false, - "i18n": true + "capabilities": { + "search": { "enabled": false }, + "websockets": { "enabled": false }, + "files": { "enabled": false }, + "analytics": { "enabled": false }, + "ai": { "enabled": false }, + "notifications": { "enabled": false }, + "i18n": { "enabled": true } }, "services": { "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel" }, @@ -144,12 +160,31 @@ Host: api.acme.com ``` `name` and `version` are the dispatcher's own build identity, not your app's name — they -are fixed strings, so do not display them as the deployment's title. `environment` is the -process `NODE_ENV`. `locale` is derived from the registered i18n service (`getDefaultLocale()` -/ `getLocales()`); with no i18n service it degrades to `{ "default": "en", "supported": -["en"], "timezone": "UTC" }`. The body also repeats `routes` under an `endpoints` key as a -backward-compatibility alias, and carries **no** `capabilities` map — that one exists only -on the REST-served response above. +are fixed strings, so do not display them as the deployment's title. `locale` is derived +from the registered i18n service (`getDefaultLocale()` / `getLocales()`); with no i18n +service it degrades to `{ "default": "en", "supported": ["en"], "timezone": "UTC" }`. + +`environment` is **derived from** `NODE_ENV`, not the raw value — the field is an enum +(`production` / `sandbox` / `development`), so out-of-enum spellings are mapped rather +than advertised verbatim (#4828): + +| `NODE_ENV` | advertised `environment` | +|:---|:---| +| `production`, `prod` | `production` | +| `sandbox` | `sandbox` | +| `staging` | `sandbox` — pre-production and production-like | +| `development`, `dev` | `development` | +| `test` | `development` — an ephemeral developer-class run | +| unset / anything else | `development` — never claims production on a guess | + + +**Retired in protocol 17 (#4828):** this document used to carry a top-level `features` +map and an `endpoints` key that duplicated `routes` verbatim. Neither was ever declared +in `DiscoverySchema`. `features` is now the canonical `capabilities` (same flags, in the +declared `{ "enabled": … }` shape, so it matches the REST-served response); `endpoints` +was removed outright after a consumer census across `objectstack`, `objectui` and `cloud` +found no reader — use `routes`. + **"Both paths return the same document" holds only in a REST-less composition.** There, the @@ -157,8 +192,9 @@ dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and `/.well-known/objectstack` both answer with the dispatcher payload above (the bare `/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as `@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule -(ADR-0076 D11) and the two paths answer different shapes. Never write a client that reads -`locale` or `environment` off `/api/v1/discovery`. +(ADR-0076 D11) and the two paths answer different *documents* — same schema, different +producers, so the envelope (`{ "data": … }` here, bare there) and the values differ even +though the key set no longer does. **Why discovery matters:** diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index f6d2635440..dfd6078da1 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -28,8 +28,8 @@ not been verified (may 501 at runtime). ## TypeScript Usage ```typescript -import { ApiRoutesSchema, DiscoverySchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, WellKnownCapabilitiesSchema } from '@objectstack/spec/api'; -import type { ApiRoutes, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, WellKnownCapabilities } from '@objectstack/spec/api'; +import { ApiRoutesSchema, DiscoverySchema, DiscoveryEnvironmentSchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, WellKnownCapabilitiesSchema } from '@objectstack/spec/api'; +import type { ApiRoutes, DiscoveryEnvironment, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, WellKnownCapabilities } from '@objectstack/spec/api'; // Validate data const result = ApiRoutesSchema.parse(data); @@ -69,15 +69,29 @@ const result = ApiRoutesSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | | | **version** | `string` | ✅ | | -| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | ✅ | | +| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | ✅ | Deployment posture a discovery response advertises. Deliberately three coarse buckets — a client reads this to answer "am I talking to production?", not to identify a specific environment (that is `sys_environment` / EnvironmentTypeSchema, a richer 7-member taxonomy). | | **routes** | `{ data: string; metadata: string; discovery?: string; ui?: string; … }` | ✅ | | | **locale** | `{ default: string; supported: string[]; timezone: string }` | ✅ | | | **services** | `Record; handlerReady?: boolean; route?: string; … }>` | ✅ | Per-service availability map keyed by CoreServiceName | | **capabilities** | `Record; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation | | **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration | +| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint | | **metadata** | `Record` | optional | Custom metadata key-value pairs for extensibility | +--- + +## DiscoveryEnvironment + +Deployment posture a discovery response advertises. Deliberately three coarse buckets — a client reads this to answer "am I talking to production?", not to identify a specific environment (that is `sys_environment` / EnvironmentTypeSchema, a richer 7-member taxonomy). + +### Allowed Values + +* `production` +* `sandbox` +* `development` + + --- ## RouteHealthEntry diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 438c77631d..cab71694fc 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -594,14 +594,15 @@ const result = AiAgentCapabilitiesSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | | | **version** | `string` | ✅ | | -| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | optional | | +| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | optional | Deployment posture a discovery response advertises. Deliberately three coarse buckets — a client reads this to answer "am I talking to production?", not to identify a specific environment (that is `sys_environment` / EnvironmentTypeSchema, a richer 7-member taxonomy). | | **routes** | `{ data: string; metadata: string; discovery?: string; ui?: string; … }` | optional | | | **locale** | `{ default: string; supported: string[]; timezone: string }` | optional | | | **services** | `Record; handlerReady?: boolean; route?: string; … }>` | optional | Per-service availability map keyed by CoreServiceName | | **capabilities** | `Record; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation | | **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration | +| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint | | **metadata** | `Record` | optional | Custom metadata key-value pairs for extensibility | -| **apiName** | `string` | optional | API name (deprecated — use name) | +| **apiName** | `string` | optional | API name (deprecated — use `name`; removed in protocol 18) | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 39c1f21bc7..f77d44a7aa 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -260,7 +260,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 393 | +| `api/` | 394 | | `cloud/` | 82 | | `identity/` | 33 | | `integration/` | 10 | diff --git a/packages/adapters/hono/src/__mocks__/runtime.ts b/packages/adapters/hono/src/__mocks__/runtime.ts index 5306d26684..b7d188ef1a 100644 --- a/packages/adapters/hono/src/__mocks__/runtime.ts +++ b/packages/adapters/hono/src/__mocks__/runtime.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; export class HttpDispatcher { - getDiscoveryInfo = vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }); + getDiscoveryInfo = vi.fn().mockReturnValue({ version: '1.0', routes: {} }); handleGraphQL = vi.fn().mockResolvedValue({ data: {} }); handleAuth = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: { ok: true } } }); handleMetadata = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: { objects: [] } } }); diff --git a/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts b/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts index 43364309cc..fbeecab0c2 100644 --- a/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts +++ b/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts @@ -32,7 +32,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Hono } from 'hono'; const mockDispatcher = { - getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }), + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), handleAuth: vi.fn(), handleGraphQL: vi.fn(), dispatch: vi.fn(), diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 1aa9b8bb05..2ff6fb3c74 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono'; // Mock dispatcher instance accessible across tests const mockDispatcher = { - getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }), + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), handleAuth: vi.fn().mockResolvedValue({ handled: true, response: { body: { ok: true }, status: 200 } }), handleGraphQL: vi.fn().mockResolvedValue({ data: {} }), dispatch: vi.fn().mockResolvedValue({ handled: true, response: { body: { success: true }, status: 200 } }), diff --git a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts new file mode 100644 index 0000000000..d00d5b7394 --- /dev/null +++ b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#4828] The discovery surface is machine-readable (AGENTS.md "Route & surface +// ownership" #4: machine-readable surfaces must not lie), and until this issue +// NOTHING compared what a producer actually emits against what the spec +// declares. `GetDiscoveryResponseSchema` — the only schema the protocol layer +// referenced — is `DiscoverySchema.partial().required({version}).extend({apiName})`, +// and a zod object strips unknown keys by default. So BOTH failure directions +// were swallowed at once: +// +// 1. missing REQUIRED keys — `.partial()` made `name`/`environment`/`locale` +// optional, so this producer never emitting them parsed clean; +// 2. UNDECLARED keys — the default strip made `features` / `endpoints` +// (emitted by the runtime dispatcher) parse clean too. +// +// The maintainer's 2026-08-05 ruling makes `DiscoverySchema` authoritative: +// every producer fills the required keys. These tests are that gate for the +// `getDiscovery()` producer; `packages/runtime` and `packages/rest` carry the +// sibling gates for the other two shapes. +// +// The two assertions are deliberately different questions (key vs value): +// +// * `DiscoverySchema.parse()` judges VALUES — required keys present, +// `environment` inside its declared enum, `scoping` well-formed. +// * the key-set subset check judges KEYS — nothing is emitted that the +// protocol never declared. Its allowed set is exactly +// `GetDiscoveryResponseSchema`'s shape, i.e. `DiscoverySchema`'s keys plus +// the one declared deprecated alias (`apiName`). Deriving the allowance +// from the schema instead of a hand-listed array is what stops this gate +// from becoming a third dialect of the contract. + +import { describe, it, expect } from 'vitest'; +import { DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from './index.js'; + +/** The keys the protocol declares for a discovery response (canonical + declared alias). */ +function declaredResponseKeys(): Set { + return new Set(Object.keys((GetDiscoveryResponseSchema as any).shape)); +} + +/** + * A protocol impl over a minimal engine. `getDiscovery()` reads + * `engine.registry` (for `sys_comment`), `engine.transaction` (for + * `transactionalBatch`) and the services registry — nothing else. + */ +function makeImpl(services: Map = new Map()) { + const engine = { + registry: { + getObject: (_name: string) => undefined, + getRegisteredTypes: () => [], + }, + }; + return new ObjectStackProtocolImplementation(engine as any, () => services); +} + +describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => { + it('emits every REQUIRED key the spec declares (name / environment / locale)', async () => { + const discovery = await makeImpl().getDiscovery(); + + const result = DiscoverySchema.safeParse(discovery); + expect( + result.success ? [] : result.error.issues.map(i => `${i.path.join('.')}: ${i.code}`), + 'getDiscovery() must satisfy the canonical DiscoverySchema', + ).toEqual([]); + }); + + it('emits NO key the protocol does not declare', async () => { + const discovery = await makeImpl().getDiscovery(); + + const declared = declaredResponseKeys(); + const undeclared = Object.keys(discovery).filter(k => !declared.has(k)); + expect(undeclared, 'undeclared top-level keys on the getDiscovery() shape').toEqual([]); + }); + + it('carries the canonical `name`, and keeps `apiName` for its deprecation window', async () => { + const discovery: any = await makeImpl().getDiscovery(); + + // Canonical (required by DiscoverySchema). + expect(discovery.name).toBe('ObjectStack API'); + // Deprecated alias — still emitted so a client pinned to it (the + // `01-discovery.test.ts` integration assertion) keeps working until the + // scheduled removal. Both spellings must name the SAME thing. + expect(discovery.apiName).toBe(discovery.name); + }); + + it('reports an `environment` inside the declared enum', async () => { + const discovery: any = await makeImpl().getDiscovery(); + + expect(['production', 'sandbox', 'development']).toContain(discovery.environment); + }); + + it('reports a `locale` block derived from the i18n service when one is registered', async () => { + const services = new Map([ + ['i18n', { + getDefaultLocale: () => 'zh-CN', + getLocales: () => ['zh-CN', 'en'], + }], + ]); + const discovery: any = await makeImpl(services).getDiscovery(); + + expect(discovery.locale.default).toBe('zh-CN'); + expect(discovery.locale.supported).toEqual(['zh-CN', 'en']); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8961c23074..80ebe63105 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19,7 +19,7 @@ import type { } from '@objectstack/spec/api'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; import type { ApiError, BatchOperationResult } from '@objectstack/spec/api'; -import { readServiceSelfInfo, ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { readServiceSelfInfo, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; import { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, @@ -2734,10 +2734,47 @@ export class ObjectStackProtocolImplementation implements capabilities[key] = { enabled }; } + // [#4828] Locale, derived from the registered i18n service exactly the + // way the runtime dispatcher's `getDiscoveryInfo()` derives it — same + // accessors, same fallback. `DiscoverySchema` declares `locale` + // REQUIRED and this producer never emitted it, so the REST `/discovery` + // shape could not satisfy the schema at all; deriving it (rather than + // hardcoding `en`) keeps the answer honest on a stack that actually + // ships translations. + const i18nSvc = registeredServices.get('i18n'); + let locale = { default: 'en', supported: ['en'], timezone: 'UTC' }; + if (i18nSvc) { + const defaultLocale = typeof i18nSvc.getDefaultLocale === 'function' + ? i18nSvc.getDefaultLocale() : 'en'; + const locales = typeof i18nSvc.getLocales === 'function' + ? i18nSvc.getLocales() : []; + locale = { + default: defaultLocale, + supported: locales.length > 0 ? locales : [defaultLocale], + timezone: 'UTC', + }; + } + + // [#4828] `name` is the canonical identity key (`DiscoverySchema` + // requires it); `apiName` is the deprecated alias, emitted with the + // IDENTICAL value until its scheduled removal in protocol 18 (schedule + // in `GetDiscoveryResponseSchema`). Before this, the two discovery + // producers spelled the same concept differently and disjointly — this + // one emitted only `apiName`, the dispatcher only `name` — so no + // consumer had a key that worked against both. + const name = 'ObjectStack API'; + return { version: '1.0', - apiName: 'ObjectStack API', + name, + /** @deprecated Use `name`. Removed in protocol 18 (#4828). */ + apiName: name, + environment: resolveDiscoveryEnvironment( + (globalThis as { process?: { env?: Record } }) + .process?.env?.NODE_ENV, + ), routes, + locale, services, capabilities, }; diff --git a/packages/rest/src/discovery-schema-conformance.test.ts b/packages/rest/src/discovery-schema-conformance.test.ts new file mode 100644 index 0000000000..d7b562e31e --- /dev/null +++ b/packages/rest/src/discovery-schema-conformance.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#4828] The REST `/discovery` half of the conformance gate — and the one that +// matters most, because this is the shape a browser client actually receives. +// +// It is a COMPOSED shape: `getDiscovery()` (metadata-protocol) builds the base, +// then `registerDiscoveryEndpoints` overrides `version` and `routes`, ANDs +// `capabilities.transactionalBatch` with its own `api.enableBatch`, and attaches +// `scoping`. Neither producer alone could be checked against the schema and be +// meaningful here, so this test drives the REAL protocol implementation through +// the REAL handler rather than the `createMockProtocol()` double the other +// rest tests use — a mock would only prove the mock's shape conforms. +// +// Before this issue the composed shape could not satisfy `DiscoverySchema` at +// all: `name`/`environment`/`locale` were absent (required), and `scoping` was +// undeclared. + +import { describe, it, expect, vi } from 'vitest'; +import { DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +/** The keys the protocol declares for a discovery response (canonical + declared alias). */ +function declaredResponseKeys(): Set { + return new Set(Object.keys((GetDiscoveryResponseSchema as any).shape)); +} + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * A REST server over the REAL `getDiscovery()` producer. + * + * `scoping` selects the mount under test: unscoped `/api/v1` (the default + * deployment) or the environment-scoped `/api/v1/environments/:environmentId` + * one, which is the only mount that can report `scoped: true`. + */ +function discoveryHandler(opts: { scoped?: boolean } = {}) { + const engine = { + registry: { + getObject: (_n: string) => undefined, + getRegisteredTypes: () => [], + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine as any, () => new Map()); + const config: any = { + api: { + requireAuth: false, + ...(opts.scoped ? { enableProjectScoping: true, projectResolution: 'auto' } : {}), + }, + }; + const rest = new RestServer(createMockServer() as any, protocol as any, config); + rest.registerRoutes(); + + const path = opts.scoped + ? '/api/v1/environments/:environmentId/discovery' + : '/api/v1/discovery'; + const entry = rest.getRouteManager().get('GET', path); + if (!entry) throw new Error(`discovery route not registered at ${path}`); + return entry.handler as (req: any, res: any) => Promise; +} + +async function invoke( + handler: (req: any, res: any) => Promise, + params: Record = {}, +) { + let body: any; + const res: any = { json: (b: any) => { body = b; }, status: () => res }; + await handler({ params }, res); + return body; +} + +describe('[#4828] the REST /discovery live shape conforms to DiscoverySchema', () => { + it('satisfies the canonical DiscoverySchema', async () => { + const body = await invoke(discoveryHandler()); + + const result = DiscoverySchema.safeParse(body); + expect( + result.success ? [] : result.error.issues.map(i => `${i.path.join('.')}: ${i.code}`), + 'the REST /discovery body must satisfy the canonical DiscoverySchema', + ).toEqual([]); + }); + + it('emits NO key the protocol does not declare', async () => { + const body = await invoke(discoveryHandler()); + + const declared = declaredResponseKeys(); + const undeclared = Object.keys(body).filter(k => !declared.has(k)); + expect(undeclared, 'undeclared top-level keys on the REST /discovery body').toEqual([]); + }); + + it('fills the three required identity keys the schema declares', async () => { + const body = await invoke(discoveryHandler()); + + expect(body.name).toBe('ObjectStack API'); + expect(['production', 'sandbox', 'development']).toContain(body.environment); + expect(body.locale).toEqual({ default: 'en', supported: ['en'], timezone: 'UTC' }); + }); + + it('declares `scoping` — reported, and now schema-checked, on the unscoped mount', async () => { + const body = await invoke(discoveryHandler()); + + expect(body.scoping).toEqual({ + enabled: false, + resolution: 'auto', + scoped: false, + environmentId: undefined, + }); + }); + + it('reports the resolved environmentId on the scoped mount', async () => { + const body = await invoke( + discoveryHandler({ scoped: true }), + { environmentId: 'env_alpha' }, + ); + + expect(body.scoping).toEqual({ + enabled: true, + resolution: 'auto', + scoped: true, + environmentId: 'env_alpha', + }); + // Still schema-clean with every `scoping` sub-key populated. + expect(DiscoverySchema.safeParse(body).success).toBe(true); + }); + + it('keeps `capabilities` as the one capability key — no `features`, no `endpoints`', async () => { + const body = await invoke(discoveryHandler()); + + expect(body).not.toHaveProperty('features'); + expect(body).not.toHaveProperty('endpoints'); + // The REST layer's own AND of the runtime verdict with `api.enableBatch` + // still lands inside `capabilities` (#3298) — pinned so the retirement + // cannot take the composed capability with it. + expect(body.capabilities.transactionalBatch).toBeDefined(); + }); +}); diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts new file mode 100644 index 0000000000..6c306c3952 --- /dev/null +++ b/packages/runtime/src/discovery-schema-conformance.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#4828] The dispatcher's half of the discovery conformance gate. See the +// sibling file in `packages/metadata-protocol` for the full reasoning; in short, +// `GetDiscoveryResponseSchema` is `DiscoverySchema.partial()` and zod strips +// unknown keys, so the ONLY schema the protocol layer referenced could see +// neither a missing required key nor an undeclared emitted one. This dispatcher +// shape was the worst case of both: it emitted `features` and `endpoints` +// (declared nowhere) and passed `NODE_ENV` through raw into a field the spec +// declares as an ENUM. +// +// The `NODE_ENV` half is self-demonstrating here: vitest sets `NODE_ENV=test`, +// which is not a member of `production|sandbox|development`. Before the fix the +// `environment` assertion below fails **in the very run that proves it** — no +// contrived fixture needed. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api'; +import { HttpDispatcher } from './http-dispatcher.js'; + +/** The keys the protocol declares for a discovery response (canonical + declared alias). */ +function declaredResponseKeys(): Set { + return new Set(Object.keys((GetDiscoveryResponseSchema as any).shape)); +} + +describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => { + let dispatcher: HttpDispatcher; + + beforeEach(() => { + const kernel = { + context: { + getService: (name: string) => { + if (name === 'objectql') { + return { + registry: { + getObject: vi.fn().mockReturnValue({ name: 'test_obj' }), + getRegisteredTypes: vi.fn().mockReturnValue([]), + getAllPackages: vi.fn().mockReturnValue([]), + }, + }; + } + return null; + }, + }, + } as any; + dispatcher = new HttpDispatcher(kernel); + }); + + it('satisfies the canonical DiscoverySchema', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + + const result = DiscoverySchema.safeParse(info); + expect( + result.success ? [] : result.error.issues.map(i => `${i.path.join('.')}: ${i.code}`), + 'getDiscoveryInfo() must satisfy the canonical DiscoverySchema', + ).toEqual([]); + }); + + it('emits NO key the protocol does not declare', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + + const declared = declaredResponseKeys(); + const undeclared = Object.keys(info).filter(k => !declared.has(k)); + expect(undeclared, 'undeclared top-level keys on the getDiscoveryInfo() shape').toEqual([]); + }); + + it('has retired `features` and `endpoints` (ADR-0049 enforce-or-remove)', async () => { + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + // `features` → the canonical `capabilities`; the flags themselves survive. + expect(info).not.toHaveProperty('features'); + expect(info.capabilities.search.enabled).toBe(false); + expect(info.capabilities.websockets.enabled).toBe(false); + + // `endpoints` was a verbatim duplicate of `routes` with no measured reader. + expect(info).not.toHaveProperty('endpoints'); + expect(info.routes).toBeDefined(); + }); + + describe('maps NODE_ENV into the declared enum instead of passing it through', () => { + const OLD_NODE_ENV = process.env.NODE_ENV; + afterEach(() => { + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = OLD_NODE_ENV; + }); + + // The two out-of-enum spellings named in the issue, driven through the REAL + // producer — `test` is what vitest itself sets, so the raw passthrough was + // advertising an undeclared value in every test run of this repo. + it.each([ + ['test', 'development'], + ['staging', 'sandbox'], + ['production', 'production'], + ['qa', 'development'], + ])('NODE_ENV=%s advertises %s', async (nodeEnv, expected) => { + process.env.NODE_ENV = nodeEnv; + + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + expect(info.environment).toBe(expected); + // …and the whole body still satisfies the schema with that value in place. + expect(DiscoverySchema.safeParse(info).success).toBe(true); + }); + }); + + it('emits the canonical `name`, never the deprecated `apiName` alias', async () => { + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + expect(info.name).toBe('ObjectOS'); + // This producer was already canonical-only; pin it so the alias cannot be + // reintroduced here while it is being retired on the other producer. + expect(info).not.toHaveProperty('apiName'); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index e00afd65b2..38d4679d4e 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2420,7 +2420,7 @@ describe('HttpDispatcher', () => { expect(info.services.i18n.enabled).toBe(true); expect(info.services.i18n.status).toBe('available'); expect(info.routes.i18n).toBe('/api/v1/i18n'); - expect(info.features.i18n).toBe(true); + expect(info.capabilities.i18n.enabled).toBe(true); }); it('should report i18n as unavailable in discovery when service is not registered', async () => { @@ -2431,7 +2431,7 @@ describe('HttpDispatcher', () => { expect(info.services.i18n.enabled).toBe(false); expect(info.services.i18n.status).toBe('unavailable'); expect(info.routes.i18n).toBeUndefined(); - expect(info.features.i18n).toBe(false); + expect(info.capabilities.i18n.enabled).toBe(false); }); // [#4093 follow-up] Discovery's remedy line must name a package that can @@ -2467,7 +2467,7 @@ describe('HttpDispatcher', () => { const info = await dispatcher.getDiscoveryInfo('/api/v1'); expect(info.services).not.toHaveProperty('workflow'); expect(info.routes).not.toHaveProperty('workflow'); - expect(info.features).not.toHaveProperty('workflow'); + expect(info.capabilities).not.toHaveProperty('workflow'); }); it('never emits the old slot-name-derived template', async () => { @@ -2585,7 +2585,7 @@ describe('HttpDispatcher', () => { // No HTTP/WS surface exists — a discovery-advertised route would 404. expect(info.routes.realtime).toBeUndefined(); - expect(info.features.websockets).toBe(false); + expect(info.capabilities.websockets.enabled).toBe(false); expect(info.services.realtime.enabled).toBe(true); expect(info.services.realtime.status).toBe('degraded'); expect(info.services.realtime.handlerReady).toBe(false); @@ -2647,7 +2647,7 @@ describe('HttpDispatcher', () => { const info = await dispatcher.getDiscoveryInfo('/api/v1'); expect(info.routes.analytics).toBeUndefined(); - expect(info.features.analytics).toBe(false); + expect(info.capabilities.analytics.enabled).toBe(false); expect(info.services.analytics.enabled).toBe(true); expect(info.services.analytics.status).toBe('stub'); expect(info.services.analytics.handlerReady).toBe(false); @@ -3037,7 +3037,7 @@ describe('HttpDispatcher', () => { expect(info.routes[key], `routes.${key}`).toBeUndefined(); } for (const key of ['files', 'ai', 'notifications', 'i18n'] as const) { - expect(info.features[key], `features.${key}`).toBe(false); + expect(info.capabilities[key].enabled, `capabilities.${key}.enabled`).toBe(false); } for (const key of ['file-storage', 'automation', 'notification', 'ai', 'i18n'] as const) { expect(info.services[key].enabled, `services.${key}.enabled`).toBe(true); @@ -3064,8 +3064,8 @@ describe('HttpDispatcher', () => { expect(info.routes.notifications).toBe('/api/v1/notifications'); expect(info.routes.ai).toBe('/api/v1/ai'); expect(info.routes.i18n).toBe('/api/v1/i18n'); - expect(info.features.files).toBe(true); - expect(info.features.i18n).toBe(true); + expect(info.capabilities.files.enabled).toBe(true); + expect(info.capabilities.i18n.enabled).toBe(true); expect(info.services['file-storage'].status).toBe('degraded'); expect(info.services['file-storage'].handlerReady).toBe(true); }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 7ef81d0758..5b650b7386 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -7,7 +7,7 @@ import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; -import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api'; +import { readServiceSelfInfo, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js'; @@ -1274,20 +1274,51 @@ export class HttpDispatcher { return { name: 'ObjectOS', version: '1.0.0', - environment: getEnv('NODE_ENV', 'development'), + // [#4828] Mapped, not passed through. `DiscoverySchema.environment` + // is an ENUM (`production|sandbox|development`) and this used to be + // `getEnv('NODE_ENV', 'development')` raw — so `NODE_ENV=test` (what + // vitest sets) or `staging` advertised a value outside the declared + // enum on a machine-readable surface. The mapping table and the + // reasoning per row live with the enum, in `@objectstack/spec/api`, + // so both discovery producers answer identically. + environment: resolveDiscoveryEnvironment(getEnv('NODE_ENV', 'development')), routes, - endpoints: routes, // Alias for backward compatibility with some clients - features: { - search: hasSearch, + // [#4828] `endpoints` (a verbatim duplicate of `routes`, commented + // "Alias for backward compatibility with some clients") and the + // top-level `features` map are GONE. Neither was ever declared in + // `DiscoverySchema`; both survived only because the one schema the + // protocol layer referenced (`GetDiscoveryResponseSchema`) strips + // unknown keys. Per ADR-0049 enforce-or-remove and the 2026-08-05 + // ruling on #4828: + // + // * `endpoints` — REMOVED. A consumer census across `objectstack`, + // `objectui` and `cloud` (2026-08-05) found NO reader: this repo's + // own SDK resolves routes via `discoveryInfo.routes` + // (`packages/client/src/index.ts`), and objectui's only + // `.endpoints` reads are its hand-written `SERVICE_ENDPOINT_CATALOG` + // (`apps/console/.../useApiDiscovery.ts`), not this payload. + // `routes` is the declared, canonical spelling. + // * `features` — RENAMED to the canonical `capabilities`, which + // `DiscoverySchema` has always declared and which this producer + // never emitted. That split is the bug: the SDK's + // `client.capabilities` getter reads `discoveryInfo.capabilities`, + // so against a dispatcher-served host it returned `undefined` for + // every flag while the answers sat one key away under `features`. + // + // The hierarchical `{ enabled }` shape is what `capabilities` + // declares (and what the `getDiscovery()` producer already emits), + // so a client reads ONE shape from either producer. + capabilities: { + search: { enabled: hasSearch }, // No WS/HTTP realtime surface is mounted anywhere — a mere // in-process realtime service must not advertise websockets // (ADR-0076 D12, #2462). - websockets: false, - files: hasFiles, - analytics: hasAnalytics, - ai: hasAi, - notifications: hasNotification, - i18n: hasI18n, + websockets: { enabled: false }, + files: { enabled: hasFiles }, + analytics: { enabled: hasAnalytics }, + ai: { enabled: hasAi }, + notifications: { enabled: hasNotification }, + i18n: { enabled: hasI18n }, }, services: { // Kernel-provided (always served by the protocol implementation) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 0b801ceadb..d01de75d12 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2530,6 +2530,8 @@ "DisablePackageRequestSchema (const)", "DisablePackageResponse (type)", "DisablePackageResponseSchema (const)", + "DiscoveryEnvironment (type)", + "DiscoveryEnvironmentSchema (const)", "DiscoveryResponse (type)", "DiscoverySchema (const)", "DispatcherConfig (type)", @@ -3145,6 +3147,7 @@ "identityFreeEndpointGateFailure (function)", "normalizeEndpointPath (function)", "readServiceSelfInfo (function)", + "resolveDiscoveryEnvironment (function)", "standardErrorCodeForHttpStatus (function)", "validateApiEndpointDeclarations (function)" ], diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 27f8a2524f..8aa4d5a947 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -874,6 +874,7 @@ "api/Discovery:name", "api/Discovery:routes", "api/Discovery:schemaDiscovery", + "api/Discovery:scoping", "api/Discovery:services", "api/Discovery:version", "api/DispatcherConfig:fallback", @@ -1062,6 +1063,7 @@ "api/GetDiscoveryResponse:name", "api/GetDiscoveryResponse:routes", "api/GetDiscoveryResponse:schemaDiscovery", + "api/GetDiscoveryResponse:scoping", "api/GetDiscoveryResponse:services", "api/GetDiscoveryResponse:version", "api/GetEffectivePermissionsResponse:objects", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 1fa6bc8de4..da34730f5f 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -182,6 +182,7 @@ "api/DisablePackageRequest", "api/DisablePackageResponse", "api/Discovery", + "api/DiscoveryEnvironment", "api/DispatcherConfig", "api/DispatcherErrorCode", "api/DispatcherErrorResponse", diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 4e6165fd28..9c7339ea07 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -9,6 +9,7 @@ import { RouteHealthReportSchema, ServiceSelfInfoSchema, readServiceSelfInfo, + resolveDiscoveryEnvironment, SERVICE_SELF_INFO_KEY, type DiscoveryResponse, type ApiRoutes, @@ -936,3 +937,129 @@ describe('Service self-description marker (ADR-0076 D12, #2462)', () => { expect(readServiceSelfInfo({ [SERVICE_SELF_INFO_KEY]: 'stub' })).toBeUndefined(); }); }); + +// =========================================================================== +// [#4828] The two-schema contract, and the gate that keeps them in step +// =========================================================================== +// +// The discovery surface drifted for as long as it did because ONE lenient +// schema stood in for two different jobs. `GetDiscoveryResponseSchema` is +// `DiscoverySchema.partial().required({version}).extend({apiName})`: `.partial()` +// hid every missing REQUIRED key, and zod's default unknown-key strip hid every +// UNDECLARED emitted one. Two producers drifted in opposite directions through +// the same blind spot — one omitted `name`/`environment`/`locale`, the other +// invented `features` and `endpoints`. +// +// The split is now explicit: `DiscoverySchema` binds PRODUCERS, the protocol +// schema is the CONSUMER's tolerant parse, and the producer-side conformance +// tests (one per producer package) check emitted keys against the protocol +// schema's shape. That last step only stays honest while the two shapes are +// the same modulo declared aliases — which is what this gate pins. + +describe('[#4828] DiscoverySchema ↔ GetDiscoveryResponseSchema', () => { + /** The one declared deprecated alias, and its removal is scheduled (protocol 18). */ + const DECLARED_ALIASES = ['apiName'] as const; + + it('the protocol response schema declares exactly DiscoverySchema keys + declared aliases', async () => { + const { GetDiscoveryResponseSchema } = await import('./protocol.zod'); + + const canonical = Object.keys((DiscoverySchema as any).shape); + const response = Object.keys((GetDiscoveryResponseSchema as any).shape); + + expect(new Set(response)).toEqual(new Set([...canonical, ...DECLARED_ALIASES])); + }); + + it('anti-vacuity: both shapes are non-trivial and carry the keys this issue is about', () => { + const canonical = Object.keys((DiscoverySchema as any).shape); + + expect(canonical.length).toBeGreaterThan(5); + // Canonical capability key — the ruling's winner over `features`. + expect(canonical).toContain('capabilities'); + // Newly declared (#4828 decision 3). + expect(canonical).toContain('scoping'); + // The retired spellings must NOT come back as declared keys. + expect(canonical).not.toContain('features'); + expect(canonical).not.toContain('endpoints'); + }); +}); + +describe('[#4828] scoping (decision 3 — declare what REST actually emits)', () => { + const base = { + name: 'ObjectStack', + version: '1.0.0', + environment: 'development', + routes: { data: '/api/v1/data', metadata: '/api/v1/meta' }, + services: minimalServices, + locale: { default: 'en', supported: ['en'], timezone: 'UTC' }, + }; + + it('accepts the shape the REST discovery endpoint emits on a scoped mount', () => { + const parsed = DiscoverySchema.parse({ + ...base, + scoping: { enabled: true, resolution: 'auto', scoped: true, environmentId: 'env_alpha' }, + }); + expect(parsed.scoping?.environmentId).toBe('env_alpha'); + }); + + it('accepts an unscoped mount, where environmentId is absent', () => { + const parsed = DiscoverySchema.parse({ + ...base, + scoping: { enabled: false, resolution: 'auto', scoped: false }, + }); + expect(parsed.scoping?.scoped).toBe(false); + expect(parsed.scoping?.environmentId).toBeUndefined(); + }); + + it('is optional — the dispatcher producer mounts no scoped variant and emits none', () => { + expect(DiscoverySchema.parse(base).scoping).toBeUndefined(); + }); + + it('rejects a resolution outside RestApiConfig.projectResolution', () => { + expect(() => DiscoverySchema.parse({ + ...base, + scoping: { enabled: true, resolution: 'whenever', scoped: true }, + })).toThrow(); + }); +}); + +describe('[#4828] resolveDiscoveryEnvironment (decision 4 — enum, not passthrough)', () => { + it('maps every documented NODE_ENV spelling into the declared enum', () => { + const table: Array<[string, string]> = [ + ['production', 'production'], + ['prod', 'production'], + ['sandbox', 'sandbox'], + ['staging', 'sandbox'], + ['development', 'development'], + ['dev', 'development'], + ['test', 'development'], + ]; + for (const [raw, expected] of table) { + expect(resolveDiscoveryEnvironment(raw), `NODE_ENV=${raw}`).toBe(expected); + } + }); + + it('normalizes case and surrounding whitespace (operator-supplied value)', () => { + expect(resolveDiscoveryEnvironment(' Production ')).toBe('production'); + expect(resolveDiscoveryEnvironment('STAGING')).toBe('sandbox'); + }); + + it('never CLAIMS production for an unset or unrecognized value', () => { + for (const raw of [undefined, null, '', 'qa', 'preview', 'nonsense']) { + expect(resolveDiscoveryEnvironment(raw as any), String(raw)).toBe('development'); + } + }); + + it('every mapped result actually satisfies the declared enum', () => { + for (const raw of ['production', 'prod', 'sandbox', 'staging', 'development', 'dev', 'test', 'qa', '']) { + const parsed = DiscoverySchema.parse({ + name: 'ObjectStack', + version: '1.0.0', + environment: resolveDiscoveryEnvironment(raw), + routes: { data: '/api/v1/data', metadata: '/api/v1/meta' }, + services: minimalServices, + locale: { default: 'en', supported: ['en'], timezone: 'UTC' }, + }); + expect(parsed.environment).toBeDefined(); + } + }); +}); diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index 70511090a3..a46ca515b4 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -208,14 +208,105 @@ export const ApiRoutesSchema = lazySchema(() => z.object({ * Each service entry includes `enabled`, `status`, `route`, and `provider`. * - `routes` is a convenience shortcut: a flat map of service-name → route-path * so that clients can resolve endpoints without iterating the services map. - * - `capabilities`/`features` was removed because it was fully derivable - * from `services[x].enabled`. Use `services` to determine feature availability. + * - `capabilities` is the ONE canonical name for the hierarchical capability + * map (#4828, maintainer ruling 2026-08-05). A top-level `features` key was + * emitted by the runtime dispatcher for a while and was never declared here; + * it is retired — see {@link DiscoverySchema} `capabilities` below. Note the + * surviving `features` is the SUB-key *inside* a capability entry + * (`capabilities..features`), which is declared and stays. + * + * **This schema is authoritative for every producer** (#4828). Both the + * `@objectstack/rest` `/discovery` endpoint and the runtime dispatcher's + * `getDiscoveryInfo()` must satisfy it — required keys included. It is a + * machine-readable surface (AGENTS.md "Route & surface ownership" #4: it must + * not lie), so the gate is `DiscoverySchema.parse()` against each producer's + * LIVE shape, plus a key-set check that nothing undeclared is emitted. Those + * gates live next to each producer: + * `packages/metadata-protocol/src/discovery-schema-conformance.test.ts`, + * `packages/runtime/src/discovery-schema-conformance.test.ts` and + * `packages/rest/src/discovery-schema-conformance.test.ts`. + * + * Why they were needed at all: the only schema the protocol layer referenced + * was `GetDiscoveryResponseSchema` (`./protocol.zod.ts`), which is this schema + * `.partial()`-ed — so missing required keys parsed clean — and a zod object + * strips unknown keys by default — so undeclared keys parsed clean too. Both + * halves of `declared ≠ enforced` were swallowed by one lenient wrapper. + */ +export const DiscoveryEnvironmentSchema = lazySchema(() => z + .enum(['production', 'sandbox', 'development']) + .describe( + 'Deployment posture a discovery response advertises. Deliberately three coarse buckets — ' + + 'a client reads this to answer "am I talking to production?", not to identify a specific ' + + 'environment (that is `sys_environment` / EnvironmentTypeSchema, a richer 7-member taxonomy).' + )); + +export type DiscoveryEnvironment = z.infer; + +/** + * `NODE_ENV` spellings accepted for each declared discovery environment (#4828). + * + * `DiscoverySchema.environment` is an enum, and the runtime dispatcher used to + * pass `NODE_ENV` through raw — so `NODE_ENV=test` (what vitest sets) or + * `staging` advertised a value outside the declared enum on a machine-readable + * surface. The maintainer's 2026-08-05 ruling requires every producer's value + * to land inside the enum, with the disposition of the out-of-enum spellings + * left to this layer and documented. + * + * Normalizing `NODE_ENV` here is the same move `NODE_ENV_TO_SEED_ENV` already + * makes in `packages/metadata-protocol/src/seed-loader.ts`: `NODE_ENV` is an + * OPERATOR-supplied variable at a third-party boundary (Prime Directive #9 + * lists it as exactly that), so normalizing its spellings is not the + * consumer-side tolerance PD #12 forbids — that rule governs OUR OWN metadata + * contract, and this is the far side of it. The `prod`/`dev` short spellings + * are accepted for the same reason they are there: an operator who exported + * `NODE_ENV=prod` gets what they meant rather than an indeterminate answer. + * + * The mapping, and why each row: + * + * | `NODE_ENV` | advertised | why | + * |:------------------------|:---------------|:----| + * | `production`, `prod` | `production` | exact / short spelling | + * | `sandbox` | `sandbox` | exact | + * | `development`, `dev` | `development` | exact / short spelling | + * | `test` | `development` | ephemeral developer-class run (vitest/CI), not a provisioned pre-production copy | + * | `staging` | `sandbox` | pre-production and production-LIKE; certainly not `production`, and `sandbox` is the enum's pre-production member | + * | unset / anything else | `development` | preserves the pre-existing `getEnv('NODE_ENV', 'development')` default, and never CLAIMS production on a guess | + * + * The last row is the safety-relevant one: an unknown spelling degrades to + * `development`, so this function can never advertise `production` for an + * environment it failed to recognise. */ +const NODE_ENV_TO_DISCOVERY_ENVIRONMENT: Readonly> = { + production: 'production', + prod: 'production', + sandbox: 'sandbox', + staging: 'sandbox', + development: 'development', + dev: 'development', + test: 'development', +}; + +/** + * Map a raw `NODE_ENV` (or any operator-supplied environment string) onto the + * `DiscoverySchema.environment` enum. + * + * Shared by both discovery producers so they cannot drift — the same reason + * `serviceUnavailableMessage` / `inProcessServiceMessage` live in + * `@objectstack/spec/system` rather than in each builder. + * + * @param raw the operator-supplied value, typically `process.env.NODE_ENV` + * @returns a value guaranteed to satisfy {@link DiscoveryEnvironmentSchema} + */ +export function resolveDiscoveryEnvironment(raw?: string | null): DiscoveryEnvironment { + if (typeof raw !== 'string') return 'development'; + return NODE_ENV_TO_DISCOVERY_ENVIRONMENT[raw.trim().toLowerCase()] ?? 'development'; +} + export const DiscoverySchema = lazySchema(() => z.object({ /** System Identity */ name: z.string(), version: z.string(), - environment: z.enum(['production', 'sandbox', 'development']), + environment: DiscoveryEnvironmentSchema, /** Dynamic Routing — convenience shortcut for client routing */ routes: ApiRoutesSchema, @@ -259,6 +350,33 @@ export const DiscoverySchema = lazySchema(() => z.object({ jsonSchema: z.string().optional().describe('URL to JSON Schema definitions'), }).optional().describe('Schema discovery endpoints for API toolchain integration'), + /** + * Environment-scoping posture of the server that answered (#4828). + * + * Added by the `@objectstack/rest` discovery endpoint, which is the only + * layer that knows it: the REST server can mount the same API twice — once + * bare (`/api/v1`) and once environment-scoped + * (`/api/v1/environments/:environmentId`) — and a client needs to know which + * mode it reached and how the environment id is resolved before it can build + * URLs. It was emitted (and consumed — `packages/client`'s + * `client.environment-scoping.test.ts` asserts `scoping.enabled` / + * `scoping.resolution` off the live response) long before it was declared; + * the 2026-08-05 ruling declares it here rather than deleting a real + * capability-negotiation fact. + * + * Optional because only the REST producer can answer it: the runtime + * dispatcher serves one kernel and mounts no scoped variant, so it emits + * nothing here rather than inventing a value. + */ + scoping: z.object({ + enabled: z.boolean().describe('Whether environment-scoped routes are mounted at all'), + resolution: z.enum(['required', 'optional', 'auto']) + .describe('How the environment id is resolved when scoping is enabled (mirrors RestApiConfig.projectResolution)'), + scoped: z.boolean().describe('Whether THIS response was served from the environment-scoped mount'), + environmentId: z.string().optional() + .describe('The resolved environment id — present only on a scoped mount'), + }).optional().describe('Environment-scoping posture, added by the REST discovery endpoint'), + /** * Custom metadata key-value pairs for extensibility */ diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index e8c46afaa1..dfb0f5d017 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -116,15 +116,56 @@ export const GetDiscoveryRequestSchema = lazySchema(() => z.object({})); * - `routes` provides a flat endpoint map for client routing. * - `services` is the single source of truth for service availability. * - `apiName` is kept as an optional alias for `name` for backward compatibility. - * + * + * ## This schema is the CLIENT's tolerance, never a producer's licence (#4828) + * + * `.partial()` exists so a client can parse an OLDER server's response without + * exploding, and zod's default unknown-key strip lets it parse a NEWER one. + * That tolerance is correct at this boundary and wrong as a producer contract — + * and for a long time it was the only schema anything referenced, so it acted + * as both. The result was `declared ≠ enforced` in both directions at once: + * `getDiscovery()` never emitted the required `name`/`environment`/`locale` and + * still parsed clean, while the runtime dispatcher emitted `features` and + * `endpoints` — declared nowhere — and also parsed clean. + * + * So the two schemas now have separate jobs, and a gate compares them: + * + * - **{@link DiscoverySchema} is authoritative for PRODUCERS.** Every producer's + * live shape must satisfy it (`packages/metadata-protocol`, `packages/runtime` + * and `packages/rest` each carry a `discovery-schema-conformance.test.ts`). + * - **This schema is the CONSUMER's parse**, and its key set is the allowance + * those producer gates check against — i.e. `DiscoverySchema`'s keys plus the + * declared deprecated aliases. `./discovery.test.ts` pins that equivalence, so + * a key can never again appear on one side only. + * + * ## `apiName` retirement schedule (ADR-0087) + * + * `apiName` is the sole surviving deprecated alias here. `name` is canonical and + * REQUIRED by `DiscoverySchema`; as of protocol 17 every producer emits `name`, + * and `getDiscovery()` additionally emits `apiName` with the identical value so + * clients pinned to the alias keep working. + * + * - **Protocol 17 (now)**: both emitted; `name` canonical, `apiName` deprecated. + * - **Protocol 18**: producers stop emitting `apiName` and it is removed from + * this schema. Consumers migrate to `name` — a pure rename with no semantic + * change, which is why it needs no D2 conversion entry (that table converts + * AUTHORED metadata at load; a response payload has no load seam). + * + * Consumers to migrate before 18 — measured 2026-08-05 across `objectstack`, + * `objectui` and `cloud`: `packages/client/tests/integration/01-discovery.test.ts` + * (TC-DISC-001/002). No product code in any of the three repos reads `apiName`. + * * @see DiscoverySchema in ./discovery.zod.ts — the canonical definition. */ export const GetDiscoveryResponseSchema = lazySchema(() => DiscoverySchema .partial() .required({ version: true }) .extend({ - /** @deprecated Use `name` instead. Kept for backward compatibility. */ - apiName: z.string().optional().describe('API name (deprecated — use name)'), + /** + * @deprecated Use `name` instead. Removed in protocol 18 — see the + * retirement schedule above. Emitted alongside `name` until then. + */ + apiName: z.string().optional().describe('API name (deprecated — use `name`; removed in protocol 18)'), })); /**