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