From 16b3fd1efc588069582134aa25b60ba03b4fddd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 13:01:02 +0000 Subject: [PATCH] fix(runtime-config): resolve OS_PRODUCT_STAGE into branding.stage (#9252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Console's PreviewBadge reads `branding.stage` off `/api/v1/runtime/config`, and objectui's app-shell README documents `OS_PRODUCT_STAGE` / `new RuntimeConfigPlugin({ stage })` as the operator switch that hides the badge. Neither half was implemented — measured with a control, the producer exists in neither the framework nor the cloud repo — so `OS_PRODUCT_STAGE=ga objectstack dev` silently did nothing. Resolved in the plugin constructor beside every sibling branding key, not threaded through the CLI: the card's own repro (examples/app-showcase) constructs its own RuntimeConfigPlugin, which wins over the CLI's by plugin name, so a `Serve.RUNTIME_CONFIG_OPTIONS` route would have left it broken. The cloud distribution's subclass inherits it, keeping one mechanism. Closed value space ('preview' | 'beta' | 'ga'); an unrecognised value is refused with a mount-time warn naming the accepted set rather than forwarded. Unset stays absent — no key at all, so the Console keeps its own documented 'preview' default. Co-Authored-By: Claude --- .changeset/runtime-config-product-stage.md | 57 ++++++ packages/cloud-connection/src/index.ts | 2 +- .../src/runtime-config-plugin.ts | 134 ++++++++++++- .../src/runtime-config-stage.test.ts | 184 ++++++++++++++++++ 4 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 .changeset/runtime-config-product-stage.md create mode 100644 packages/cloud-connection/src/runtime-config-stage.test.ts diff --git a/.changeset/runtime-config-product-stage.md b/.changeset/runtime-config-product-stage.md new file mode 100644 index 0000000000..a6bfd1a85b --- /dev/null +++ b/.changeset/runtime-config-product-stage.md @@ -0,0 +1,57 @@ +--- +"@objectstack/cloud-connection": minor +--- + +fix(runtime-config): `OS_PRODUCT_STAGE` / `branding.stage` actually reaches `/api/v1/runtime/config`, so the documented preview-badge switch stops being a no-op (#9252) + + + +Running `examples/app-showcase` with `OS_PRODUCT_STAGE=ga objectstack dev` left +the Console's "Preview" chip on screen. `RuntimeConfigPlugin` never emitted +`branding.stage`, so objectui's `PreviewBadge` — which reads exactly that key — +never saw the value, and the switch objectui's app-shell README presents as the +operational way to hide the badge did nothing at all. + +**Nobody implemented it, in either distribution.** The card guessed the knob was +"honored only by the cloud distribution"; measured with a control first, so the +zeros are a reading rather than a broken search: + +| probe | result | +|---|---| +| `OS_PRODUCT_STAGE`, framework repo-wide | 0 hits | +| `OS_PRODUCT_STAGE` / `branding.stage` / `PlatformStage`, cloud repo-wide | 0 hits | +| control: `OS_PRODUCT_NAME`, cloud repo | 9 hits | +| control: files mentioning `branding`, cloud repo | 18 files | + +So this is the declared-but-unenforced trap in its purest form: a documented +operator knob with no producer anywhere. Emitting the key restores an +already-declared contract rather than widening a surface — no request that is +accepted today becomes rejected, or vice versa. + +**Resolved in the plugin, not threaded through the CLI.** Both halves of the +documented interface name this plugin (`OS_PRODUCT_STAGE` **or** +`new RuntimeConfigPlugin({ stage })`), every sibling branding key already +resolves `config.X ?? OS_X` in the same constructor, and — decisively — the +card's own repro constructs its **own** `RuntimeConfigPlugin` in +`examples/app-showcase/objectstack.config.ts`, which wins over the CLI's by +plugin name. A value threaded through `Serve.RUNTIME_CONFIG_OPTIONS` would have +left the reported repro still broken. The cloud distribution inherits the fix +for free: its `RuntimeConfigPlugin` extends this one and spreads its config into +`super()`, so there is one mechanism answering this question, not two. + +**The value space is closed** — `'preview' | 'beta' | 'ga'`, mirroring the +`PlatformStage` union the Console branches on (exported as `PlatformStage`). An +unrecognised value is refused and named in a mount-time `warn` listing the +accepted spellings, never forwarded: the SPA discards off-contract values +anyway, so a passthrough would recreate this bug's exact shape — an operator +sets the knob, nothing happens, nothing is said. + +**Unset stays absent.** No `stage` key at all, rather than an empty string or a +default invented server-side, so the Console keeps applying its own documented +`'preview'` default and nothing that works today changes. The regression proof +asserts that direction on **key presence** (`hasOwnProperty`), not +`toBeUndefined()` — `{ stage: undefined }` satisfies the latter while being a +present property that survives `structuredClone` and shows up in `Object.keys`. diff --git a/packages/cloud-connection/src/index.ts b/packages/cloud-connection/src/index.ts index ba90220c7c..19b1a91eb1 100644 --- a/packages/cloud-connection/src/index.ts +++ b/packages/cloud-connection/src/index.ts @@ -54,7 +54,7 @@ export type { export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js'; export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js'; export { RuntimeConfigPlugin } from './runtime-config-plugin.js'; -export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js'; +export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures, PlatformStage } from './runtime-config-plugin.js'; // ADR-0008 consumption side — the self-hosted credential ledger (bind // persists the oscc_ bearer here; forwards present it to the control plane). export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js'; diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts index 6b15794aeb..75ec559a7e 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -15,9 +15,48 @@ * singleEnvironment: boolean, * defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... }, - * branding: { productName, productShortName, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor } + * branding: { productName, productShortName, stage?, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor } * } * + * ## `branding.stage` — a documented knob that this runtime never sent (#9252) + * + * The Console's `PreviewBadge` reads `branding.stage` to decide whether to show + * its "Preview" / "Beta" chip, and objectui's app-shell README states the + * operator interface in as many words: *"Operators set it with + * `OS_PRODUCT_STAGE` or `new RuntimeConfigPlugin({ stage })`"*. Neither half + * existed. Measured on `main` with a control before this change (the control is + * what makes the zeros a reading rather than a broken search): + * + * OS_PRODUCT_STAGE, repo-wide 0 hits + * branding.stage / PlatformStage, cloud repo 0 hits + * control: OS_PRODUCT_NAME, cloud repo 9 hits + * + * So `OS_PRODUCT_STAGE=ga objectstack dev` left the badge up, and the card's + * guess that "the knob is honored only by the cloud distribution" was wrong in + * the operator's favour: **no** distribution honoured it. Emitting the key is + * restoration of an already-declared contract, not a new surface. + * + * It is resolved HERE and not threaded in from the CLI, which is the one design + * choice in this fix worth stating. Both halves of the documented interface name + * this plugin, every sibling branding key already resolves `config.X ?? OS_X` + * in this constructor, and — decisively — the card's own repro + * (`examples/app-showcase`) constructs its **own** `RuntimeConfigPlugin` in + * `objectstack.config.ts`, which takes precedence over the CLI's by plugin name. + * A value threaded through `Serve.RUNTIME_CONFIG_OPTIONS` would therefore have + * left the reported repro still broken, and made every other host responsible + * for remembering one more passthrough — the every-host-must-remember failure + * `features.installLocal` above was already demoted for. + * + * The value space is CLOSED (`preview` | `beta` | `ga`), mirroring the + * `PlatformStage` union the Console branches on. An unrecognised value is + * refused and reported at mount time rather than forwarded: the SPA would + * discard it anyway (its own `isPlatformStage` guard keeps the current stage on + * a malformed payload), so a passthrough would recreate this bug's exact shape — + * an operator setting the knob, nothing happening, nothing said. Unset stays + * **absent**: no `stage` key at all, never an empty string or a guessed default, + * so the Console keeps applying its own documented `'preview'` default and + * nothing that works today changes. + * * ## Feature seam (open-core boundary — cloud ADR-0012) * * This open package owns the **mechanism**: serve a per-request `features` @@ -253,6 +292,42 @@ function someRoutePattern(rawApp: unknown, matches: (pattern: string) => boolean } +/** + * Product lifecycle stage — drives the Console's top-bar preview/beta chip + * (#9252). + * + * A CLOSED set, not free text, because the consumer BRANCHES on the value: + * `PreviewBadge` renders "Preview" for `preview`, "Beta" for `beta`, and + * nothing at all for `ga`. This union is the server-side mirror of the + * `PlatformStage` union in objectui's `app-shell/src/runtime-config.ts`; the + * two are pinned together by the operator-facing documentation in its README + * rather than by an import, since neither repo depends on the other here. + * + * There is deliberately no `'preview'` default on this side — see + * {@link RuntimeConfigPluginConfig.stage}. + */ +export type PlatformStage = 'preview' | 'beta' | 'ga'; + +/** The accepted spellings, in the order the diagnostic lists them. */ +const PLATFORM_STAGES: readonly PlatformStage[] = ['preview', 'beta', 'ga']; + +/** + * Narrow an operator-supplied string to the closed stage set. + * + * Exact match against the trimmed value — no case folding, no synonyms. A + * near-miss (`GA`, `general-availability`) is REFUSED and reported, not + * guessed: silently coercing it would fossilize a second spelling of a + * documented key, and this file's whole subject is a knob that appeared to work + * while doing nothing. + */ +function asPlatformStage(value: string | undefined): PlatformStage | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return (PLATFORM_STAGES as readonly string[]).includes(trimmed) + ? (trimmed as PlatformStage) + : undefined; +} + /** * Feature-flag overrides a host's distribution policy can derive per request. * @@ -326,6 +401,23 @@ export interface RuntimeConfigPluginConfig { productName?: string; /** Short product name (PWA shortName, compact spots). Defaults to productName. */ productShortName?: string; + /** + * Product lifecycle stage driving the Console's preview/beta chip (#9252). + * Falls back to the `OS_PRODUCT_STAGE` env var; set `'ga'` to hide the + * badge. Both spellings are the ones objectui's app-shell README already + * documents to operators. + * + * ⛔ Unset means **unset**: the response then carries no `stage` key at all, + * rather than an empty string or a default invented here. The Console + * already owns the documented default (`'preview'` until a server says + * otherwise), so guessing one on this side would be this card's own defect + * pointing the other way — a consumer misreading a missing thing, except + * the server would be the one asserting it. + * + * An unrecognised value (env typo, or a JS host outside this type) is + * refused and warned about at mount time — never forwarded. + */ + stage?: PlatformStage; /** Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. */ logoUrl?: string; /** Absolute or relative URL for the favicon. Falls back to OS_FAVICON_URL env var. */ @@ -369,6 +461,16 @@ export class RuntimeConfigPlugin implements Plugin { private readonly singleEnvironment: boolean; private readonly productName: string; private readonly productShortName: string; + /** Resolved stage, or `undefined` for "send no key" (unset or refused). */ + private readonly stage: PlatformStage | undefined; + /** + * The rejected spelling, kept only so `start()` can name it once. Holding + * it — rather than warning from the constructor — is what the route-ledger + * diagnostic below already does: the constructor has no logger, and a + * silently dropped operator knob is exactly the thing that must not be + * invisible from the SPA end. + */ + private readonly refusedStage: string | undefined; private readonly logoUrl: string | undefined; private readonly faviconUrl: string | undefined; private readonly brandColor: string | undefined; @@ -393,6 +495,15 @@ export class RuntimeConfigPlugin implements Plugin { const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim(); this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS'; this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName; + // Same precedence as every branding key above — the HOST's explicit + // option wins, the env var is the operator's fallback — but resolved + // through the closed set, so an unrecognised spelling from either door + // becomes "no key" plus one diagnostic rather than an out-of-contract + // value the Console would silently discard. + const envStage = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_STAGE : undefined)?.trim(); + const requestedStage = config.stage ?? (envStage || undefined); + this.stage = asPlatformStage(requestedStage); + this.refusedStage = this.stage === undefined ? requestedStage : undefined; const envLogoUrl = (typeof process !== 'undefined' ? process.env?.OS_LOGO_URL : undefined)?.trim(); const envFaviconUrl = (typeof process !== 'undefined' ? process.env?.OS_FAVICON_URL : undefined)?.trim(); const envBrandColor = (typeof process !== 'undefined' ? process.env?.OS_BRAND_COLOR : undefined)?.trim(); @@ -441,6 +552,20 @@ export class RuntimeConfigPlugin implements Plugin { ); } + // An operator who set OS_PRODUCT_STAGE (or a JS host that passed + // `stage`) to something outside the closed set gets told here, + // naming what was refused and what is accepted. `warn`, not + // `error`: this is a FUNCTIONAL degradation — the badge visibly + // stays up and the next person to look finds out — with nothing + // claimed-persisted going missing behind it. + if (this.refusedStage !== undefined) { + ctx.logger?.warn?.( + `[RuntimeConfigPlugin] ignoring unrecognised product stage ${JSON.stringify(this.refusedStage)} ` + + `(OS_PRODUCT_STAGE / the \`stage\` option) — branding.stage will be omitted and the Console ` + + `keeps its default preview badge. Accepted values: ${PLATFORM_STAGES.join(', ')}.`, + ); + } + // A multi-tenant runtime serves many subdomains, each mapped to // one environment. Telling the SPA *which* environment it is // attached to (per-request) lets the App Marketplace skip the @@ -541,6 +666,13 @@ export class RuntimeConfigPlugin implements Plugin { branding: { productName: this.productName, productShortName: this.productShortName, + // Spread, not `stage: this.stage` — the sibling keys + // below may serialize as `undefined` (JSON.stringify + // drops them) but this one is asserted on by KEY + // PRESENCE, so it must never exist as a + // present-and-undefined property on the object handed + // to a non-JSON consumer or a test. + ...(this.stage !== undefined ? { stage: this.stage } : {}), logoUrl: this.logoUrl, faviconUrl: this.faviconUrl, brandColor: this.brandColor, diff --git a/packages/cloud-connection/src/runtime-config-stage.test.ts b/packages/cloud-connection/src/runtime-config-stage.test.ts new file mode 100644 index 0000000000..95dc33e4d0 --- /dev/null +++ b/packages/cloud-connection/src/runtime-config-stage.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `branding.stage` — the documented preview-badge switch (#9252). + * + * objectui's app-shell README tells operators the badge is turned off with + * `OS_PRODUCT_STAGE` or `new RuntimeConfigPlugin({ stage })`. Neither half was + * implemented, so `OS_PRODUCT_STAGE=ga objectstack dev` left the "Preview" chip + * on screen and nothing said why. + * + * Both directions are pinned here, and the absent one is asserted on KEY + * PRESENCE rather than `toBeUndefined()` — the two are not the same claim, and + * only one of them is this card's contract. `{ stage: undefined }` satisfies + * `toBeUndefined()` while being a present property: it survives structuredClone, + * shows up in `Object.keys`, and reaches any consumer that does not round-trip + * the body through JSON. The contract is that the key is NOT THERE. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js'; + +interface Served { + body: any; + warnings: string[]; +} + +/** + * Mount the plugin on a Hono-shaped raw app and serve one request, keeping the + * warnings it emitted at mount time. Same harness shape as + * `runtime-config-plugin.test.ts`; it records `warn` because the refusal path + * is only observable there. + */ +async function serve(pluginConfig: RuntimeConfigPluginConfig = {}): Promise { + let handler: ((c: any) => Promise) | undefined; + const rawApp = { + routes: [] as Array<{ method: string; path: string }>, + get(path: string, h: (c: any) => Promise) { + this.routes.push({ method: 'GET', path }); + if (path === '/api/v1/runtime/config') handler = h; + }, + }; + const warnings: string[] = []; + const ctx: any = { + logger: { info() {}, warn: (m: string) => { warnings.push(String(m)); } }, + getService: (n: string) => { + if (n === 'http-server') return { getRawApp: () => rawApp }; + throw new Error(`no ${n}`); + }, + hooks: [] as Array<() => Promise>, + hook(_e: string, cb: () => Promise) { this.hooks.push(cb); }, + }; + const plugin = new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, ...pluginConfig }); + await plugin.start(ctx); + for (const cb of ctx.hooks) await cb(); + if (!handler) throw new Error('handler not mounted'); + const body = await handler({ + req: { header: () => undefined }, + json: (b: any) => b, + }); + return { body, warnings }; +} + +/** The assertion this card turns on: is the key THERE, whatever its value? */ +function hasStageKey(body: any): boolean { + return Object.prototype.hasOwnProperty.call(body.branding, 'stage'); +} + +describe('RuntimeConfigPlugin — branding.stage (#9252)', () => { + const saved = process.env.OS_PRODUCT_STAGE; + + beforeEach(() => { delete process.env.OS_PRODUCT_STAGE; }); + afterEach(() => { + if (saved === undefined) delete process.env.OS_PRODUCT_STAGE; + else process.env.OS_PRODUCT_STAGE = saved; + }); + + describe('direction 1 — the env var reaches the response', () => { + it('OS_PRODUCT_STAGE=ga is served as branding.stage (the reported repro)', async () => { + process.env.OS_PRODUCT_STAGE = 'ga'; + const { body, warnings } = await serve(); + expect(hasStageKey(body)).toBe(true); + expect(body.branding.stage).toBe('ga'); + expect(warnings).toEqual([]); + }); + + it.each(['preview', 'beta', 'ga'] as const)('carries the whole closed set: %s', async (stage) => { + process.env.OS_PRODUCT_STAGE = stage; + const { body } = await serve(); + expect(body.branding.stage).toBe(stage); + }); + + it('trims surrounding whitespace, like every sibling branding env read', async () => { + process.env.OS_PRODUCT_STAGE = ' ga '; + const { body } = await serve(); + expect(body.branding.stage).toBe('ga'); + }); + + it('the host option wins over the env var — the host decides, env is its fallback', async () => { + process.env.OS_PRODUCT_STAGE = 'preview'; + const { body } = await serve({ stage: 'ga' }); + expect(body.branding.stage).toBe('ga'); + }); + + it('the host option works with no env var set at all', async () => { + const { body } = await serve({ stage: 'beta' }); + expect(body.branding.stage).toBe('beta'); + }); + }); + + describe('direction 2 — unset stays ABSENT, not empty and not guessed', () => { + it('omits the key entirely when nothing set it', async () => { + const { body, warnings } = await serve(); + // The load-bearing assertion: absent, not present-and-undefined. + expect(hasStageKey(body)).toBe(false); + expect(Object.keys(body.branding)).not.toContain('stage'); + // ...and no invented default in its place. The Console owns the + // documented 'preview' default; asserting a value here would be + // this card's own defect pointing the other way. + expect(body.branding.stage).toBeUndefined(); + expect(warnings).toEqual([]); + }); + + it('survives a JSON round trip as an absent key', async () => { + const { body } = await serve(); + const parsed = JSON.parse(JSON.stringify(body)); + expect(Object.prototype.hasOwnProperty.call(parsed.branding, 'stage')).toBe(false); + }); + + it('an EMPTY env var reads as unset — absent, and silent (not a typo)', async () => { + process.env.OS_PRODUCT_STAGE = ' '; + const { body, warnings } = await serve(); + expect(hasStageKey(body)).toBe(false); + expect(warnings).toEqual([]); + }); + + it('leaves the sibling branding keys exactly as they were', async () => { + const { body } = await serve(); + expect(body.branding.productName).toBe('ObjectOS'); + expect(body.branding.productShortName).toBe('ObjectOS'); + expect(body.branding.pwaThemeColor).toBe('#4f46e5'); + }); + }); + + describe('the value space is closed — an unrecognised stage is refused, loudly', () => { + it('refuses a near-miss spelling rather than coercing it', async () => { + process.env.OS_PRODUCT_STAGE = 'GA'; + const { body } = await serve(); + expect(hasStageKey(body)).toBe(false); + }); + + it.each(['general-availability', 'production', 'stable', 'ga '.repeat(3)])( + 'refuses %j', + async (value) => { + process.env.OS_PRODUCT_STAGE = value; + const { body } = await serve(); + expect(hasStageKey(body)).toBe(false); + }, + ); + + it('names the refused value AND the accepted set, so the operator can fix it', async () => { + process.env.OS_PRODUCT_STAGE = 'GA'; + const { warnings } = await serve(); + const warning = warnings.find((w) => w.includes('product stage')); + expect(warning).toBeDefined(); + // What was refused, quoted, and where it came from. + expect(warning).toContain('"GA"'); + expect(warning).toContain('OS_PRODUCT_STAGE'); + // ...and every accepted spelling, so the fix needs no source dive. + for (const accepted of ['preview', 'beta', 'ga']) expect(warning).toContain(accepted); + }); + + it('refuses an off-contract value from a JS host too, not only from the env', async () => { + const { body, warnings } = await serve({ stage: 'launched' as any }); + expect(hasStageKey(body)).toBe(false); + expect(warnings.some((w) => w.includes('"launched"'))).toBe(true); + }); + + it('a valid value emits no warning', async () => { + process.env.OS_PRODUCT_STAGE = 'ga'; + const { warnings } = await serve(); + expect(warnings.some((w) => w.includes('product stage'))).toBe(false); + }); + }); +});