Skip to content

Commit 990a893

Browse files
os-zhuangclaude
andauthored
fix(runtime-config): resolve OS_PRODUCT_STAGE into branding.stage (#9252) (#9658)
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 <noreply@anthropic.com>
1 parent 73010f1 commit 990a893

4 files changed

Lines changed: 375 additions & 2 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
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)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) One constructor option
8+
and one optional response key are ADDED; nothing authorable is renamed, retired
9+
or tombstoned, and no stored `sys_metadata` shape changes. There is no
10+
conversion to register. -->
11+
12+
Running `examples/app-showcase` with `OS_PRODUCT_STAGE=ga objectstack dev` left
13+
the Console's "Preview" chip on screen. `RuntimeConfigPlugin` never emitted
14+
`branding.stage`, so objectui's `PreviewBadge` — which reads exactly that key —
15+
never saw the value, and the switch objectui's app-shell README presents as the
16+
operational way to hide the badge did nothing at all.
17+
18+
**Nobody implemented it, in either distribution.** The card guessed the knob was
19+
"honored only by the cloud distribution"; measured with a control first, so the
20+
zeros are a reading rather than a broken search:
21+
22+
| probe | result |
23+
|---|---|
24+
| `OS_PRODUCT_STAGE`, framework repo-wide | 0 hits |
25+
| `OS_PRODUCT_STAGE` / `branding.stage` / `PlatformStage`, cloud repo-wide | 0 hits |
26+
| control: `OS_PRODUCT_NAME`, cloud repo | 9 hits |
27+
| control: files mentioning `branding`, cloud repo | 18 files |
28+
29+
So this is the declared-but-unenforced trap in its purest form: a documented
30+
operator knob with no producer anywhere. Emitting the key restores an
31+
already-declared contract rather than widening a surface — no request that is
32+
accepted today becomes rejected, or vice versa.
33+
34+
**Resolved in the plugin, not threaded through the CLI.** Both halves of the
35+
documented interface name this plugin (`OS_PRODUCT_STAGE` **or**
36+
`new RuntimeConfigPlugin({ stage })`), every sibling branding key already
37+
resolves `config.X ?? OS_X` in the same constructor, and — decisively — the
38+
card's own repro constructs its **own** `RuntimeConfigPlugin` in
39+
`examples/app-showcase/objectstack.config.ts`, which wins over the CLI's by
40+
plugin name. A value threaded through `Serve.RUNTIME_CONFIG_OPTIONS` would have
41+
left the reported repro still broken. The cloud distribution inherits the fix
42+
for free: its `RuntimeConfigPlugin` extends this one and spreads its config into
43+
`super()`, so there is one mechanism answering this question, not two.
44+
45+
**The value space is closed**`'preview' | 'beta' | 'ga'`, mirroring the
46+
`PlatformStage` union the Console branches on (exported as `PlatformStage`). An
47+
unrecognised value is refused and named in a mount-time `warn` listing the
48+
accepted spellings, never forwarded: the SPA discards off-contract values
49+
anyway, so a passthrough would recreate this bug's exact shape — an operator
50+
sets the knob, nothing happens, nothing is said.
51+
52+
**Unset stays absent.** No `stage` key at all, rather than an empty string or a
53+
default invented server-side, so the Console keeps applying its own documented
54+
`'preview'` default and nothing that works today changes. The regression proof
55+
asserts that direction on **key presence** (`hasOwnProperty`), not
56+
`toBeUndefined()``{ stage: undefined }` satisfies the latter while being a
57+
present property that survives `structuredClone` and shows up in `Object.keys`.

packages/cloud-connection/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export type {
5454
export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';
5555
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
5656
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
57-
export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js';
57+
export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures, PlatformStage } from './runtime-config-plugin.js';
5858
// ADR-0008 consumption side — the self-hosted credential ledger (bind
5959
// persists the oscc_ bearer here; forwards present it to the control plane).
6060
export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js';

