diff --git a/.changeset/health-discovery-version-derived.md b/.changeset/health-discovery-version-derived.md new file mode 100644 index 0000000000..e66acecd24 --- /dev/null +++ b/.changeset/health-discovery-version-derived.md @@ -0,0 +1,32 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): `version` on `GET /health` and the discovery payload is derived, not a hardcoded `'1.0.0'` literal (#10993) + +Both `HttpDispatcher`'s `/health` handler and its discovery payload +(`getDiscoveryInfo()`, served at `/`, `/discovery`, and everywhere else +`DiscoverySchema` is answered) reported a literal `version: '1.0.0'` — +unrelated to the package, the build, or anything actually running, and +identical on every deployment (single-env, EE, every hosted tenant). A field +that looks authoritative and lies is worse than no field: `cloud` +(objectstack-ai/cloud#1537) needed `/health` to name the serving artifact +after a production container served a three-month-old image behind four +green deploys, could not use it because the value never changed, and +resorted to a container-stamped response header instead. + +`HttpDispatcher` now resolves `version` once at construction +(`resolveRuntimeVersion()`, `packages/runtime/src/runtime-version.ts`): + +1. `OS_RUNTIME_VERSION`, if the host injects one (an image tag, a git sha, a + release version) — the SAME env var `cloud-connection-plugin.ts` already + reads for a device-bind approval URL, reused rather than inventing a + second name for the same value. +2. Otherwise, `@objectstack/runtime`'s own resolved `package.json` version. +3. `'unknown'` only if both are unavailable — never a plausible-looking + literal a caller could mistake for real identity. + +The liveness contract is unchanged: `/health` still checks nothing beyond +"this process is executing code" (framework#3756); this only changes where +one field's VALUE comes from. No schema widening — `DiscoverySchema` already +declared `version: z.string()`. diff --git a/packages/runtime/src/http-dispatcher.root.test.ts b/packages/runtime/src/http-dispatcher.root.test.ts index 286ab689a7..0dca1008b8 100644 --- a/packages/runtime/src/http-dispatcher.root.test.ts +++ b/packages/runtime/src/http-dispatcher.root.test.ts @@ -1,13 +1,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequire } from 'node:module'; import { HttpDispatcher } from './http-dispatcher'; import { ObjectKernel } from '@objectstack/core'; +// [#10993] `version` on both the root ("") and `/discovery` payloads is no +// longer the literal '1.0.0' — it is `@objectstack/runtime`'s own resolved +// package version (no `OS_RUNTIME_VERSION` stamp injected below), read the +// same way the production code does so this assertion can never silently +// drift from what `resolveRuntimeVersion()` actually returns. +const RUNTIME_PACKAGE_VERSION = ( + createRequire(import.meta.url)('../package.json') as { version: string } +).version; + describe('HttpDispatcher Root Handling', () => { let kernel: ObjectKernel; let dispatcher: HttpDispatcher; beforeEach(() => { + // No build stamp injected — these assertions exercise the package- + // version FALLBACK path of resolveRuntimeVersion() (#10993). + delete process.env.OS_RUNTIME_VERSION; + // Mock minimal Kernel structure kernel = { services: {}, @@ -37,7 +51,7 @@ describe('HttpDispatcher Root Handling', () => { expect(data).toBeDefined(); // getDiscoveryInfo returns 'name' not 'apiName' expect(data.name).toBe('ObjectOS'); - expect(data.version).toBe('1.0.0'); + expect(data.version).toBe(RUNTIME_PACKAGE_VERSION); expect(data.routes).toBeDefined(); // Since we passed empty prefix in dispatch code (hardcoded), routes should be relative expect(data.routes.metadata).toBe('/meta'); @@ -54,7 +68,7 @@ describe('HttpDispatcher Root Handling', () => { const data = result.response?.body?.data; expect(data).toBeDefined(); expect(data.name).toBe('ObjectOS'); - expect(data.version).toBe('1.0.0'); + expect(data.version).toBe(RUNTIME_PACKAGE_VERSION); expect(data.routes).toBeDefined(); }); diff --git a/packages/runtime/src/http-dispatcher.runtime-version.test.ts b/packages/runtime/src/http-dispatcher.runtime-version.test.ts new file mode 100644 index 0000000000..d7a6d3e0df --- /dev/null +++ b/packages/runtime/src/http-dispatcher.runtime-version.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10993 — `version` on `GET /health` and in the discovery payload + * (`registerBuiltinDomains()` / `getDiscoveryInfo()` in `./http-dispatcher.ts`) + * must be DERIVED: an injected `OS_RUNTIME_VERSION` stamp, falling back to + * the resolved `@objectstack/runtime` package version — never the `'1.0.0'` + * literal both sites hardcoded before this fix. + * + * Every assertion here drives the REAL `HttpDispatcher.dispatch()` path + * (never the source text or `resolveRuntimeVersion()` in isolation), so it + * fails if either handler stops actually reading the derived value — the + * anti-vacuity requirement #10993 calls out by name. A reverse-verification + * pass (temporarily restoring the `'1.0.0'` literal on both sites) confirmed + * this file goes red at exactly that restoration and names the failing site + * — see the PR description for the transcript; that step is deliberately + * NOT encoded as a test here, since a test cannot un-revert itself mid-run. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createRequire } from 'node:module'; +import { HttpDispatcher } from './http-dispatcher'; +import { ObjectKernel } from '@objectstack/core'; + +/** + * The real fallback value, read the same way `resolveRuntimeVersion()` reads + * it — so this assertion tracks the production code instead of independently + * guessing a version string that could drift from it. + */ +const RUNTIME_PACKAGE_VERSION = ( + createRequire(import.meta.url)('../package.json') as { version: string } +).version; + +function newDispatcher(): HttpDispatcher { + const kernel = { + services: {}, + context: { getService: vi.fn() }, + } as unknown as ObjectKernel; + return new HttpDispatcher(kernel); +} + +async function healthVersion(dispatcher: HttpDispatcher): Promise { + const res = await dispatcher.dispatch('GET', '/health', undefined, undefined, {} as any); + return res.response?.body?.data?.version; +} + +async function discoveryVersion(dispatcher: HttpDispatcher): Promise { + const res = await dispatcher.dispatch('GET', '/discovery', undefined, {}, {} as any); + return res.response?.body?.data?.version; +} + +describe('HttpDispatcher — served `version` is derived, not a literal (#10993)', () => { + const ORIGINAL_STAMP = process.env.OS_RUNTIME_VERSION; + + afterEach(() => { + if (ORIGINAL_STAMP === undefined) delete process.env.OS_RUNTIME_VERSION; + else process.env.OS_RUNTIME_VERSION = ORIGINAL_STAMP; + }); + + // A value with no plausible relationship to '1.0.0' or the package + // version — if either handler answered anything else, it would prove + // the handler is not actually reading the injected stamp. + const INJECTED_STAMP = 'stamp-9f3c7a1-cloud1537-could-not-be-a-coincidence'; + + it('serves the injected OS_RUNTIME_VERSION stamp verbatim on /health', async () => { + process.env.OS_RUNTIME_VERSION = INJECTED_STAMP; + const dispatcher = newDispatcher(); + + expect(await healthVersion(dispatcher)).toBe(INJECTED_STAMP); + }); + + it('serves the SAME injected stamp on the discovery payload — one derived source, two callers', async () => { + process.env.OS_RUNTIME_VERSION = INJECTED_STAMP; + const dispatcher = newDispatcher(); + + expect(await discoveryVersion(dispatcher)).toBe(INJECTED_STAMP); + }); + + it('falls back to the resolved package version — not a literal, not undefined — when no stamp is injected', async () => { + delete process.env.OS_RUNTIME_VERSION; + const dispatcher = newDispatcher(); + + const health = await healthVersion(dispatcher); + const discovery = await discoveryVersion(dispatcher); + + expect(health).toBe(RUNTIME_PACKAGE_VERSION); + expect(discovery).toBe(RUNTIME_PACKAGE_VERSION); + expect(health).not.toBe('1.0.0'); + expect(health).not.toBeUndefined(); + }); + + it('resolves the stamp/fallback ONCE at construction — a later env change does not retroactively affect an already-constructed dispatcher', async () => { + delete process.env.OS_RUNTIME_VERSION; + const dispatcher = newDispatcher(); + expect(await healthVersion(dispatcher)).toBe(RUNTIME_PACKAGE_VERSION); + + process.env.OS_RUNTIME_VERSION = 'late-stamp-must-not-apply-to-the-existing-instance'; + expect(await healthVersion(dispatcher)).toBe(RUNTIME_PACKAGE_VERSION); + + // A freshly constructed dispatcher DOES pick up the now-set stamp — + // confirms the previous assertion is about construction-time + // resolution, not about the stamp never being read at all. + expect(await healthVersion(newDispatcher())).toBe('late-stamp-must-not-apply-to-the-existing-instance'); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 2026ee9c68..6f9fe59768 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -9,6 +9,7 @@ import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } f import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import { readServiceSelfInfo, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; +import { resolveRuntimeVersion } from './runtime-version.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js'; // `import * as actionExec from './action-execution.js'` was dropped in #4936: @@ -258,6 +259,16 @@ export class HttpDispatcher { * `this.kernel` (#5155). */ private readonly defaultKernel: ObjectKernel; + /** + * The value served as `version` on `GET /health` and in the discovery + * payload (#10993) — an injected `OS_RUNTIME_VERSION` stamp, falling back + * to the resolved `@objectstack/runtime` package version. Resolved ONCE + * here, at construction, not per request: it names the running process, + * which cannot change over the dispatcher's lifetime. See + * {@link resolveRuntimeVersion} for the derivation and the fallback + * order. + */ + private readonly runtimeVersion: string; private defaultProject?: { environmentId: string; orgId?: string }; private kernelResolver?: KernelResolver; private scopeManager?: EnvironmentScopeManager; @@ -304,6 +315,7 @@ export class HttpDispatcher { */ constructor(kernel: ObjectKernel, _envRegistryIgnored?: unknown, options?: HttpDispatcherOptions) { this.defaultKernel = kernel; + this.runtimeVersion = resolveRuntimeVersion(); const resolveService = (name: string): any => { try { return (kernel as any).getService?.(name); } catch { return undefined; } }; @@ -558,7 +570,7 @@ export class HttpDispatcher { response: this.success({ status: 'ok', timestamp: new Date().toISOString(), - version: '1.0.0', + version: this.runtimeVersion, uptime: typeof process !== 'undefined' ? process.uptime() : undefined, }), }), @@ -1407,7 +1419,13 @@ export class HttpDispatcher { return { name: 'ObjectOS', - version: '1.0.0', + // [#10993] Was a hardcoded '1.0.0' literal — the identical defect + // as `/health`'s `version` (same field name, same "System + // Identity" contract in `DiscoverySchema`, same artifact-identity + // purpose), fixed the same way: {@link resolveRuntimeVersion}, + // resolved once at dispatcher construction. See + // `packages/runtime/src/runtime-version.ts`. + version: this.runtimeVersion, // [#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 diff --git a/packages/runtime/src/runtime-version.ts b/packages/runtime/src/runtime-version.ts new file mode 100644 index 0000000000..417748e6c7 --- /dev/null +++ b/packages/runtime/src/runtime-version.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Resolves the value `HttpDispatcher` serves as `version` on `GET /health` + * and in the discovery payload (`registerBuiltinDomains()` / + * `getDiscoveryInfo()` in `./http-dispatcher.ts`). + * + * #10993 — both surfaces served a hardcoded `'1.0.0'` literal, unrelated to + * the package, the build, or anything else actually running. cloud#1537 + * needed the SERVING PROCESS to name the artifact it runs (a production + * container served a three-month-old image behind four green deploys, and + * boot-freshness checks alone could not see it); `/health` was the natural + * home but the field never changed, so cloud stamped its own response + * header (`x-objectstack-build-sha`) instead. Fixed per the #10993 triage + * ruling: an injected build stamp / env var, read once at dispatcher + * construction, falling back to the resolved `@objectstack/runtime` package + * version — never a literal. The liveness contract itself is unchanged + * (framework#3756): this file only supplies the VALUE, `/health` still + * checks nothing beyond "this process is executing code". + * + * `OS_RUNTIME_VERSION` is not a new name: `cloud-connection-plugin.ts` + * already reads it (with a per-plugin literal fallback) to name the runtime + * in a device-bind approval URL and the cloud-side bind payload. Reusing it + * here — with a real package-version fallback instead of a fixed string — + * keeps one env var meaning one thing across the codebase, per AGENTS.md + * Prime Directive #9's `OS_{DOMAIN}_{NAME}` config-value shape (`RUNTIME` is + * the domain, `VERSION` the value; no `_ENABLED`/`_ALLOW_`/`_SKIP_` shape + * applies — this is a value, not a flag or an escape hatch). + */ + +import { createRequire } from 'node:module'; +import { getEnv } from '@objectstack/core'; + +/** + * `null` = not yet resolved: `undefined` (a legitimate resolved outcome — + * the read failed) would compare equal to "unset" if used as the sentinel. + */ +let cachedPackageVersion: string | undefined | null = null; + +/** + * `@objectstack/runtime`'s own installed version, read from its + * `package.json`. That file sits one directory above both `src/` (tests + * running against source) and the bundled `dist/index.{js,cjs}` (tsup, + * single-entry, `splitting: false`, so the whole package collapses into one + * file per format) — `../package.json` resolves the same package.json from + * either shape. `createRequire` (not a static `import … with { type: + * "json" }`) matches the resolution style already used for this exact kind + * of read elsewhere in the repo (`packages/cli/src/utils/spec-version.ts`) + * and needs no JSON-module-assertion support from the build target. + */ +function resolvePackageVersion(): string | undefined { + if (cachedPackageVersion !== null) return cachedPackageVersion; + try { + const require = createRequire(import.meta.url); + const pkg = require('../package.json') as { version?: unknown }; + cachedPackageVersion = typeof pkg.version === 'string' && pkg.version.length > 0 + ? pkg.version + : undefined; + } catch { + cachedPackageVersion = undefined; + } + return cachedPackageVersion; +} + +/** + * The version this runtime process should report of itself. + * + * 1. `OS_RUNTIME_VERSION` — an operator/build-pipeline-injected stamp (image + * tag, git sha, release version). Read live (not memoized): a construction + * time env read is exactly what "at kernel construction" calls for, and + * tests that set/unset the variable around constructing a fresh + * `HttpDispatcher` must see it take effect without a stale cache. + * 2. The resolved `@objectstack/runtime` package version, when no stamp was + * injected. + * 3. `'unknown'` — only if BOTH of the above are unavailable (the package's + * own `package.json` is unreadable). Honest about not knowing, rather than + * a plausible-looking literal a caller could mistake for real identity — + * the exact failure mode #10993 exists to close. + */ +export function resolveRuntimeVersion(): string { + return getEnv('OS_RUNTIME_VERSION') || resolvePackageVersion() || 'unknown'; +} diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts index f3e18f209e..c0c84ea7b1 100644 --- a/packages/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -10,6 +10,17 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + // [#10993] `runtime-version.ts` reads its own `package.json` via + // `createRequire(import.meta.url)` — correct as written for the ESM + // output, but esbuild EMPTIES `import.meta` in a CJS bundle (measured: + // `packages/lint`'s own dist already warns `[empty-import-meta]` on the + // dependency that does this without the shim). `shims: true` makes tsup + // rewrite `import.meta.url` in the CJS build to a real `__filename`-derived + // value instead (its `assets/cjs_shims.js`), so `require('@objectstack/ + // runtime')` resolves the SAME package.json `dist/index.js` does. Need-based + // injection — nothing here references `__dirname`/`__filename`, so the ESM + // build's shim path is a no-op. + shims: true, // Mark driver packages as external so they are resolved at runtime, not bundled external: [ '@objectstack/driver-memory',