Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/11726-marketplace-hint-same-origin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/i18n': patch
---

The marketplace load-error hint no longer tells a runtime that proxies a control plane that it serves the catalog itself (objectui#11726).

When the runtime config reports `cloudUrl: ''` and a catalog load fails for a reachability reason, Browse Marketplace showed "This runtime serves the marketplace catalog itself. Check that the runtime is online." `''` says only that requests stay on this origin, and the CLI's cloud-connected `os serve` reports it while its marketplace proxy forwards to a control plane, so on that runtime the sentence was false. `marketplace.load.failedHintSameOrigin` now reads, in `en`, "The marketplace catalog is reached through this runtime. Check that the runtime is online and can reach the catalog.", and the nine other packs say the same. It names no host, because no failure the proxy returns carries the upstream host in a field or a header.

A runtime that reports its control plane keeps the hint that names it, `marketplace.load.failedHintConfigured`, unchanged. No key is added or removed.
11 changes: 9 additions & 2 deletions packages/app-shell/src/console/marketplace/MarketplacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ export function MarketplacePage() {
// runtime mounts no marketplace browse surface at all — there is nothing
// to fetch, so nothing here fetches (objectui#5504).
const marketplaceEnabled = isMarketplaceEnabled();
// Empty string means "this runtime IS the catalog host" (same origin),
// not "unknown" — see `AppShellRuntimeConfig.cloudUrl`.
// Empty string means "requests stay on this origin", not "unknown", and not
// "this runtime IS the catalog host" either: a runtime that proxies a
// control plane answers `''` too (objectui#11726) — see
// `AppShellRuntimeConfig.cloudUrl`.
const cloudBase = getCloudBase();
const [items, setItems] = useState<MarketplacePackageSummary[]>([]);
// Seeded from the flag rather than settled by the effect: a runtime with
Expand Down Expand Up @@ -352,6 +354,11 @@ export function MarketplacePage() {
<div className="text-muted-foreground mt-1" data-testid="marketplace-load-cause">{error.message}</div>
{/* The hint names the control plane the SERVER said it uses, so it
can never claim a default the operator overrode (objectui#5504).
Under `''` it names none and says only that the catalog is
reached through this runtime, which is true whether the runtime
serves the catalog or proxies a plane: no failure the proxy
returns carries the upstream host in a field or a header, and
the server's prose is never parsed for one (objectui#11726).
Plain text, not `dangerouslySetInnerHTML`: `cloudBase` is
server-supplied data and interpolating it into innerHTML would
be an injection sink for no gain. Shown only when being online
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
* offline arm mounts runtime-config with `controlPlaneUrl: ''`, and the
* browse mount is absent, so the derived flag is false)
* - `OS_CLOUD_URL` unset -> `{ cloudUrl: 'https://cloud.objectos.ai', marketplace: true }`
* from a host whose `RuntimeConfigPlugin` resolves its URL, but
* `{ cloudUrl: '', marketplace: true }` from the CLI's cloud-connected
* arm, whose runtime-config is mounted with `controlPlaneUrl: ''` while
* its proxy forwards to that default plane (objectui#11726)
* - self-hosted plane -> `{ cloudUrl: '<their URL>', marketplace: true }`
*
* ## The inversion this file exists to prevent
Expand Down Expand Up @@ -193,10 +197,13 @@ describe('a runtime whose control plane is merely DOWN — the inversion guard',
);
});

it('says so plainly when the runtime hosts the catalog itself', async () => {
// `cloudUrl: ''` with the browse surface mounted is the control plane
// serving `/api/v1/marketplace/*` natively — same origin, no upstream to
// name, and nothing for the operator to point anywhere.
it('names no plane when the runtime names none (`cloudUrl: ""`)', async () => {
// `cloudUrl: ''` with the browse surface mounted says only that requests
// stay on this origin. That is the control plane serving
// `/api/v1/marketplace/*` natively, and it is equally the CLI's
// cloud-connected arm proxying a plane it does not name (objectui#11726),
// so the same-origin hint claims neither. Its wording is pinned with the
// real packs in `MarketplacePage.sameOriginHint-11726.test.tsx`.
await bootOn(serverConfig('', true));
listMarketplacePackages.mockImplementation(CATALOG_404);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
* Browse Marketplace showed "Failed to load marketplace · Forbidden · This
* runtime serves the marketplace catalog itself. Check that the runtime is
* online.", then "No apps have been approved for the marketplace yet." below
* it. The server had answered `403` with the `text/plain` body "Host not in
* it. (That hint sentence was itself wrong on this runtime, which proxies a
* control plane; objectui#11726 reworded it, and
* `MarketplacePage.sameOriginHint-11726.test.tsx` pins the new wording.)
* The server had answered `403` with the `text/plain` body "Host not in
* allowlist: cloud.objectos.ai. …", which is what an egress proxy in front of
* the control plane sends; the runtime's marketplace proxy forwards it
* verbatim. Three faults, one per line:
Expand Down Expand Up @@ -119,7 +122,7 @@ afterEach(() => {

describe('a failure the server answered shows the server its cause (objectui#11688)', () => {
it.each([
['a runtime reporting no upstream (`cloudUrl: ""`)', ''],
['a runtime that names no upstream (`cloudUrl: ""`)', ''],
['a runtime reporting its control plane', 'https://cloud.acme.internal'],
])('a 403 whose plain-text body names the allowlist — on %s', async (_label, cloudUrl) => {
await bootOn(cloudUrl);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Under `cloudUrl: ''` the marketplace load-error hint says only that the
* catalog is reached through this runtime. It never says the runtime serves the
* catalog itself, and it names no host (objectui#11726).
*
* ## The defect
*
* The CLI's cloud-connected `os serve` mounts its runtime config with
* `controlPlaneUrl: ''` while its marketplace proxy forwards to the default
* control plane. A catalog load that failed there for a reachability reason (a
* 502 / 503 / 504 from the proxy, or no answer at all) drew "This runtime
* serves the marketplace catalog itself.", which is false on that runtime.
* `''` is the producer's "stay on this origin" spelling: it says where requests
* go, not who serves them.
*
* ## No host is named, and why
*
* The card allowed naming the upstream host where a failure carries it in a
* structured place. None does. The proxy's own failure is a 502 with the
* `MARKETPLACE_PROXY_FAILED` envelope, whose message is `fetch failed` and
* which has no host field. An upstream 5xx is forwarded with the upstream's own
* body, and the only header the proxy adds is `X-Cache`. Reading a host out of
* the message would treat server prose as data, so the hint names none. The
* stubbed transport below answers in those shapes.
*
* ## Why the REAL packs and the REAL `call()`
*
* The subject is the sentence a reader gets, so the hint is read through the
* real `I18nProvider` in `en` and `zh`. The sibling suites' `«key»` echo proves
* which key is chosen, not what it says. The catalog request goes through the
* genuine `listMarketplacePackages` over a stubbed `fetch` that answers real
* `Response` objects, as `MarketplacePage.loadFailureCause-11688.test.tsx`
* does, so whether the hint shows at all is decided by the read a browser makes.
*
* The same-origin assertions are about the CLAIM, not the whole sentence: the
* hint routes the catalog through this runtime, and says nothing about the
* runtime serving it and nothing about a host. The configured plane's hint is
* pinned verbatim, because "unchanged" is the claim there.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

vi.mock('../marketplaceApi', async (importOriginal) => ({
// `listMarketplacePackages` stays REAL: whether the hint shows is its read.
...(await importOriginal<Record<string, unknown>>()),
listLocalInstalls: async () => [],
listOrgPackages: async () => ({ connected: false, items: [] }),
listInstalledPackages: async () => ({ connected: false, items: [] }),
}));

vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} }));

import { I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
import { MarketplacePage } from '../MarketplacePage';

const json = (body: unknown, status = 200, statusText = 'OK') =>
new Response(JSON.stringify(body), {
status,
statusText,
headers: { 'content-type': 'application/json; charset=utf-8' },
});

/** The catalog request's answer, set per case. */
let catalog: () => Promise<Response>;

/** Boot the SPA against a runtime whose config reports `cloudUrl`, marketplace mounted. */
async function bootOn(cloudUrl: string) {
resetRuntimeConfigForTesting();
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
String(url).includes('/api/v1/marketplace/packages')
? catalog()
: json({
cloudUrl,
singleEnvironment: true,
features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
}),
),
);
await initRuntimeConfig();
}

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
resetRuntimeConfigForTesting();
});

