|
12 | 12 | * |
13 | 13 | * { |
14 | 14 | * cloudUrl: string, // base URL of the upstream cloud ('' = same origin) |
| 15 | + * upgradeUrl?: string, // absolute URL of the control plane's upgrade / billing entry — absent unless declared (#14514) |
15 | 16 | * singleEnvironment: boolean, |
16 | 17 | * defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname |
17 | 18 | * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... }, |
18 | 19 | * branding: { productName, productShortName, stage?, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor }, |
19 | 20 | * telemetry: { errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate } } |
20 | 21 | * } |
21 | 22 | * |
| 23 | + * ## `upgradeUrl` — the control plane's upgrade entry is published, not derived (#14514) |
| 24 | + * |
| 25 | + * The tenant Console offers an "upgrade" exit when the AI quota guardrail |
| 26 | + * refuses a turn. It used to COMPOSE the target from `cloudUrl` plus a guessed |
| 27 | + * console mount, app slug and page route — three facts owned by whoever |
| 28 | + * deploys the control plane — and missed all three, landing on the control |
| 29 | + * plane's API 404. The control plane's own two call sites did not even agree |
| 30 | + * on the spelling, which settles it: a consumer in another repo cannot derive |
| 31 | + * what the producer cannot keep to one dialect. Maintainer ruling 2026-09-02 |
| 32 | + * (cloud#1850, option A): this payload carries the ABSOLUTE URL, the host that |
| 33 | + * owns the page declares it, and the Console renders a link only when the key |
| 34 | + * is present. |
| 35 | + * |
| 36 | + * Optional and default-absent — the same shape as `branding.stage` below: a |
| 37 | + * runtime that says nothing serves no key, never `''` or a guessed default, |
| 38 | + * because a vanilla `objectstack dev`, a self-hosted box and an air-gapped |
| 39 | + * deployment have no billing page to point at. Declared, it passes through |
| 40 | + * VERBATIM. The one thing enforced is that it is absolute (`http:` / |
| 41 | + * `https:`): the Console opens it from the TENANT origin, so a relative path — |
| 42 | + * the control plane's own current `upgrade_url` dialect — would resolve against |
| 43 | + * the tenant runtime and recreate the guessed-path 404 this key removes. |
| 44 | + * Refused loudly at mount, never coerced, like every other knob in this file. |
| 45 | + * Host option only, no env var: the value belongs to the distribution whose |
| 46 | + * control plane serves the page, and the cloud subclass fills it. |
| 47 | + * |
22 | 48 | * ## `branding.stage` — a documented knob that this runtime never sent (#9252) |
23 | 49 | * |
24 | 50 | * The Console's `PreviewBadge` reads `branding.stage` to decide whether to show |
@@ -434,6 +460,22 @@ function asPlatformStage(value: string | undefined): PlatformStage | undefined { |
434 | 460 | : undefined; |
435 | 461 | } |
436 | 462 |
|
| 463 | +/** |
| 464 | + * Is this host-supplied upgrade entry an ABSOLUTE http(s) URL? (#14514) |
| 465 | + * |
| 466 | + * The parse decides only whether the value is forwarded at all; the value |
| 467 | + * itself is served verbatim, never re-serialised, so what the host declared |
| 468 | + * is byte-for-byte what the Console opens. Anything the WHATWG parser cannot |
| 469 | + * resolve without a base (a relative path, a bare host) and any scheme other |
| 470 | + * than http(s) — this string is rendered as a link in every browser that |
| 471 | + * loads the Console — is refused. |
| 472 | + */ |
| 473 | +function isAbsoluteHttpUrl(value: string): boolean { |
| 474 | + let parsed: URL; |
| 475 | + try { parsed = new URL(value); } catch { return false; } |
| 476 | + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; |
| 477 | +} |
| 478 | + |
437 | 479 | /** |
438 | 480 | * Feature-flag overrides a host's distribution policy can derive per request. |
439 | 481 | * |
@@ -463,6 +505,34 @@ export interface RuntimeConfigPluginConfig { |
463 | 505 | * for marketplace + install). |
464 | 506 | */ |
465 | 507 | controlPlaneUrl?: string; |
| 508 | + /** |
| 509 | + * Absolute URL of the control plane's upgrade / billing entry, served |
| 510 | + * verbatim as the top-level `upgradeUrl` key beside `cloudUrl` (#14514). |
| 511 | + * |
| 512 | + * The tenant Console offers an "upgrade" exit when the AI quota guardrail |
| 513 | + * refuses a turn. It used to compose the target from `cloudUrl` plus a |
| 514 | + * guessed console mount, app slug and route — three facts that belong to |
| 515 | + * whoever deploys the control plane — and missed all three (a 404 on the |
| 516 | + * control plane's API router). The destination is therefore declared here |
| 517 | + * by the host that owns it, and the Console renders the link only when |
| 518 | + * the key is present. |
| 519 | + * |
| 520 | + * ⛔ Default **absent**: unset serves no `upgradeUrl` key at all, never an |
| 521 | + * empty string or a guessed default. A vanilla `objectstack dev`, a |
| 522 | + * self-hosted box and an air-gapped deployment have no billing page, and |
| 523 | + * the Console already reads "no key" as "no link". Empty / whitespace-only |
| 524 | + * reads as unset (absent, silent). |
| 525 | + * |
| 526 | + * ⛔ Must be ABSOLUTE (`http:` / `https:`). A relative path such as |
| 527 | + * `/settings/billing` is refused and named at mount time, never forwarded: |
| 528 | + * the Console opens this URL from the TENANT origin, where a relative path |
| 529 | + * would resolve against the tenant runtime and recreate exactly the |
| 530 | + * guessed-path 404 this key exists to remove. |
| 531 | + * |
| 532 | + * Host option only — no env var. The value belongs to the distribution |
| 533 | + * whose control plane serves the page; the cloud subclass fills it. |
| 534 | + */ |
| 535 | + upgradeUrl?: string; |
466 | 536 | /** |
467 | 537 | * CEILING for the `features.installLocal` flag — no longer its source |
468 | 538 | * (#8388). |
@@ -613,6 +683,10 @@ export class RuntimeConfigPlugin implements Plugin { |
613 | 683 | * invisible from the SPA end. |
614 | 684 | */ |
615 | 685 | private readonly refusedStage: string | undefined; |
| 686 | + /** The declared upgrade entry, or `undefined` for "send no key" (unset or refused). */ |
| 687 | + private readonly upgradeUrl: string | undefined; |
| 688 | + /** The refused spelling, kept so `start()` can name it once — same reason as `refusedStage`. */ |
| 689 | + private readonly refusedUpgradeUrl: string | undefined; |
616 | 690 | private readonly logoUrl: string | undefined; |
617 | 691 | private readonly faviconUrl: string | undefined; |
618 | 692 | private readonly brandColor: string | undefined; |
@@ -655,6 +729,19 @@ export class RuntimeConfigPlugin implements Plugin { |
655 | 729 | const requestedStage = config.stage ?? (envStage || undefined); |
656 | 730 | this.stage = asPlatformStage(requestedStage); |
657 | 731 | this.refusedStage = this.stage === undefined ? requestedStage : undefined; |
| 732 | + // Upgrade / billing entry (#14514). Empty reads as unset; anything |
| 733 | + // actually said that is not an absolute http(s) URL is refused and |
| 734 | + // named at mount, never forwarded. Verbatim on acceptance — the |
| 735 | + // original string, not the parser's re-serialisation. |
| 736 | + const requestedUpgradeUrl = typeof config.upgradeUrl === 'string' && config.upgradeUrl.trim() !== '' |
| 737 | + ? config.upgradeUrl |
| 738 | + : undefined; |
| 739 | + this.upgradeUrl = requestedUpgradeUrl !== undefined && isAbsoluteHttpUrl(requestedUpgradeUrl) |
| 740 | + ? requestedUpgradeUrl |
| 741 | + : undefined; |
| 742 | + this.refusedUpgradeUrl = requestedUpgradeUrl !== undefined && this.upgradeUrl === undefined |
| 743 | + ? requestedUpgradeUrl |
| 744 | + : undefined; |
658 | 745 | const envLogoUrl = (typeof process !== 'undefined' ? process.env?.OS_LOGO_URL : undefined)?.trim(); |
659 | 746 | const envFaviconUrl = (typeof process !== 'undefined' ? process.env?.OS_FAVICON_URL : undefined)?.trim(); |
660 | 747 | const envBrandColor = (typeof process !== 'undefined' ? process.env?.OS_BRAND_COLOR : undefined)?.trim(); |
@@ -743,6 +830,20 @@ export class RuntimeConfigPlugin implements Plugin { |
743 | 830 | ); |
744 | 831 | } |
745 | 832 |
|
| 833 | + // A host that declared an upgrade entry the Console could not |
| 834 | + // safely open (#14514) — a relative path, or a non-http scheme. |
| 835 | + // Same shape as the stage refusal above: they meant to configure |
| 836 | + // something, it was refused rather than coerced, and the |
| 837 | + // consequence (no upgrade link rendered) must not be silent. |
| 838 | + if (this.refusedUpgradeUrl !== undefined) { |
| 839 | + ctx.logger?.warn?.( |
| 840 | + `[RuntimeConfigPlugin] ignoring upgradeUrl ${JSON.stringify(this.refusedUpgradeUrl)} ` |
| 841 | + + `(the \`upgradeUrl\` option): it must be an absolute http(s) URL — the Console opens it ` |
| 842 | + + `from the tenant origin, so a relative path would resolve against the wrong host. ` |
| 843 | + + `No upgradeUrl key is served and the Console renders no upgrade link.`, |
| 844 | + ); |
| 845 | + } |
| 846 | + |
746 | 847 | // Telemetry knobs the operator got wrong (#12681). Same shape and |
747 | 848 | // same reason as the stage refusal above: they meant to CONFIGURE |
748 | 849 | // something, the value was not understood, and it was refused |
@@ -858,6 +959,13 @@ export class RuntimeConfigPlugin implements Plugin { |
858 | 959 | } |
859 | 960 | return c.json({ |
860 | 961 | cloudUrl: this.cloudUrl, |
| 962 | + // Declared by the host, never derived (#14514). Spread, |
| 963 | + // not `upgradeUrl: this.upgradeUrl`, for the same reason |
| 964 | + // as `branding.stage` below: the contract is asserted on |
| 965 | + // KEY PRESENCE and the Console reads "no key" as "no |
| 966 | + // link", so a present-and-undefined property must never |
| 967 | + // reach a non-JSON consumer or a test. |
| 968 | + ...(this.upgradeUrl !== undefined ? { upgradeUrl: this.upgradeUrl } : {}), |
861 | 969 | singleEnvironment: resolvedSingleEnv, |
862 | 970 | defaultOrgId, |
863 | 971 | defaultEnvironmentId, |
|
0 commit comments