Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/health-discovery-version-derived.md
Original file line numberDiff line numberDiff line change
@@ -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()`.
18 changes: 16 additions & 2 deletions packages/runtime/src/http-dispatcher.root.test.ts
Original file line numberDiff line numberDiff line change
@@ -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: {},
Expand DownExpand Up@@ -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');
Expand All@@ -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();
});

Expand Down
105 changes: 105 additions & 0 deletions packages/runtime/src/http-dispatcher.runtime-version.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<unknown> {
const res = await dispatcher.dispatch('GET', '/health', undefined, undefined, {} as any);
return res.response?.body?.data?.version;
}

async function discoveryVersion(dispatcher: HttpDispatcher): Promise<unknown> {
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');
});
});
22 changes: 20 additions & 2 deletions packages/runtime/src/http-dispatcher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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; }
};
Expand DownExpand Up@@ -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,
}),
}),
Expand DownExpand Up@@ -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
Expand Down
82 changes: 82 additions & 0 deletions packages/runtime/src/runtime-version.ts
Original file line numberDiff line numberDiff line change
@@ -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';
}
11 changes: 11 additions & 0 deletions packages/runtime/tsup.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
Loading