const LANGS = {
en: { defaultLanguage: 'en', detectBrowserLanguage: false },
zh: { defaultLanguage: 'zh', detectBrowserLanguage: false },
} as const;
type Lang = keyof typeof LANGS;

function renderIn(lang: Lang) {
return render(
<I18nProvider config={LANGS[lang]} persistLanguage={false}>
<MarketplacePage />
</I18nProvider>,
);
}

const packValue = (lang: Lang, key: string): unknown =>
key
.split('.')
.reduce<unknown>((n, k) => (n as Record<string, unknown> | undefined)?.[k], (builtInLocales as Record<string, unknown>)[lang]);

/**
* What the same-origin hint must and must not say, per language. `routes` is
* the one thing `''` means; `servesItself` is the claim objectui#11726 removed.
*/
const CLAIMS: Record<Lang, { routes: RegExp; servesItself: RegExp }> = {
en: { routes: /through this runtime/i, servesItself: /\bserves?\b|\bitself\b/i },
zh: { routes: /通过本运行时/, servesItself: /自己提供|自身提供|本身提供/ },
};

/** Any host or URL, or an unfilled `{{url}}` hole. */
const NAMES_A_HOST = /https?:\/\/|objectos\.ai|acme|\{\{\s*url\s*\}\}/i;

