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
13 changes: 13 additions & 0 deletions .changeset/11688-marketplace-load-error-cause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
---

Browse Marketplace now says why the catalog failed to load, and no longer draws "No apps have been approved for the marketplace yet." under the error (objectui#11688).

A refusal the server answered in plain text, such as an egress proxy's `403` "Host not in allowlist: cloud.objectos.ai", was shown as the bare status text ("Forbidden"), and every load failure carried the "check that it is online" hint. Now:

- the error shows the server's own text: a JSON error's message, as before, or the body of a `text/plain` refusal. Any other body that is not JSON, such as an HTML error page, still shows the status text;
- the "check that it is online" hint is shown only when no server answered (a network failure, a CORS refusal) or the server answered `502`, `503` or `504`. A refusal such as a `401` or `403` shows its cause without it;
- the empty state is drawn only when the catalog loaded empty, and the "Your organization" packages and the installed count still appear when the public catalog fails to load.

The package detail page reads its load through the same request helper, so a plain-text refusal there now shows its text too.
79 changes: 65 additions & 14 deletions packages/app-shell/src/console/marketplace/MarketplacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ function useRelativeFormatter() {
};
}

/**
* A failed catalog load as the page tells it (objectui#11688): the server's
* own words, and whether "check that it is online" is the real cause.
*
* Both hints end in that question, so they are shown only when it is the
* cause: no server answered at all (`call()` stamps `status` on every failure
* a server answered, so a failure without one never reached a server), or the
* server that answered said it could not reach the one behind it or is not
* serving. Any other answer is a refusal whose cause is the server's text, and
* an "is it online" line under a 403 sends the reader after the wrong fault.
*/
interface LoadFailure {
message: string;
showOnlineHint: boolean;
}

/**
* The answered statuses whose cause IS reachability: 502 and 504 are a gateway
* that got no (timely) answer upstream, the runtime proxy's own
* `MARKETPLACE_PROXY_FAILED` among them, and 503 is a service that is not up.
*/
const UNREACHABLE_STATUSES: ReadonlySet<number> = new Set([502, 503, 504]);

function toLoadFailure(e: unknown): LoadFailure {
const err = e as { message?: unknown; status?: unknown } | null | undefined;
const status = typeof err?.status === 'number' ? err.status : undefined;
return {
message: typeof err?.message === 'string' ? err.message : String(e),
showOnlineHint: status === undefined || UNREACHABLE_STATUSES.has(status),
};
}

