Skip to content

Commit fc15f0a

Browse files
hotlongclaude
andauthored
feat(cloud-connection): publish the control plane's upgrade entry as an absolute upgradeUrl on /api/v1/runtime/config (#14590)
RuntimeConfigPlugin gains one optional host option, upgradeUrl, on the exported RuntimeConfigPluginConfig, and the served payload gains one optional top-level key of the same name beside cloudUrl. Declared, it is served verbatim; undeclared (or empty), the key is absent; not an absolute http(s) URL, it is refused and named at mount and no key is served. Contract test covers both directions on key presence plus the refusal group; README gains the field row and semantics; changeset minor. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 2a26536 commit fc15f0a

4 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
feat(cloud-connection): `/api/v1/runtime/config` can carry the control plane's upgrade / billing entry as an absolute `upgradeUrl` (#14514)
6+
7+
**Additive, optional, default-absent.** No existing key moves; a runtime that
8+
declares nothing serves the same payload as before.
9+
10+
The tenant Console offers an "upgrade" exit when the AI quota guardrail refuses
11+
a turn. It used to compose the target from `cloudUrl` plus a guessed console
12+
mount, app slug and page route — three facts owned by whoever deploys the
13+
control plane — and missed all three, landing on the control plane's API 404.
14+
The control plane's own two call sites did not even agree on the spelling.
15+
Maintainer ruling 2026-09-02 (cloud#1850, option A): the payload carries the
16+
absolute URL; the host that owns the page declares it; the Console renders a
17+
link only when the key is present (objectui already consumes it that way).
18+
19+
`RuntimeConfigPlugin` gains one option, `upgradeUrl`, on the exported
20+
`RuntimeConfigPluginConfig`, and the served payload gains one optional top-level
21+
key of the same name beside `cloudUrl`:
22+
23+
- **declared** → served verbatim (no trailing-slash trimming, no
24+
re-serialisation);
25+
- **undeclared** (or empty / whitespace-only) → the key is NOT THERE — asserted
26+
on key presence, not on `undefined`;
27+
- **not absolute** (`/settings/billing`, a bare host, a non-`http(s)` scheme) →
28+
refused and named in the boot log, no key served. The Console opens this URL
29+
from the tenant origin, so a relative path would resolve against the wrong
30+
host and recreate the defect this key removes.
31+
32+
Host option only — no env var. The value belongs to the distribution whose
33+
control plane serves the page; the cloud subclass fills it (cloud#1850's other
34+
half, after the pin bump).

packages/cloud-connection/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,49 @@ const sink = readClientErrorReporting(payload);
130130
if (sink) initErrorReporting(sink);
131131
```
132132

133+
## The control plane's upgrade entry is published, not derived
134+
135+
`GET /api/v1/runtime/config` can carry an optional top-level `upgradeUrl`
136+
the **absolute** URL of the control plane's upgrade / billing page — beside the
137+
`cloudUrl` the Console already reads:
138+
139+
| Key | Type | Default | Meaning |
140+
|---|---|---|---|
141+
| `upgradeUrl` | absolute `http(s)` URL | **absent** | Where the Console sends a user who chooses "upgrade" (AI quota exhausted, plan limit hit). Declared by the host that owns the page and served verbatim; no key means "render no link". |
142+
143+
```json
144+
{
145+
"cloudUrl": "https://cloud.example.com",
146+
"upgradeUrl": "https://cloud.example.com/_console/apps/cloud_control/page/pricing"
147+
}
148+
```
149+
150+
It is declared by the host that composes the plugin — there is no env var,
151+
because the value belongs to the distribution whose control plane serves the
152+
page:
153+
154+
```ts
155+
new RuntimeConfigPlugin({ upgradeUrl: 'https://cloud.example.com/_console/apps/cloud_control/page/pricing' })
156+
```
157+
158+
Why it is published rather than composed by the Console: the destination
159+
depends on the console mount, the app slug and the page route — three facts
160+
owned by whoever deploys the control plane — and a consumer in another repo
161+
that guessed them landed on the control plane's API 404. Three properties,
162+
pinned by `runtime-config-upgrade-url.test.ts`:
163+
164+
- **Absent by default.** A vanilla `objectstack dev`, a self-hosted box and an
165+
air-gapped deployment have no billing page. They serve no `upgradeUrl` key at
166+
all — never `""` or a guessed default — and the Console reads "no key" as "no
167+
link".
168+
- **Verbatim when declared.** No trailing-slash trimming, no re-serialisation:
169+
what the host declared is what the Console opens.
170+
- **Absolute, or refused at mount.** The Console opens this URL from the
171+
*tenant* origin, so a relative path (`/settings/billing`) would resolve
172+
against the tenant runtime and recreate the guessed-path 404 this key
173+
removes. A relative path or a non-`http(s)` scheme is refused and named in
174+
the boot log, and no key is served.
175+
133176
## Boundary (open mechanism, closed intelligence)
134177

135178
This package is **mechanism**: proxying a catalog, installing into the local

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,39 @@
1212
*
1313
* {
1414
* 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)
1516
* singleEnvironment: boolean,
1617
* defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname
1718
* features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... },
1819
* branding: { productName, productShortName, stage?, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor },
1920
* telemetry: { errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate } }
2021
* }
2122
*
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+
*
2248
* ## `branding.stage` — a documented knob that this runtime never sent (#9252)
2349
*
2450
* The Console's `PreviewBadge` reads `branding.stage` to decide whether to show
@@ -434,6 +460,22 @@ function asPlatformStage(value: string | undefined): PlatformStage | undefined {
434460
: undefined;
435461
}
436462

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+
437479
/**
438480
* Feature-flag overrides a host's distribution policy can derive per request.
439481
*
@@ -463,6 +505,34 @@ export interface RuntimeConfigPluginConfig {
463505
* for marketplace + install).
464506
*/
465507
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;
466536
/**
467537
* CEILING for the `features.installLocal` flag — no longer its source
468538
* (#8388).
@@ -613,6 +683,10 @@ export class RuntimeConfigPlugin implements Plugin {
613683
* invisible from the SPA end.
614684
*/
615685
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;
616690
private readonly logoUrl: string | undefined;
617691
private readonly faviconUrl: string | undefined;
618692
private readonly brandColor: string | undefined;
@@ -655,6 +729,19 @@ export class RuntimeConfigPlugin implements Plugin {
655729
const requestedStage = config.stage ?? (envStage || undefined);
656730
this.stage = asPlatformStage(requestedStage);
657731
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;
658745
const envLogoUrl = (typeof process !== 'undefined' ? process.env?.OS_LOGO_URL : undefined)?.trim();
659746
const envFaviconUrl = (typeof process !== 'undefined' ? process.env?.OS_FAVICON_URL : undefined)?.trim();
660747
const envBrandColor = (typeof process !== 'undefined' ? process.env?.OS_BRAND_COLOR : undefined)?.trim();
@@ -743,6 +830,20 @@ export class RuntimeConfigPlugin implements Plugin {
743830
);
744831
}
745832

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+
746847
// Telemetry knobs the operator got wrong (#12681). Same shape and
747848
// same reason as the stage refusal above: they meant to CONFIGURE
748849
// something, the value was not understood, and it was refused
@@ -858,6 +959,13 @@ export class RuntimeConfigPlugin implements Plugin {
858959
}
859960
return c.json({
860961
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 } : {}),
861969
singleEnvironment: resolvedSingleEnv,
862970
defaultOrgId,
863971
defaultEnvironmentId,

0 commit comments

Comments
 (0)