/** The failures whose cause IS reachability, in the shapes the proxy returns. */
const REACHABILITY_FAILURES: ReadonlyArray<readonly [string, () => Promise<Response>]> = [
[
"the proxy could not reach its upstream (502, the proxy's own envelope)",
async () => json({ success: false, error: { code: 'MARKETPLACE_PROXY_FAILED', message: 'fetch failed' } }, 502, 'Bad Gateway'),
],
[
'the upstream answered 503, forwarded verbatim',
async () => json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'Service Unavailable' } }, 503, 'Service Unavailable'),
],
[
'the upstream answered 504 with an empty body',
async () => new Response('', { status: 504, statusText: 'Gateway Timeout', headers: { 'content-type': 'text/plain;charset=UTF-8' } }),
],
[
'no server answered at all',
async () => {
throw new TypeError('Failed to fetch');
},
],
];

describe.each(Object.keys(LANGS) as Lang[])(
'%s: under `cloudUrl: ""` the hint routes the catalog through this runtime and claims nothing more (objectui#11726)',
(lang) => {
it.each(REACHABILITY_FAILURES)('%s', async (_label, failure) => {
await bootOn('');
catalog = failure;

renderIn(lang);

const text = (await screen.findByTestId('marketplace-load-hint')).textContent ?? '';
// The sentence drawn is this pack's same-origin hint, not a fallback.
expect(text).toBe(packValue(lang, 'marketplace.load.failedHintSameOrigin'));
expect(text).toMatch(CLAIMS[lang].routes);
expect(text).not.toMatch(CLAIMS[lang].servesItself);
expect(text).not.toMatch(NAMES_A_HOST);
});

it('a host named in the server prose stays in the cause line and never reaches the hint', async () => {
await bootOn('');
catalog = async () =>
json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'cloud.objectos.ai is not serving' } }, 503, 'Service Unavailable');

renderIn(lang);

expect(await screen.findByTestId('marketplace-load-cause')).toHaveTextContent('cloud.objectos.ai is not serving');
const text = screen.getByTestId('marketplace-load-hint').textContent ?? '';
expect(text).toBe(packValue(lang, 'marketplace.load.failedHintSameOrigin'));
expect(text).not.toMatch(NAMES_A_HOST);
});
},
);

describe('a configured plane keeps its hint, naming that plane (objectui#11726)', () => {
beforeEach(() => {
catalog = async () =>
json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'Service Unavailable' } }, 503, 'Service Unavailable');
});

it('en', async () => {
await bootOn('https://cloud.acme.internal');
renderIn('en');
expect(await screen.findByTestId('marketplace-load-hint')).toHaveTextContent(
'This runtime reaches the marketplace through the control plane at https://cloud.acme.internal. Check that it is online and reachable from here.',
);
});

