fix(marketplace): load default discovery through runtime (#637)

This commit is contained in:
Coderfu
2026-10-02 12:33:16 +08:00
committed by GitHub
parent fa78f46216
commit 52287c8369
10 changed files with 113 additions and 35 deletions
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest";
import { createCatalogStore } from "../catalog-store.ts";
import { setDefaultGuardedFetchDnsLookup } from "../core/guarded-fetch.ts";
import { setEgressTrustedHosts } from "../core/request.ts";
import { defaultMarketplaceDiscoveryUrl } from "./default-marketplace.ts";
import { MarketplaceService } from "./marketplace-service.ts";
const provider: ProviderDefinition = {
@@ -70,6 +71,42 @@ describe("MarketplaceService", () => {
},
);
it("loads the default discovery summary through the runtime fetcher", async () => {
setDefaultGuardedFetchDnsLookup(null);
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse({
version: 1,
id: "oomol",
name: "Default",
pricing: "metered",
validate: "/validate",
endpoint: "/actions",
actions: ["example.run"],
}),
);
const service = new MarketplaceService({
catalog: createCatalogStore([provider]),
store: new MemoryMarketplaceStore(),
secretCodec: reversibleCodec,
fetcher,
});
try {
await expect(service.getDefaultDiscovery()).resolves.toEqual({
version: 1,
name: "Default",
actions: ["example.run"],
});
expect(fetcher).toHaveBeenCalledOnce();
expect(String(fetcher.mock.calls[0]?.[0])).toBe(defaultMarketplaceDiscoveryUrl);
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
headers: { accept: "application/json" },
redirect: "manual",
});
} finally {
setDefaultGuardedFetchDnsLookup(null);
}
});
it.each([
{ failure: new TypeError("terminated"), status: 502, message: "Marketplace discovery could not be read." },
{ failure: new DOMException("Timed out", "TimeoutError"), status: 504, message: "Marketplace request timed out." },
+13 -2
View File
@@ -63,6 +63,12 @@ export interface MarketplaceSnapshot {
actionsByService: ReadonlyMap<string, ReadonlySet<string>>;
}
export interface MarketplaceDiscoverySummary {
version: 1;
name: string;
actions: string[];
}
export interface MarketplaceState {
configured: boolean;
enabled: boolean;
@@ -144,6 +150,11 @@ export class MarketplaceService {
return this.snapshot;
}
async getDefaultDiscovery(signal?: AbortSignal): Promise<MarketplaceDiscoverySummary> {
const discovery = await this.discover(defaultMarketplaceDiscoveryUrl, signal);
return { version: 1, name: discovery.name, actions: discovery.actions };
}
supportsAction(actionId: string): boolean {
return this.snapshot?.compatibleActions.has(actionId) ?? false;
}
@@ -288,7 +299,7 @@ export class MarketplaceService {
};
}
private async discover(discoveryUrl: string): Promise<MarketplaceDiscovery> {
private async discover(discoveryUrl: string, signal?: AbortSignal): Promise<MarketplaceDiscovery> {
try {
return await requestRemote(
{
@@ -298,7 +309,7 @@ export class MarketplaceService {
requireHttps: false,
allowTrustedHosts: true,
timeoutMs: 15_000,
init: { headers: { accept: "application/json" } },
init: { headers: { accept: "application/json" }, signal },
},
async (response, signal) => {
if (!response.ok)
+25
View File
@@ -165,6 +165,31 @@ describe("ConnectServer", () => {
});
});
it("serves default Marketplace discovery through the same-origin API", async () => {
const database = new SqliteRuntimeDatabase(":memory:");
requestDatabases.push(database);
const marketplace = new MarketplaceService({
catalog: createCatalogStore([apiKeyProvider]),
store: database.marketplaceStore,
secretCodec: new PlainTextSecretCodec(),
});
vi.spyOn(marketplace, "getDefaultDiscovery").mockResolvedValue({
version: 1,
name: "Default",
actions: ["example.echo"],
});
const app = createTestServer([apiKeyProvider], { marketplace }).createApp();
const response = await app.request("/api/marketplace/discovery");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
version: 1,
name: "Default",
actions: ["example.echo"],
});
});
it("rejects connections for providers unavailable in the current runtime", async () => {
const app = createTestServer([catalogOnlyProvider]).createApp();
+21 -9
View File
@@ -202,6 +202,7 @@ export class ConnectServer {
app.use("*", createLocalAuthMiddleware(auth));
if (this.options.marketplace) {
app.get("/api/marketplace", (context) => context.json(this.options.marketplace!.getState()));
app.get("/api/marketplace/discovery", (context) => this.getMarketplaceDiscovery(context));
app.put("/api/marketplace", (context) => this.configureMarketplace(context));
app.patch("/api/marketplace", (context) => this.configureMarketplace(context));
app.delete("/api/marketplace", (context) => this.deleteMarketplace(context));
@@ -477,19 +478,30 @@ export class ConnectServer {
try {
return context.json(await this.options.marketplace!.configure(input));
} catch (error) {
if (error instanceof MarketplaceError) {
const status = error.status;
return jsonError(
context,
status === 401 || status === 403 || status === 404 || status === 502 || status === 504 ? status : 400,
error.code,
error.message,
);
}
if (error instanceof MarketplaceError) return this.writeMarketplaceError(context, error);
throw error;
}
}
private async getMarketplaceDiscovery(context: Context): Promise<Response> {
try {
return context.json(await this.options.marketplace!.getDefaultDiscovery(context.req.raw.signal));
} catch (error) {
if (error instanceof MarketplaceError) return this.writeMarketplaceError(context, error);
throw error;
}
}
private writeMarketplaceError(context: Context, error: MarketplaceError): Response {
const status = error.status;
return jsonError(
context,
status === 401 || status === 403 || status === 404 || status === 502 || status === 504 ? status : 400,
error.code,
error.message,
);
}
private async deleteMarketplace(context: Context): Promise<Response> {
await this.options.marketplace!.remove();
return context.json(this.options.marketplace!.getState());
+2 -4
View File
@@ -13,7 +13,6 @@ import { Badge, EmptyState, ProviderIcon } from "./shared-ui";
interface DefaultMarketplaceCatalogProps {
providers: ProviderDefinition[];
discoveryUrl: string;
connected?: boolean;
embedded?: boolean;
}
@@ -28,7 +27,6 @@ const promotedServices = Object.keys(promotedModels);
export function DefaultMarketplaceCatalog({
providers,
discoveryUrl,
connected,
embedded,
}: DefaultMarketplaceCatalogProps): ReactNode {
@@ -42,7 +40,7 @@ export function DefaultMarketplaceCatalog({
let active = true;
setFailure(undefined);
const controller = new AbortController();
void loadDefaultMarketplaceCatalog(discoveryUrl, controller.signal).then(
void loadDefaultMarketplaceCatalog(controller.signal).then(
(value) => {
if (active) setCatalog(value);
},
@@ -56,7 +54,7 @@ export function DefaultMarketplaceCatalog({
active = false;
controller.abort();
};
}, [attempt, discoveryUrl]);
}, [attempt]);
const available = new Set(catalog?.actions);
const rows = providers
@@ -3,17 +3,18 @@ import { loadDefaultMarketplaceCatalog } from "./default-marketplace-discovery";
afterEach(() => vi.unstubAllGlobals());
it("reads public discovery directly without credentials", async () => {
it("reads public discovery through the same-origin runtime endpoint", async () => {
const fetcher = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ version: 1, name: "Default", actions: ["kling.generate"] })));
vi.stubGlobal("fetch", fetcher);
await expect(
loadDefaultMarketplaceCatalog("https://example.com/discovery", new AbortController().signal),
).resolves.toEqual({ name: "Default", actions: ["kling.generate"] });
await expect(loadDefaultMarketplaceCatalog(new AbortController().signal)).resolves.toEqual({
name: "Default",
actions: ["kling.generate"],
});
expect(fetcher).toHaveBeenCalledWith(
"https://example.com/discovery",
expect.objectContaining({ credentials: "omit", redirect: "error" }),
"/api/marketplace/discovery",
expect.objectContaining({ credentials: "same-origin", redirect: "error" }),
);
});
@@ -22,7 +23,5 @@ it.each([
new Response(JSON.stringify({ version: 1, name: "Default", actions: [42] })),
])("rejects failed or malformed catalogs", async (response) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
await expect(
loadDefaultMarketplaceCatalog("https://example.com/discovery", new AbortController().signal),
).rejects.toThrow();
await expect(loadDefaultMarketplaceCatalog(new AbortController().signal)).rejects.toThrow();
});
+4 -7
View File
@@ -3,13 +3,10 @@ export interface DefaultMarketplaceDiscovery {
actions: string[];
}
/** Reads the public browsing catalog without sending credentials or activating a connection. */
export async function loadDefaultMarketplaceCatalog(
url: string,
signal: AbortSignal,
): Promise<DefaultMarketplaceDiscovery> {
const response = await fetch(url, {
credentials: "omit",
/** Reads the public browsing catalog through the runtime so browser CORS policy cannot block it. */
export async function loadDefaultMarketplaceCatalog(signal: AbortSignal): Promise<DefaultMarketplaceDiscovery> {
const response = await fetch("/api/marketplace/discovery", {
credentials: "same-origin",
redirect: "error",
signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
headers: { accept: "application/json" },
+1 -1
View File
@@ -276,7 +276,7 @@ export function MarketplacePage(props: MarketplacePageProps): ReactNode {
{props.embedded && !marketplace?.configured ? null : !marketplace?.configured ? (
isDefaultMarketplace(marketplace?.discoveryUrl ?? defaultMarketplaceDiscoveryUrl) ? (
<DefaultMarketplaceCatalog providers={props.data.providers} discoveryUrl={defaultMarketplaceDiscoveryUrl} />
<DefaultMarketplaceCatalog providers={props.data.providers} />
) : null
) : (
<section className="marketplace-panel">
+1 -1
View File
@@ -24,7 +24,7 @@ export function OneKeyProviderOption({ marketplace, provider, connected }: OneKe
useEffect(() => {
if (!officialMarketplace || connected || (isOneKeyPromotionHidden() && !marketplace?.configured)) return;
const controller = new AbortController();
void loadDefaultMarketplaceCatalog(defaultMarketplaceDiscoveryUrl, controller.signal).then(
void loadDefaultMarketplaceCatalog(controller.signal).then(
(catalog) => setSupported(provider.actions.some((action) => catalog.actions.includes(action.id))),
() => setSupported(false),
);
+1 -2
View File
@@ -300,7 +300,7 @@ function ProviderBrowser(props: ProviderBrowserProps): ReactNode {
useEffect(() => {
if (!officialMarketplace || (oneKeyHidden && !props.data.marketplace?.configured && !showSupportedFeatures)) return;
const controller = new AbortController();
void loadDefaultMarketplaceCatalog(defaultMarketplaceDiscoveryUrl, controller.signal).then(
void loadDefaultMarketplaceCatalog(controller.signal).then(
(catalog) => {
const actions = new Set(catalog.actions);
setOneKeyServices(
@@ -502,7 +502,6 @@ function ProviderBrowser(props: ProviderBrowserProps): ReactNode {
<DefaultMarketplaceCatalog
embedded
providers={props.data.providers}
discoveryUrl={defaultMarketplaceDiscoveryUrl}
connected={props.data.marketplace?.status === "available"}
/>
) : null}