export function MarketplacePage() {
const navigate = useNavigate();
const { appName } = useParams();
Expand All @@ -94,7 +126,7 @@ export function MarketplacePage() {
// no catalog is not "loading", it is done. Writing that from inside the
// effect would be a second `react-hooks/set-state-in-effect` site.
const [loading, setLoading] = useState(marketplaceEnabled);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<LoadFailure | null>(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<string>('');
const [installed, setInstalled] = useState<LocalInstallEntry[]>([]);
Expand All @@ -115,21 +147,33 @@ export function MarketplacePage() {
setLoading(true);
setError(null);
try {
const [resp, installs, org, cloudInstalled] = await Promise.all([
listMarketplacePackages({ limit: 100 }),
const [catalog, installs, org, cloudInstalled] = await Promise.all([
// Settled here rather than left to reject the batch. The three side
// loads each answer "nothing" on their own failure, so the catalog is
// the only one that can fail, and its failure must not discard what
// they loaded: the org section and the installed count (objectui#11688).
listMarketplacePackages({ limit: 100 }).then(
(resp) => ({ resp, failure: null }),
(e: unknown) => ({ resp: null, failure: toLoadFailure(e) }),
),
listLocalInstalls(),
listOrgPackages(),
listInstalledPackages(),
]);
setItems(resp.items ?? []);
setInstalled(installs);
setOrgItems(org.items ?? []);
const ids = new Set<string>();
for (const e of installs) ids.add(e.manifestId);
for (const e of cloudInstalled.items) ids.add(e.manifestId);
setInstalledIds(ids);
} catch (e: any) {
setError(e?.message ?? String(e));
if (catalog.failure !== null) {
setError(catalog.failure);
setItems([]);
} else {
setItems(catalog.resp.items ?? []);
}
} catch (e: unknown) {
setError(toLoadFailure(e));
setItems([]);
} finally {
setLoading(false);
Expand Down Expand Up @@ -303,17 +347,22 @@ export function MarketplacePage() {
<AlertCircle className="h-4 w-4 mt-0.5 text-destructive" aria-hidden="true" />
<div>
<div className="font-medium text-destructive">{t('marketplace.load.failed')}</div>
<div className="text-muted-foreground mt-1">{error}</div>
{/* The server's own text, as a text child: it is server-supplied
data, so it never reaches `dangerouslySetInnerHTML`. */}
<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).
Plain text, not `dangerouslySetInnerHTML`: `cloudBase` is
server-supplied data and interpolating it into innerHTML would
be an injection sink for no gain. */}
<div className="text-xs text-muted-foreground mt-2" data-testid="marketplace-load-hint">
{cloudBase
? t('marketplace.load.failedHintConfigured', { url: cloudBase })
: t('marketplace.load.failedHintSameOrigin')}
</div>
be an injection sink for no gain. Shown only when being online
is the cause (`toLoadFailure`, objectui#11688). */}
{error.showOnlineHint && (
<div className="text-xs text-muted-foreground mt-2" data-testid="marketplace-load-hint">
{cloudBase
? t('marketplace.load.failedHintConfigured', { url: cloudBase })
: t('marketplace.load.failedHintSameOrigin')}
</div>
)}
</div>
</div>
)}
Expand Down Expand Up @@ -399,7 +448,9 @@ export function MarketplacePage() {
</Card>
))}
</div>
) : filtered.length === 0 ? (
) : error ? null : filtered.length === 0 ? (
// Not drawn under a load error: "no apps have been approved yet" is a
// claim about a catalog this page failed to read (objectui#11688).
<div className="text-center py-12 text-sm text-muted-foreground">
{items.length === 0 ? t('marketplace.noApprovedYet') : t('marketplace.noMatchFilters')}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A marketplace load failure names its real cause, and the empty state is not
* drawn under it (objectui#11688).
*
* ## The defect
*
* 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
* 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:
*
* - `call()` parsed the body straight to JSON, threw the text away, and
* `readApiError` fell back to the status text, "Forbidden".
* - the "is it online" hint was drawn under EVERY failure, including one
* the server answered with its own reason.
* - the empty state did not know a load had failed, so it claimed an empty
* catalog the page never read.
*
* ## Why these cases drive the REAL `call()`
*
* The cause is lost inside `call()`, on how it reads a `Response`. A mocked
* `listMarketplacePackages` rejecting with a hand-made `Error` would assert
* against a stand-in for that read and prove nothing about it. So only the three
* side loads are mocked; the catalog goes through the genuine module over a
* stubbed `fetch` that answers with real `Response` objects.
*
* ## Why `t` returns «key»
*
* Same reason as `MarketplacePage.disabledState.test.tsx`: an echoed KEY proves
* which string was chosen, and the `url` argument is appended so the configured
* hint is seen to name the control plane the server reported.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } 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('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
typeof opts?.url === 'string' ? `«${key}:${opts.url}»` : `«${key}»`,
language: 'en',
}),
}));

const listOrgPackages = vi.fn();

vi.mock('../marketplaceApi', async (importOriginal) => ({
// `listMarketplacePackages` stays REAL: the defect lives in its `call()`.
...(await importOriginal<Record<string, unknown>>()),
listLocalInstalls: async () => [],
listOrgPackages: (...args: unknown[]) => listOrgPackages(...args),
listInstalledPackages: async () => ({ connected: false, items: [] }),
}));

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

import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
import { MarketplacePage } from '../MarketplacePage';

/** What the sweep's runtime answered: an egress proxy's refusal, forwarded verbatim. */
const ALLOWLIST_TEXT =
'Host not in allowlist: cloud.objectos.ai. Add this host to your network egress settings to allow access.';

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();
}

beforeEach(() => {
listOrgPackages.mockReset();
listOrgPackages.mockResolvedValue({ connected: false, items: [] });
});

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