packages/cloud-connection/src/runtime-config-plugin.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,48 @@
1515
* singleEnvironment: boolean,
1616
* defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname
1717
* features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... },
18-
* branding: { productName, productShortName, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor }
18+
* branding: { productName, productShortName, stage?, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor }
1919
* }
2020
*
21+
* ## `branding.stage` — a documented knob that this runtime never sent (#9252)
22+
*
23+
* The Console's `PreviewBadge` reads `branding.stage` to decide whether to show
24+
* its "Preview" / "Beta" chip, and objectui's app-shell README states the
25+
* operator interface in as many words: *"Operators set it with
26+
* `OS_PRODUCT_STAGE` or `new RuntimeConfigPlugin({ stage })`"*. Neither half
27+
* existed. Measured on `main` with a control before this change (the control is
28+
* what makes the zeros a reading rather than a broken search):
29+
*
30+
* OS_PRODUCT_STAGE, repo-wide 0 hits
31+
* branding.stage / PlatformStage, cloud repo 0 hits
32+
* control: OS_PRODUCT_NAME, cloud repo 9 hits
33+
*
34+
* So `OS_PRODUCT_STAGE=ga objectstack dev` left the badge up, and the card's
35+
* guess that "the knob is honored only by the cloud distribution" was wrong in
36+
* the operator's favour: **no** distribution honoured it. Emitting the key is
37+
* restoration of an already-declared contract, not a new surface.
38+
*
39+
* It is resolved HERE and not threaded in from the CLI, which is the one design
40+
* choice in this fix worth stating. Both halves of the documented interface name
41+
* this plugin, every sibling branding key already resolves `config.X ?? OS_X`
42+
* in this constructor, and — decisively — the card's own repro
43+
* (`examples/app-showcase`) constructs its **own** `RuntimeConfigPlugin` in
44+
* `objectstack.config.ts`, which takes precedence over the CLI's by plugin name.
45+
* A value threaded through `Serve.RUNTIME_CONFIG_OPTIONS` would therefore have
46+
* left the reported repro still broken, and made every other host responsible
47+
* for remembering one more passthrough — the every-host-must-remember failure
48+
* `features.installLocal` above was already demoted for.
49+
*
50+
* The value space is CLOSED (`preview` | `beta` | `ga`), mirroring the
51+
* `PlatformStage` union the Console branches on. An unrecognised value is
52+
* refused and reported at mount time rather than forwarded: the SPA would
53+
* discard it anyway (its own `isPlatformStage` guard keeps the current stage on
54+
* a malformed payload), so a passthrough would recreate this bug's exact shape —
55+
* an operator setting the knob, nothing happening, nothing said. Unset stays
56+
* **absent**: no `stage` key at all, never an empty string or a guessed default,
57+
* so the Console keeps applying its own documented `'preview'` default and
58+
* nothing that works today changes.
59+
*
2160
* ## Feature seam (open-core boundary — cloud ADR-0012)
2261
*
2362
* This open package owns the **mechanism**: serve a per-request `features`
@@ -253,6 +292,42 @@ function someRoutePattern(rawApp: unknown, matches: (pattern: string) => boolean
253292
}
254293

255294

