mirror of
https://gh-proxy.org/https://github.com/klinsmaya/open-connector.git
synced 2026-11-08 01:16:52 +08:00
fix(marketplace): load default discovery through runtime (#637)
This commit is contained in:
@@ -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." },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user