describe('a failure the server answered shows the server its cause (objectui#11688)', () => {
it.each([
['a runtime reporting 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);
catalog = async () =>
new Response(ALLOWLIST_TEXT, {
status: 403,
statusText: 'Forbidden',
headers: { 'content-type': 'text/plain; charset=utf-8' },
});

render(<MarketplacePage />);

await screen.findByText('«marketplace.load.failed»');
// The cause is the server's text, not the status text that stood in for it.
expect(screen.getByTestId('marketplace-load-cause')).toHaveTextContent(ALLOWLIST_TEXT);
// The server answered, so "check that it is online" is not the cause.
expect(screen.queryByTestId('marketplace-load-hint')).toBeNull();
// And the page never read a catalog, so it claims nothing about one.
expect(screen.queryByText('«marketplace.noApprovedYet»')).toBeNull();
});

it('a JSON envelope refusal still reads its message after the body is read as text', async () => {
await bootOn('');
catalog = async () =>
json({ success: false, error: { code: 'FORBIDDEN', message: 'This environment may not browse the catalog.' } }, 403, 'Forbidden');

render(<MarketplacePage />);

expect(await screen.findByTestId('marketplace-load-cause')).toHaveTextContent(
'This environment may not browse the catalog.',
);
expect(screen.queryByTestId('marketplace-load-hint')).toBeNull();
});

it('an HTML error page is not printed as the cause — only `text/plain` is read as prose', async () => {
await bootOn('');
catalog = async () =>
new Response('<html><body><h1>403 Forbidden</h1></body></html>', {
status: 403,
statusText: 'Forbidden',
headers: { 'content-type': 'text/html' },
});

render(<MarketplacePage />);

expect(await screen.findByTestId('marketplace-load-cause')).toHaveTextContent(/^Forbidden$/);
});
});

describe('a failure whose cause IS reachability keeps the online hint (objectui#11688)', () => {
it('a network failure — no server answered — shows the online hint', async () => {
await bootOn('');
catalog = async () => {
throw new TypeError('Failed to fetch');
};

render(<MarketplacePage />);

await screen.findByText('«marketplace.load.failed»');
expect(screen.getByTestId('marketplace-load-hint')).toHaveTextContent('«marketplace.load.failedHintSameOrigin»');
expect(screen.queryByText('«marketplace.noApprovedYet»')).toBeNull();
});

it('the runtime proxy reporting its upstream unreachable (502) shows its message and names the plane', async () => {
await bootOn('https://cloud.acme.internal');
catalog = async () =>
json({ success: false, error: { code: 'MARKETPLACE_PROXY_FAILED', message: 'fetch failed' } }, 502, 'Bad Gateway');

render(<MarketplacePage />);

expect(await screen.findByTestId('marketplace-load-cause')).toHaveTextContent('fetch failed');
expect(screen.getByTestId('marketplace-load-hint')).toHaveTextContent(
'«marketplace.load.failedHintConfigured:https://cloud.acme.internal»',
);
});
});

describe('what the page draws beside a load failure (objectui#11688)', () => {
it('control: a catalog that loaded empty draws the empty state, and no failure', async () => {
await bootOn('');
catalog = async () => json({ success: true, data: { items: [], total: 0, limit: 100, offset: 0 } });

render(<MarketplacePage />);

await screen.findByText('«marketplace.noApprovedYet»');
expect(screen.queryByText('«marketplace.load.failed»')).toBeNull();
});

it('a catalog failure does not discard the org packages that did load', async () => {
await bootOn('https://cloud.acme.internal');
listOrgPackages.mockResolvedValue({
connected: true,
items: [{ id: 'pkg_1', manifest_id: 'acme.crm', display_name: 'Acme CRM' }],
});
catalog = async () =>
new Response(ALLOWLIST_TEXT, {
status: 403,
statusText: 'Forbidden',
headers: { 'content-type': 'text/plain' },
});

render(<MarketplacePage />);

expect(await screen.findByTestId('org-card-acme.crm')).toBeInTheDocument();
expect(screen.getByTestId('marketplace-load-cause')).toHaveTextContent(ALLOWLIST_TEXT);
expect(screen.queryByText('«marketplace.noApprovedYet»')).toBeNull();
});
});
Loading
Loading