diff --git a/.changeset/runtime-config-upgrade-url.md b/.changeset/runtime-config-upgrade-url.md new file mode 100644 index 0000000000..fa70c0a345 --- /dev/null +++ b/.changeset/runtime-config-upgrade-url.md @@ -0,0 +1,34 @@ +--- +"@objectstack/cloud-connection": minor +--- + +feat(cloud-connection): `/api/v1/runtime/config` can carry the control plane's upgrade / billing entry as an absolute `upgradeUrl` (#14514) + +**Additive, optional, default-absent.** No existing key moves; a runtime that +declares nothing serves the same payload as before. + +The tenant Console offers an "upgrade" exit when the AI quota guardrail refuses +a turn. It used to compose the target from `cloudUrl` plus a guessed console +mount, app slug and page route — three facts owned by whoever deploys the +control plane — and missed all three, landing on the control plane's API 404. +The control plane's own two call sites did not even agree on the spelling. +Maintainer ruling 2026-09-02 (cloud#1850, option A): the payload carries the +absolute URL; the host that owns the page declares it; the Console renders a +link only when the key is present (objectui already consumes it that way). + +`RuntimeConfigPlugin` gains one option, `upgradeUrl`, on the exported +`RuntimeConfigPluginConfig`, and the served payload gains one optional top-level +key of the same name beside `cloudUrl`: + +- **declared** → served verbatim (no trailing-slash trimming, no + re-serialisation); +- **undeclared** (or empty / whitespace-only) → the key is NOT THERE — asserted + on key presence, not on `undefined`; +- **not absolute** (`/settings/billing`, a bare host, a non-`http(s)` scheme) → + refused and named in the boot log, no key served. The Console opens this URL + from the tenant origin, so a relative path would resolve against the wrong + host and recreate the defect this key removes. + +Host option only — no env var. The value belongs to the distribution whose +control plane serves the page; the cloud subclass fills it (cloud#1850's other +half, after the pin bump). diff --git a/packages/cloud-connection/README.md b/packages/cloud-connection/README.md index 8132f6071a..cdfa29f912 100644 --- a/packages/cloud-connection/README.md +++ b/packages/cloud-connection/README.md @@ -130,6 +130,49 @@ const sink = readClientErrorReporting(payload); if (sink) initErrorReporting(sink); ``` +## The control plane's upgrade entry is published, not derived + +`GET /api/v1/runtime/config` can carry an optional top-level `upgradeUrl` — +the **absolute** URL of the control plane's upgrade / billing page — beside the +`cloudUrl` the Console already reads: + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `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". | + +```json +{ + "cloudUrl": "https://cloud.example.com", + "upgradeUrl": "https://cloud.example.com/_console/apps/cloud_control/page/pricing" +} +``` + +It is declared by the host that composes the plugin — there is no env var, +because the value belongs to the distribution whose control plane serves the +page: + +```ts +new RuntimeConfigPlugin({ upgradeUrl: 'https://cloud.example.com/_console/apps/cloud_control/page/pricing' }) +``` + +Why it is published rather than composed by the Console: the destination +depends on the console mount, the app slug and the page route — three facts +owned by whoever deploys the control plane — and a consumer in another repo +that guessed them landed on the control plane's API 404. Three properties, +pinned by `runtime-config-upgrade-url.test.ts`: + +- **Absent by default.** A vanilla `objectstack dev`, a self-hosted box and an + air-gapped deployment have no billing page. They serve no `upgradeUrl` key at + all — never `""` or a guessed default — and the Console reads "no key" as "no + link". +- **Verbatim when declared.** No trailing-slash trimming, no re-serialisation: + what the host declared is what the Console opens. +- **Absolute, or refused at mount.** The Console opens this URL from the + *tenant* origin, so a relative path (`/settings/billing`) would resolve + against the tenant runtime and recreate the guessed-path 404 this key + removes. A relative path or a non-`http(s)` scheme is refused and named in + the boot log, and no key is served. + ## Boundary (open mechanism, closed intelligence) This package is **mechanism**: proxying a catalog, installing into the local diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts index b481aa7ffa..418e09a963 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -12,6 +12,7 @@ * * { * cloudUrl: string, // base URL of the upstream cloud ('' = same origin) + * upgradeUrl?: string, // absolute URL of the control plane's upgrade / billing entry — absent unless declared (#14514) * singleEnvironment: boolean, * defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... }, @@ -19,6 +20,31 @@ * telemetry: { errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate } } * } * + * ## `upgradeUrl` — the control plane's upgrade entry is published, not derived (#14514) + * + * The tenant Console offers an "upgrade" exit when the AI quota guardrail + * refuses a turn. It used to COMPOSE the target from `cloudUrl` plus a guessed + * console mount, app slug and page route — three facts owned by whoever + * deploys the control plane — and missed all three, landing on the control + * plane's API 404. The control plane's own two call sites did not even agree + * on the spelling, which settles it: a consumer in another repo cannot derive + * what the producer cannot keep to one dialect. Maintainer ruling 2026-09-02 + * (cloud#1850, option A): this payload carries the ABSOLUTE URL, the host that + * owns the page declares it, and the Console renders a link only when the key + * is present. + * + * Optional and default-absent — the same shape as `branding.stage` below: a + * runtime that says nothing serves no key, never `''` or a guessed default, + * because a vanilla `objectstack dev`, a self-hosted box and an air-gapped + * deployment have no billing page to point at. Declared, it passes through + * VERBATIM. The one thing enforced is that it is absolute (`http:` / + * `https:`): the Console opens it from the TENANT origin, so a relative path — + * the control plane's own current `upgrade_url` dialect — would resolve against + * the tenant runtime and recreate the guessed-path 404 this key removes. + * Refused loudly at mount, never coerced, like every other knob in this file. + * Host option only, no env var: the value belongs to the distribution whose + * control plane serves the page, and the cloud subclass fills it. + * * ## `branding.stage` — a documented knob that this runtime never sent (#9252) * * The Console's `PreviewBadge` reads `branding.stage` to decide whether to show @@ -434,6 +460,22 @@ function asPlatformStage(value: string | undefined): PlatformStage | undefined { : undefined; } +/** + * Is this host-supplied upgrade entry an ABSOLUTE http(s) URL? (#14514) + * + * The parse decides only whether the value is forwarded at all; the value + * itself is served verbatim, never re-serialised, so what the host declared + * is byte-for-byte what the Console opens. Anything the WHATWG parser cannot + * resolve without a base (a relative path, a bare host) and any scheme other + * than http(s) — this string is rendered as a link in every browser that + * loads the Console — is refused. + */ +function isAbsoluteHttpUrl(value: string): boolean { + let parsed: URL; + try { parsed = new URL(value); } catch { return false; } + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; +} + /** * Feature-flag overrides a host's distribution policy can derive per request. * @@ -463,6 +505,34 @@ export interface RuntimeConfigPluginConfig { * for marketplace + install). */ controlPlaneUrl?: string; + /** + * Absolute URL of the control plane's upgrade / billing entry, served + * verbatim as the top-level `upgradeUrl` key beside `cloudUrl` (#14514). + * + * The tenant Console offers an "upgrade" exit when the AI quota guardrail + * refuses a turn. It used to compose the target from `cloudUrl` plus a + * guessed console mount, app slug and route — three facts that belong to + * whoever deploys the control plane — and missed all three (a 404 on the + * control plane's API router). The destination is therefore declared here + * by the host that owns it, and the Console renders the link only when + * the key is present. + * + * ⛔ Default **absent**: unset serves no `upgradeUrl` key at all, never an + * empty string or a guessed default. A vanilla `objectstack dev`, a + * self-hosted box and an air-gapped deployment have no billing page, and + * the Console already reads "no key" as "no link". Empty / whitespace-only + * reads as unset (absent, silent). + * + * ⛔ Must be ABSOLUTE (`http:` / `https:`). A relative path such as + * `/settings/billing` is refused and named at mount time, never forwarded: + * the Console opens this URL from the TENANT origin, where a relative path + * would resolve against the tenant runtime and recreate exactly the + * guessed-path 404 this key exists to remove. + * + * Host option only — no env var. The value belongs to the distribution + * whose control plane serves the page; the cloud subclass fills it. + */ + upgradeUrl?: string; /** * CEILING for the `features.installLocal` flag — no longer its source * (#8388). @@ -613,6 +683,10 @@ export class RuntimeConfigPlugin implements Plugin { * invisible from the SPA end. */ private readonly refusedStage: string | undefined; + /** The declared upgrade entry, or `undefined` for "send no key" (unset or refused). */ + private readonly upgradeUrl: string | undefined; + /** The refused spelling, kept so `start()` can name it once — same reason as `refusedStage`. */ + private readonly refusedUpgradeUrl: string | undefined; private readonly logoUrl: string | undefined; private readonly faviconUrl: string | undefined; private readonly brandColor: string | undefined; @@ -655,6 +729,19 @@ export class RuntimeConfigPlugin implements Plugin { const requestedStage = config.stage ?? (envStage || undefined); this.stage = asPlatformStage(requestedStage); this.refusedStage = this.stage === undefined ? requestedStage : undefined; + // Upgrade / billing entry (#14514). Empty reads as unset; anything + // actually said that is not an absolute http(s) URL is refused and + // named at mount, never forwarded. Verbatim on acceptance — the + // original string, not the parser's re-serialisation. + const requestedUpgradeUrl = typeof config.upgradeUrl === 'string' && config.upgradeUrl.trim() !== '' + ? config.upgradeUrl + : undefined; + this.upgradeUrl = requestedUpgradeUrl !== undefined && isAbsoluteHttpUrl(requestedUpgradeUrl) + ? requestedUpgradeUrl + : undefined; + this.refusedUpgradeUrl = requestedUpgradeUrl !== undefined && this.upgradeUrl === undefined + ? requestedUpgradeUrl + : 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(); @@ -743,6 +830,20 @@ export class RuntimeConfigPlugin implements Plugin { ); } + // A host that declared an upgrade entry the Console could not + // safely open (#14514) — a relative path, or a non-http scheme. + // Same shape as the stage refusal above: they meant to configure + // something, it was refused rather than coerced, and the + // consequence (no upgrade link rendered) must not be silent. + if (this.refusedUpgradeUrl !== undefined) { + ctx.logger?.warn?.( + `[RuntimeConfigPlugin] ignoring upgradeUrl ${JSON.stringify(this.refusedUpgradeUrl)} ` + + `(the \`upgradeUrl\` option): it must be an absolute http(s) URL — the Console opens it ` + + `from the tenant origin, so a relative path would resolve against the wrong host. ` + + `No upgradeUrl key is served and the Console renders no upgrade link.`, + ); + } + // Telemetry knobs the operator got wrong (#12681). Same shape and // same reason as the stage refusal above: they meant to CONFIGURE // something, the value was not understood, and it was refused @@ -858,6 +959,13 @@ export class RuntimeConfigPlugin implements Plugin { } return c.json({ cloudUrl: this.cloudUrl, + // Declared by the host, never derived (#14514). Spread, + // not `upgradeUrl: this.upgradeUrl`, for the same reason + // as `branding.stage` below: the contract is asserted on + // KEY PRESENCE and the Console reads "no key" as "no + // link", so a present-and-undefined property must never + // reach a non-JSON consumer or a test. + ...(this.upgradeUrl !== undefined ? { upgradeUrl: this.upgradeUrl } : {}), singleEnvironment: resolvedSingleEnv, defaultOrgId, defaultEnvironmentId, diff --git a/packages/cloud-connection/src/runtime-config-upgrade-url.test.ts b/packages/cloud-connection/src/runtime-config-upgrade-url.test.ts new file mode 100644 index 0000000000..17f0334bca --- /dev/null +++ b/packages/cloud-connection/src/runtime-config-upgrade-url.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `upgradeUrl` — the control plane's upgrade / billing entry, published on + * `/api/v1/runtime/config` instead of derived by the Console (#14514, the + * framework half of cloud#1850). + * + * The contract has exactly two directions, and both are pinned here: + * + * declared → served VERBATIM, at the top level beside `cloudUrl`; + * undeclared → the key is NOT THERE. + * + * The absent direction is asserted on KEY PRESENCE, not `toBeUndefined()` — + * `{ upgradeUrl: undefined }` satisfies the latter while being a present + * property that survives structuredClone and shows up in `Object.keys`. The + * Console reads "no key" as "no link", so the key must be absent, not empty. + * + * A third group pins the one thing the producer enforces: the value must be an + * ABSOLUTE http(s) URL. A relative path is the defect this key exists to remove + * (opened from the tenant origin it resolves against the wrong host), so it is + * refused and named at mount, never forwarded. + */ + +import { describe, it, expect } 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-stage.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 hasUpgradeUrlKey(body: any): boolean { + return Object.prototype.hasOwnProperty.call(body, 'upgradeUrl'); +} + +/** The shape the cloud distribution will declare — console mount, app slug and page route included. */ +const PRICING = 'https://cloud.example.com/_console/apps/cloud_control/page/pricing'; + +describe('RuntimeConfigPlugin — upgradeUrl (#14514)', () => { + describe('direction 1 — declared, it is served verbatim', () => { + it('passes the declared absolute URL through unchanged, at the top level beside cloudUrl', async () => { + const { body, warnings } = await serve({ upgradeUrl: PRICING }); + expect(hasUpgradeUrlKey(body)).toBe(true); + expect(body.upgradeUrl).toBe(PRICING); + expect(Object.prototype.hasOwnProperty.call(body, 'cloudUrl')).toBe(true); + expect(warnings).toEqual([]); + }); + + it('is verbatim — no trailing-slash trimming, no case folding, no re-serialisation', async () => { + const spelled = 'https://Cloud.Example.com:8443/_console/apps/cloud_control/page/pricing/?from=quota#plans'; + const { body } = await serve({ upgradeUrl: spelled }); + expect(body.upgradeUrl).toBe(spelled); + }); + + it('accepts http:// too — a local rig control plane is not https', async () => { + const local = 'http://localhost:3000/_console/apps/cloud_control/page/pricing'; + const { body, warnings } = await serve({ upgradeUrl: local }); + expect(body.upgradeUrl).toBe(local); + expect(warnings).toEqual([]); + }); + + it('survives a JSON round trip with the same value', async () => { + const { body } = await serve({ upgradeUrl: PRICING }); + expect(JSON.parse(JSON.stringify(body)).upgradeUrl).toBe(PRICING); + }); + + it('lives beside cloudUrl — not inside features, not inside branding', async () => { + const { body } = await serve({ upgradeUrl: PRICING }); + expect(Object.keys(body.features)).not.toContain('upgradeUrl'); + expect(Object.keys(body.branding)).not.toContain('upgradeUrl'); + }); + + it('is declared independently of cloudUrl — the two keys do not derive from each other', async () => { + const { body } = await serve({ controlPlaneUrl: 'https://cloud.example.com', upgradeUrl: PRICING }); + expect(body.cloudUrl).toBe('https://cloud.example.com'); + expect(body.upgradeUrl).toBe(PRICING); + }); + }); + + describe('direction 2 — undeclared, the key is NOT THERE', () => { + it('omits the key entirely when nothing declared it', async () => { + const { body, warnings } = await serve(); + // The load-bearing assertion: absent, not present-and-undefined. + expect(hasUpgradeUrlKey(body)).toBe(false); + expect(Object.keys(body)).not.toContain('upgradeUrl'); + // ...and no invented default in its place. The Console owns the + // "no key means no link" reading; a guessed URL here would be the + // original defect pointing the other way. + expect(body.upgradeUrl).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, 'upgradeUrl')).toBe(false); + }); + + it.each(['', ' '])('an empty declaration %j reads as unset — absent, and silent (not a typo)', async (value) => { + const { body, warnings } = await serve({ upgradeUrl: value }); + expect(hasUpgradeUrlKey(body)).toBe(false); + expect(warnings).toEqual([]); + }); + + it('a resolved cloudUrl does not conjure an upgradeUrl — nothing is derived from the origin', async () => { + const { body } = await serve({ controlPlaneUrl: 'https://cloud.example.com' }); + expect(body.cloudUrl).toBe('https://cloud.example.com'); + expect(hasUpgradeUrlKey(body)).toBe(false); + }); + + it('leaves the sibling keys exactly as they were', async () => { + const { body } = await serve(); + expect(body.cloudUrl).toBe(''); + expect(body.singleEnvironment).toBe(true); + expect(body.features.aiStudio).toBe(true); + expect(body.telemetry).toEqual({}); + }); + }); + + describe('the value must be ABSOLUTE — a relative path is refused, loudly', () => { + it.each([ + '/settings/billing', + '/_console/apps/cloud_control/page/pricing', + 'apps/cloud_control/page/pricing', + 'cloud.example.com/pricing', + ])('refuses %j rather than forwarding a path the tenant origin would resolve wrongly', async (value) => { + const { body, warnings } = await serve({ upgradeUrl: value }); + expect(hasUpgradeUrlKey(body)).toBe(false); + expect(warnings.some((w) => w.includes(JSON.stringify(value)))).toBe(true); + }); + + it.each([ + 'javascript:alert(1)', + 'mailto:billing@example.com', + 'ftp://cloud.example.com/pricing', + ])('refuses a non-http scheme %j — this string is rendered as a link in every browser', async (value) => { + const { body, warnings } = await serve({ upgradeUrl: value }); + expect(hasUpgradeUrlKey(body)).toBe(false); + expect(warnings.some((w) => w.includes(JSON.stringify(value)))).toBe(true); + }); + + it('names the option, the refused value and the requirement, so the host can fix it', async () => { + const { warnings } = await serve({ upgradeUrl: '/settings/billing' }); + const warning = warnings.find((w) => w.includes('upgradeUrl')); + expect(warning).toBeDefined(); + expect(warning).toContain('"/settings/billing"'); + expect(warning).toContain('absolute'); + }); + + it('a refused value does not disturb the rest of the payload', async () => { + const { body } = await serve({ upgradeUrl: '/settings/billing' }); + expect(body.cloudUrl).toBe(''); + expect(body.features.aiStudio).toBe(true); + expect(body.telemetry).toEqual({}); + }); + + it('an accepted value emits no warning at all', async () => { + const { warnings } = await serve({ upgradeUrl: PRICING }); + expect(warnings.some((w) => w.includes('upgradeUrl'))).toBe(false); + }); + }); +});