295+
/**
296+
* Product lifecycle stage — drives the Console's top-bar preview/beta chip
297+
* (#9252).
298+
*
299+
* A CLOSED set, not free text, because the consumer BRANCHES on the value:
300+
* `PreviewBadge` renders "Preview" for `preview`, "Beta" for `beta`, and
301+
* nothing at all for `ga`. This union is the server-side mirror of the
302+
* `PlatformStage` union in objectui's `app-shell/src/runtime-config.ts`; the
303+
* two are pinned together by the operator-facing documentation in its README
304+
* rather than by an import, since neither repo depends on the other here.
305+
*
306+
* There is deliberately no `'preview'` default on this side — see
307+
* {@link RuntimeConfigPluginConfig.stage}.
308+
*/
309+
export type PlatformStage = 'preview' | 'beta' | 'ga';
310+
311+
/** The accepted spellings, in the order the diagnostic lists them. */
312+
const PLATFORM_STAGES: readonly PlatformStage[] = ['preview', 'beta', 'ga'];
313+
314+
/**
315+
* Narrow an operator-supplied string to the closed stage set.
316+
*
317+
* Exact match against the trimmed value — no case folding, no synonyms. A
318+
* near-miss (`GA`, `general-availability`) is REFUSED and reported, not
319+
* guessed: silently coercing it would fossilize a second spelling of a
320+
* documented key, and this file's whole subject is a knob that appeared to work
321+
* while doing nothing.
322+
*/
323+
function asPlatformStage(value: string | undefined): PlatformStage | undefined {
324+
if (value === undefined) return undefined;
325+
const trimmed = value.trim();
326+
return (PLATFORM_STAGES as readonly string[]).includes(trimmed)
327+
? (trimmed as PlatformStage)
328+
: undefined;
329+
}
330+
256331
/**
257332
* Feature-flag overrides a host's distribution policy can derive per request.
258333
*
@@ -326,6 +401,23 @@ export interface RuntimeConfigPluginConfig {
326401
productName?: string;
327402
/** Short product name (PWA shortName, compact spots). Defaults to productName. */
328403
productShortName?: string;
404+
/**
405+
* Product lifecycle stage driving the Console's preview/beta chip (#9252).
406+
* Falls back to the `OS_PRODUCT_STAGE` env var; set `'ga'` to hide the
407+
* badge. Both spellings are the ones objectui's app-shell README already
408+
* documents to operators.
409+
*
410+
* ⛔ Unset means **unset**: the response then carries no `stage` key at all,
411+
* rather than an empty string or a default invented here. The Console
412+
* already owns the documented default (`'preview'` until a server says
413+
* otherwise), so guessing one on this side would be this card's own defect
414+
* pointing the other way — a consumer misreading a missing thing, except
415+
* the server would be the one asserting it.
416+
*
417+
* An unrecognised value (env typo, or a JS host outside this type) is
418+
* refused and warned about at mount time — never forwarded.
419+
*/
420+
stage?: PlatformStage;
329421
/** Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. */
330422
logoUrl?: string;
331423
/** Absolute or relative URL for the favicon. Falls back to OS_FAVICON_URL env var. */
@@ -369,6 +461,16 @@ export class RuntimeConfigPlugin implements Plugin {
369461
private readonly singleEnvironment: boolean;
370462
private readonly productName: string;
371463
private readonly productShortName: string;
464+
/** Resolved stage, or `undefined` for "send no key" (unset or refused). */
465+
private readonly stage: PlatformStage | undefined;
466+
/**
467+
* The rejected spelling, kept only so `start()` can name it once. Holding
468+
* it — rather than warning from the constructor — is what the route-ledger
469+
* diagnostic below already does: the constructor has no logger, and a
470+
* silently dropped operator knob is exactly the thing that must not be
471+
* invisible from the SPA end.
472+
*/
473+
private readonly refusedStage: string | undefined;
372474
private readonly logoUrl: string | undefined;
373475
private readonly faviconUrl: string | undefined;
374476
private readonly brandColor: string | undefined;
@@ -393,6 +495,15 @@ export class RuntimeConfigPlugin implements Plugin {
393495
const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim();
394496
this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS';
395497
this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName;
498+
// Same precedence as every branding key above — the HOST's explicit
499+
// option wins, the env var is the operator's fallback — but resolved
500+
// through the closed set, so an unrecognised spelling from either door
501+
// becomes "no key" plus one diagnostic rather than an out-of-contract
502+
// value the Console would silently discard.
503+
const envStage = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_STAGE : undefined)?.trim();
504+
const requestedStage = config.stage ?? (envStage || undefined);
505+
this.stage = asPlatformStage(requestedStage);
506+
this.refusedStage = this.stage === undefined ? requestedStage : undefined;
396507
const envLogoUrl = (typeof process !== 'undefined' ? process.env?.OS_LOGO_URL : undefined)?.trim();
397508
const envFaviconUrl = (typeof process !== 'undefined' ? process.env?.OS_FAVICON_URL : undefined)?.trim();
398509
const envBrandColor = (typeof process !== 'undefined' ? process.env?.OS_BRAND_COLOR : undefined)?.trim();
@@ -441,6 +552,20 @@ export class RuntimeConfigPlugin implements Plugin {
441552
);
442553
}
443554

555+
// An operator who set OS_PRODUCT_STAGE (or a JS host that passed
556+
// `stage`) to something outside the closed set gets told here,
557+
// naming what was refused and what is accepted. `warn`, not
558+
// `error`: this is a FUNCTIONAL degradation — the badge visibly
559+
// stays up and the next person to look finds out — with nothing
560+
// claimed-persisted going missing behind it.
561+
if (this.refusedStage !== undefined) {
562+
ctx.logger?.warn?.(
563+
`[RuntimeConfigPlugin] ignoring unrecognised product stage ${JSON.stringify(this.refusedStage)} `
564+
+ `(OS_PRODUCT_STAGE / the \`stage\` option) — branding.stage will be omitted and the Console `
565+
+ `keeps its default preview badge. Accepted values: ${PLATFORM_STAGES.join(', ')}.`,
566+
);
567+
}
568+
444569
// A multi-tenant runtime serves many subdomains, each mapped to
445570
// one environment. Telling the SPA *which* environment it is
446571
// attached to (per-request) lets the App Marketplace skip the
@@ -541,6 +666,13 @@ export class RuntimeConfigPlugin implements Plugin {
541666
branding: {
542667
productName: this.productName,
543668
productShortName: this.productShortName,
669+
// Spread, not `stage: this.stage` — the sibling keys
670+
// below may serialize as `undefined` (JSON.stringify
671+
// drops them) but this one is asserted on by KEY
672+
// PRESENCE, so it must never exist as a
673+
// present-and-undefined property on the object handed
674+
// to a non-JSON consumer or a test.
675+
...(this.stage !== undefined ? { stage: this.stage } : {}),
544676
logoUrl: this.logoUrl,
545677
faviconUrl: this.faviconUrl,
546678
brandColor: this.brandColor,

0 commit comments

Comments
 (0)