it('zh', async () => {
await bootOn('https://cloud.acme.internal');
renderIn('zh');
expect(await screen.findByTestId('marketplace-load-hint')).toHaveTextContent(
'本运行时通过 https://cloud.acme.internal 上的控制面访问应用市场。请检查该地址是否在线、能否从本运行时访问。',
);
});
});
15 changes: 10 additions & 5 deletions packages/app-shell/src/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,12 @@ const DEFAULT_REPLAYS_ON_ERROR_SAMPLE_RATE = 0;
export interface AppShellRuntimeConfig {
/**
* Upstream cloud base URL — the SPA dispatches install + env listing
* directly against this origin. Empty string ⇒ same-origin (i.e. the
* runtime we're attached to *is* the cloud).
* directly against this origin. Empty string ⇒ requests stay on this
* origin, and that is all it says: the runtime may serve the catalog
* itself or proxy a control plane it does not name. The CLI's cloud-connected
* `os serve` answers `''` while its marketplace proxy forwards to the
* default control plane (objectui#11726), so read `''` neither as "this
* runtime is the cloud" nor as "there is no upstream".
*/
cloudUrl: string;
/** Single-environment runtime (CLI `os serve`, etc.). */
Expand Down Expand Up @@ -496,9 +500,10 @@ export function isRuntimeConfigInitialised(): boolean {

/**
* Resolve the upstream cloud base URL the SPA should target. When the
* runtime says it *is* the cloud (`cloudUrl: ''`) the SPA stays on the
* current origin. Otherwise this returns the server-supplied URL with no
* trailing slash.
* runtime answers `cloudUrl: ''` the SPA stays on the current origin; that
* says where requests go, not who serves them (see
* `AppShellRuntimeConfig.cloudUrl`). Otherwise this returns the
* server-supplied URL with no trailing slash.
*/
export function getCloudBase(): string {
return current.cloudUrl ?? '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
*
* Its replacements each state something the SERVER actually said:
* `failedHintConfigured` names the control plane from `runtime-config`'s
* `cloudUrl`, and `failedHintSameOrigin` covers the runtime that hosts the
* catalog itself. Neither can claim a default the operator overrode, because
* neither mentions a default.
* `cloudUrl`, and `failedHintSameOrigin` covers `cloudUrl: ''`, which says
* only that requests stay on this origin: the runtime may serve the catalog
* or proxy a control plane it does not name, so that hint claims neither
* (objectui#11726). Neither can claim a default the operator overrode,
* because neither mentions a default.
*
* ## Why this pin is NEGATIVE, and why it is needed at all
*
Expand Down
2 changes: 1 addition & 1 deletion packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3661,7 +3661,7 @@ const ar = {
load: {
failed: "تعذر تحميل السوق",
failedHintConfigured: "يصل وقت التشغيل هذا إلى السوق عبر مستوى التحكم على {{url}}. تحقق من أنه متصل بالإنترنت ويمكن الوصول إليه من هنا.",
failedHintSameOrigin: "يقدّم وقت التشغيل هذا كتالوج السوق بنفسه. تأكد من أن وقت التشغيل متصل بالإنترنت.",
failedHintSameOrigin: "يتم الوصول إلى كتالوج السوق عبر وقت التشغيل هذا. تأكد من أن وقت التشغيل متصل بالإنترنت ويمكنه الوصول إلى الكتالوج.",
packageFailed: "تعذر تحميل الحزمة",
notFound: "غير موجود.",
},
Expand Down
2 changes: 1 addition & 1 deletion packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3398,7 +3398,7 @@ const de = {
load: {
failed: "Marktplatz konnte nicht geladen werden",
failedHintConfigured: "Diese Laufzeitumgebung erreicht den Marktplatz über die Control Plane unter {{url}}. Prüfen Sie, ob diese online und von hier aus erreichbar ist.",
failedHintSameOrigin: "Diese Laufzeitumgebung stellt den Marktplatz-Katalog selbst bereit. Prüfen Sie, ob die Laufzeitumgebung online ist.",
failedHintSameOrigin: "Der Marktplatz-Katalog wird über diese Laufzeitumgebung erreicht. Prüfen Sie, ob die Laufzeitumgebung online ist und den Katalog erreichen kann.",
packageFailed: "Paket konnte nicht geladen werden",
notFound: "Nicht gefunden.",
},
Expand Down
2 changes: 1 addition & 1 deletion packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4000,7 +4000,7 @@ const en = {
load: {
failed: 'Failed to load marketplace',
failedHintConfigured: 'This runtime reaches the marketplace through the control plane at {{url}}. Check that it is online and reachable from here.',
failedHintSameOrigin: 'This runtime serves the marketplace catalog itself. Check that the runtime is online.',
failedHintSameOrigin: 'The marketplace catalog is reached through this runtime. Check that the runtime is online and can reach the catalog.',
packageFailed: 'Failed to load package',
notFound: 'Not found.',
},
Expand Down
2 changes: 1 addition & 1 deletion packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3462,7 +3462,7 @@ const es = {
load: {
failed: "No se pudo cargar el marketplace",
failedHintConfigured: "Este runtime accede al marketplace a través del plano de control en {{url}}. Compruebe que esté en línea y sea accesible desde aquí.",
failedHintSameOrigin: "Este runtime sirve el catálogo del marketplace por sí mismo. Compruebe que el runtime esté en línea.",
failedHintSameOrigin: "Al catálogo del marketplace se accede a través de este runtime. Compruebe que el runtime esté en línea y pueda acceder al catálogo.",
packageFailed: "No se pudo cargar el paquete",
notFound: "No encontrado.",
},
Expand Down
Loading
Loading