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
2 changes: 2 additions & 0 deletions docs/marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ When `OOMOL_CONNECT_ENCRYPTION_KEY` is configured, Connect encrypts the Marketpl

At startup, Connect fetches discovery once, validates the API key once, intersects the remote action allowlist with its local catalog, and keeps that snapshot for action execution. Discovery or authentication failures do not prevent the rest of the server from starting.

Before connecting, the browser reads the default Marketplace’s public discovery document directly, so you can browse official OOMOL applications without an API key. After a successful connection, the console shows the selected Marketplace's compatible services and enable/disable controls. Changing the discovery URL requires a new API key; removing the connection returns to the official directory. Official discounts do not apply to custom Marketplaces.

## Public API contract

Marketplace v1 consists of three HTTP endpoints:
Expand Down
17 changes: 17 additions & 0 deletions src/marketplace/default-marketplace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const defaultMarketplaceDiscoveryUrl = "https://connector.oomol.com/.well-known/oomol-connector-marketplace";

/** Identifies the default source by URL, never by a remote name or provider ID. */
export function isDefaultMarketplace(discoveryUrl: string): boolean {
try {
const url = new URL(discoveryUrl.trim());
return (
!url.username &&
!url.password &&
!url.search &&
!url.hash &&
url.href.replace(/\/$/, "") === defaultMarketplaceDiscoveryUrl
);
} catch {
return false;
}
}
39 changes: 39 additions & 0 deletions src/marketplace/marketplace-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@ const provider: ProviderDefinition = {
};

describe("MarketplaceService", () => {
it("keeps the current source on failed replacement and hides old preferences after a successful switch", async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const url = new URL(String(input));
if (url.pathname === "/validate") return new Response(null, { status: 204 });
if (url.hostname === "broken.example") throw new Error("offline");
return jsonResponse({
version: 1,
id: url.hostname,
name: url.hostname,
pricing: "metered",
validate: "/validate",
endpoint: "/actions",
actions: url.hostname === "first.example" ? ["example.run"] : ["remote.only"],
});
});
const store = new MemoryMarketplaceStore();
const service = new MarketplaceService({
catalog: createCatalogStore([provider]),
store,
secretCodec: reversibleCodec,
fetcher,
});
await service.configure({ discoveryUrl: "https://first.example/discovery", apiKey: "first-key" });
const count = fetcher.mock.calls.length;
await expect(service.configure({ discoveryUrl: "https://other.example/discovery" })).rejects.toThrow("new apiKey");
expect(fetcher).toHaveBeenCalledTimes(count);
await expect(
service.configure({ discoveryUrl: "https://broken.example/discovery", apiKey: "new-key" }),
).rejects.toThrow("offline");
expect(service.getState().discoveryUrl).toBe("https://first.example/discovery");
expect(await service.listProviderPreferences()).toHaveLength(1);
await service.configure({ discoveryUrl: "https://other.example/discovery", apiKey: "new-key" });
expect(await service.listProviderPreferences()).toEqual([]);
expect(await store.listProviderPreferences()).toHaveLength(1);
await service.remove();
expect(await service.listProviderPreferences()).toEqual([]);
expect(service.getState().configured).toBe(false);
});

it("validates discovery and derives only locally compatible actions", async () => {
const store = new MemoryMarketplaceStore();
const fetcher = vi
Expand Down
15 changes: 11 additions & 4 deletions src/marketplace/marketplace-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import type { ISecretCodec } from "../server/secrets/secret-codec-core.ts";

import { assertPublicHttpUrl } from "../core/request.ts";
import { providerFetch } from "../providers/provider-runtime.ts";

export const defaultMarketplaceDiscoveryUrl = "https://connector.oomol.com/.well-known/oomol-connector-marketplace";
import { defaultMarketplaceDiscoveryUrl } from "./default-marketplace.ts";
const maximumDiscoveryBytes = 4 * 1024 * 1024;

export type MarketplacePricing = "free" | "metered";
Expand Down Expand Up @@ -146,6 +145,9 @@ export class MarketplaceService {
async configure(input: MarketplaceConfigInput): Promise<MarketplaceState> {
const previous = await this.options.store.getConfig();
const discoveryUrl = input.discoveryUrl?.trim() || previous?.discoveryUrl || defaultMarketplaceDiscoveryUrl;
if (previous && discoveryUrl !== previous.discoveryUrl && !input.apiKey?.trim()) {
throw new MarketplaceError("invalid_input", "A new apiKey is required when changing the discovery URL.");
}
const apiKey =
input.apiKey?.trim() || (previous ? await this.options.secretCodec.decode(previous.apiKeyEncrypted) : "");
if (!apiKey) throw new MarketplaceError("invalid_input", "apiKey is required.");
Expand Down Expand Up @@ -180,7 +182,8 @@ export class MarketplaceService {
}

async listProviderPreferences(): Promise<ProviderPreference[]> {
return await this.options.store.listProviderPreferences();
const preferences = await this.options.store.listProviderPreferences();
return preferences.filter((preference) => this.snapshot?.actionsByService.has(preference.service));
}

async setProviderEnabled(service: string, enabled: boolean): Promise<ProviderPreference> {
Expand Down Expand Up @@ -282,7 +285,11 @@ export class MarketplaceService {
fieldName: "discoveryUrl",
createError: (message) => new MarketplaceError("invalid_marketplace_discovery", message),
});
const response = await this.fetch(url, { headers: { accept: "application/json" }, redirect: "manual" });
const response = await this.fetch(url, {
headers: { accept: "application/json" },
redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
if (300 <= response.status && response.status < 400) {
throw new MarketplaceError("invalid_marketplace_discovery", "Marketplace discovery redirects are not allowed.");
}
Expand Down
136 changes: 136 additions & 0 deletions web/src/default-marketplace-catalog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { DefaultMarketplaceDiscovery } from "./default-marketplace-discovery";
import type { ProviderDefinition } from "./model";
import type { ReactNode } from "react";

import { useTranslate } from "@embra/i18n/react";
import { Loader2, Store } from "lucide-react";
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { Button } from "./components/ui/button";
import { loadDefaultMarketplaceCatalog } from "./default-marketplace-discovery";
import { Badge, EmptyState, ProviderIcon } from "./shared-ui";

interface DefaultMarketplaceCatalogProps {
providers: ProviderDefinition[];
discoveryUrl: string;
}

// Default promotions apply only to the named models, never to custom marketplaces.
const promotedModels: Record<string, string[]> = {
kling: ["Kling 3.0"],
minimax: ["MiniMax H3"],
seedance: ["Seedance 2.0", "Seedance 2.5"],
};
Comment thread
Cheerego7 marked this conversation as resolved.
const promotedServices = Object.keys(promotedModels);

export function DefaultMarketplaceCatalog({ providers, discoveryUrl }: DefaultMarketplaceCatalogProps): ReactNode {
const t = useTranslate();
const [catalog, setCatalog] = useState<DefaultMarketplaceDiscovery>();
const [failure, setFailure] = useState<Error>();
const [attempt, setAttempt] = useState(0);

useEffect(() => {
let active = true;
setFailure(undefined);
const controller = new AbortController();
void loadDefaultMarketplaceCatalog(discoveryUrl, controller.signal).then(
(value) => {
if (active) setCatalog(value);
},
(error: unknown) => {
if (active) {
setFailure(error instanceof Error ? error : new Error());
}
},
);
return () => {
active = false;
controller.abort();
};
}, [attempt, discoveryUrl]);

const available = new Set(catalog?.actions);
const rows = providers
.filter((provider) => provider.actions.some((action) => available.has(action.id)))
.sort((a, b) => {
const aRank = promotedServices.indexOf(a.service);
const bRank = promotedServices.indexOf(b.service);
return (
(aRank < 0 ? promotedServices.length : aRank) - (bRank < 0 ? promotedServices.length : bRank) ||
a.displayName.localeCompare(b.displayName)
);
});

return (
<section className="marketplace-panel">
<header className="marketplace-panel-header">
<div>
<h2>{t("marketplace.default.title")}</h2>
<p>{t("marketplace.default.description")}</p>
</div>
{catalog ? <Badge>{t("marketplace.providers.count", { count: rows.length })}</Badge> : null}
</header>
{failure ? (
<div className="marketplace-catalog-feedback" role="status">
<p>{t("marketplace.default.failed")}</p>
{failure.message ? <p className="marketplace-catalog-error">{failure.message}</p> : null}
<Button variant="outline" size="sm" onClick={() => setAttempt((value) => value + 1)}>
{t("marketplace.default.retry")}
</Button>
</div>
) : !catalog ? (
<div className="marketplace-catalog-feedback" role="status">
<Loader2 className="spin" size={16} aria-hidden="true" />
{t("marketplace.default.loading")}
</div>
) : rows.length === 0 ? (
<EmptyState
icon={<Store size={20} />}
title={t("marketplace.default.empty")}
description={t("marketplace.default.description")}
density="compact"
/>
) : (
<div className="marketplace-provider-list">
{rows.map((provider) => {
const promoted = promotedServices.includes(provider.service);
const path = `/providers/${encodeURIComponent(provider.service)}`;
return (
<div className="marketplace-provider-row marketplace-default-row" key={provider.service}>
<ProviderIcon provider={provider} />
<div className="marketplace-default-copy">
<Link className="marketplace-provider-copy" to={path}>
<strong>{provider.displayName}</strong>
<span>
{promoted
? t(`marketplace.default.descriptions.${provider.service}`)
: provider.description || t("marketplace.default.browseDescription")}
</span>
</Link>
{promoted ? (
<div className="marketplace-default-tags">
{promotedModels[provider.service].map((model) => (
<span className="marketplace-model-tag" key={model}>
{model}
</span>
))}
<Badge tone="success">{t(`marketplace.default.offers.${provider.service}`)}</Badge>
</div>
) : null}
</div>
<Button asChild variant="outline" size="sm">
<Link to={path}>{t("marketplace.default.view")}</Link>
</Button>
</div>
);
})}
</div>
)}
{catalog ? (
<footer className="marketplace-catalog-footer">
{catalog.name} · {t("marketplace.default.disconnected")}
</footer>
) : null}
</section>
);
}
28 changes: 28 additions & 0 deletions web/src/default-marketplace-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { afterEach, expect, it, vi } from "vitest";
import { loadDefaultMarketplaceCatalog } from "./default-marketplace-discovery";

afterEach(() => vi.unstubAllGlobals());

it("reads public discovery directly without credentials", 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"] });
expect(fetcher).toHaveBeenCalledWith(
"https://example.com/discovery",
expect.objectContaining({ credentials: "omit", redirect: "error" }),
);
});

it.each([
new Response("offline", { status: 503 }),
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();
});
33 changes: 33 additions & 0 deletions web/src/default-marketplace-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface DefaultMarketplaceDiscovery {
name: string;
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",
redirect: "error",
signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
headers: { accept: "application/json" },
});
if (!response.ok) throw new Error(`Default Marketplace returned HTTP ${response.status}.`);
const value: unknown = await response.json();
if (
!value ||
typeof value !== "object" ||
!("version" in value) ||
value.version !== 1 ||
!("name" in value) ||
typeof value.name !== "string" ||
!("actions" in value) ||
!Array.isArray(value.actions) ||
!value.actions.every((action: unknown) => typeof action === "string")
) {
throw new Error("Default Marketplace returned an invalid discovery document.");
}
return { name: value.name, actions: value.actions };
}
Loading