diff --git a/apps/api/src/plugin-distribution-routes.test.mjs b/apps/api/src/plugin-distribution-routes.test.mjs index 13c4851b..a8921dbd 100644 --- a/apps/api/src/plugin-distribution-routes.test.mjs +++ b/apps/api/src/plugin-distribution-routes.test.mjs @@ -53,6 +53,42 @@ describe("GitHub plugin release asset proxy", () => { ]); }); + test("falls back to the GitHub API asset endpoint when the public download URL fails", async () => { + const calls = []; + const buffer = await downloadGithubReleaseAssetByTag({ + owner: "example", + repository: "edgeever-plugin", + releaseTag: "v1.2.3", + assetName: "main.js", + request: async (input) => { + const url = String(input); + calls.push(url); + if (url.includes("/releases/download/")) throw new TypeError("Failed to fetch"); + if (url.includes("/releases/tags/")) { + return Response.json({ + tag_name: "v1.2.3", + draft: false, + assets: [{ + id: 42, + name: "main.js", + size: 18, + url: "https://api.github.com/repos/example/edgeever-plugin/releases/assets/42", + browser_download_url: "https://github.com/example/edgeever-plugin/releases/download/v1.2.3/main.js", + }], + }); + } + if (url.endsWith("/releases/assets/42")) { + return new Response("export default {};", { headers: { "content-length": "18" } }); + } + throw new Error(url); + }, + }); + + expect(new TextDecoder().decode(buffer)).toBe("export default {};"); + expect(calls[0]).toBe("https://github.com/example/edgeever-plugin/releases/download/v1.2.3/main.js"); + expect(calls).toContain("https://api.github.com/repos/example/edgeever-plugin/releases/assets/42"); + }); + test("rejects release coordinates that could escape the GitHub download path", async () => { await expect(downloadGithubReleaseAssetByTag({ owner: "example", diff --git a/apps/api/src/plugin-distribution-routes.ts b/apps/api/src/plugin-distribution-routes.ts index 5d591eba..da5c9719 100644 --- a/apps/api/src/plugin-distribution-routes.ts +++ b/apps/api/src/plugin-distribution-routes.ts @@ -170,12 +170,29 @@ export const downloadGithubReleaseAssetByTag = async ({ if (!hasValidRepositoryCoordinates(owner, repository) || !RELEASE_TAG_PATTERN.test(releaseTag)) { throw new Error("Invalid GitHub release coordinates."); } - const url = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/download/${encodeURIComponent(releaseTag)}/${encodeURIComponent(assetName)}`; - const response = await request(url, { - redirect: "follow", - headers: { "User-Agent": "EdgeEver" }, - }); - return readBoundedAsset(response, assetName); + const url = `https://github.com/${owner}/${repository}/releases/download/${releaseTag}/${assetName}`; + try { + const response = await request(url, { + redirect: "follow", + headers: { "User-Agent": "EdgeEver" }, + }); + return await readBoundedAsset(response, assetName); + } catch (error) { + const release = await readGithubReleaseByTag({ owner, repository, releaseTag, request }); + const asset = release.assets.find((candidate) => candidate.name === assetName); + if (!asset) { + const missing = new GithubUpstreamError(`GitHub Release ${releaseTag} is missing ${assetName}.`, 404); + missing.cause = error; + throw missing; + } + return downloadGithubReleaseAsset({ + owner, + repository, + assetId: String(asset.id), + assetName, + request, + }); + } }; export const registerPluginDistributionRoutes = (app: Hono) => { diff --git a/apps/web/src/lib/plugins/github-plugin-distribution.ts b/apps/web/src/lib/plugins/github-plugin-distribution.ts index 0b76e2d5..0e2668d9 100644 --- a/apps/web/src/lib/plugins/github-plugin-distribution.ts +++ b/apps/web/src/lib/plugins/github-plugin-distribution.ts @@ -163,16 +163,32 @@ export type GithubAssetDownloader = ( ) => Promise; const downloadGithubAssetThroughApi: GithubAssetDownloader = async (coordinates, releaseTag, asset) => { - try { + const assetName = asset.name as "manifest.json" | "main.js" | "styles.css"; + const download = async () => { + if (asset.id > 0) { + try { + return await api.downloadGithubPluginAssetById( + coordinates.owner, + coordinates.repository, + String(asset.id), + assetName, + ); + } catch (error) { + if (!isGithubUnreachableError(error) && !(error instanceof ApiRequestError)) throw error; + } + } return await api.downloadGithubPluginAsset( coordinates.owner, coordinates.repository, releaseTag, - asset.name as "manifest.json" | "main.js" | "styles.css", + assetName, ); + }; + try { + return await download(); } catch (error) { if (isGithubUnreachableError(error)) { - throw new Error("Could not download the plugin package from your EdgeEver instance."); + throw new Error(`Could not download ${assetName} from your EdgeEver instance.`); } throw error; } diff --git a/apps/web/src/lib/plugins/plugin-marketplace.test.mjs b/apps/web/src/lib/plugins/plugin-marketplace.test.mjs index c2163405..25832076 100644 --- a/apps/web/src/lib/plugins/plugin-marketplace.test.mjs +++ b/apps/web/src/lib/plugins/plugin-marketplace.test.mjs @@ -118,6 +118,50 @@ describe("bundled plugin marketplace", () => { }); }); + test("does not download official plugin packages when the live version already matches", async () => { + const checksums = { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) }; + const registry = parseMarketplaceRegistry({ + registryVersion: "1", + updatedAt: "2026-09-08T00:00:00.000Z", + entries: [{ + id: "org.edgeever.plugins.ai-rss", + name: "EdgeEver AI RSS", + description: "RSS", + author: "EdgeEver", + publisher: "edgeever", + category: "News & AI", + repositoryUrl: "https://github.com/tianma-if/edgeever-ai-rss", + distribution: { type: "github", repositoryUrl: "https://github.com/tianma-if/edgeever-ai-rss" }, + verification: { version: "0.5.5", checksums }, + }], + }); + const calls = []; + const request = async (input) => { + calls.push(String(input)); + if (String(input).includes("/contents/manifest.json")) { + return new Response(JSON.stringify({ + type: "plugin", + id: "org.edgeever.plugins.ai-rss", + name: "EdgeEver AI RSS", + version: "0.5.5", + apiVersion: "2", + settingsUi: "host", + entry: "./main.js", + permissions: ["ui:notices"], + })); + } + throw new Error(`Unexpected request: ${input}`); + }; + + const resolved = await resolveOfficialPluginMarketplace(registry, request, async () => { + throw new Error("should not download plugin assets"); + }); + + expect(resolved.resolutionErrors).toEqual({}); + expect(resolved.entries[0].verification).toEqual({ version: "0.5.5", checksums }); + expect(calls).toHaveLength(1); + }); + test("keeps the pinned release and reports an error when live resolution fails", async () => { const registry = parseMarketplaceRegistry({ registryVersion: "1", diff --git a/apps/web/src/lib/plugins/plugin-marketplace.ts b/apps/web/src/lib/plugins/plugin-marketplace.ts index e12c3dc1..a9b2ba11 100644 --- a/apps/web/src/lib/plugins/plugin-marketplace.ts +++ b/apps/web/src/lib/plugins/plugin-marketplace.ts @@ -1,6 +1,7 @@ import { parseMarketplaceRegistry, type MarketplaceEntry, type MarketplaceRegistry } from "@edgeever/plugin-api"; import { downloadGithubExtension, + loadGithubRepositoryManifest, type GithubAssetDownloader, } from "@/lib/plugins/github-plugin-distribution"; import { isVersionOutdated } from "@/lib/version-check"; @@ -30,6 +31,16 @@ const resolveOfficialGithubEntry = async ( downloadAssetBytes?: GithubAssetDownloader, ): Promise => { if (entry.publisher !== "edgeever" || entry.distribution.type !== "github") return entry; + const live = await loadGithubRepositoryManifest(entry.distribution.repositoryUrl, request); + if (live.manifest.id !== entry.id) { + throw new Error("Official repository manifest id does not match the marketplace entry."); + } + if (isVersionOutdated(live.manifest.version, entry.verification.version)) { + throw new Error("Official repository version is older than the marketplace verified version."); + } + if (live.manifest.version === entry.verification.version && entry.verification.checksums?.manifestJson) { + return entry; + } const downloaded = await downloadGithubExtension(entry.distribution.repositoryUrl, request, downloadAssetBytes); if (downloaded.manifest.id !== entry.id) { throw new Error("Official repository manifest id does not match the marketplace entry."); diff --git a/packages/client/src/index.test.mjs b/packages/client/src/index.test.mjs index 3ed19d66..51c6791b 100644 --- a/packages/client/src/index.test.mjs +++ b/packages/client/src/index.test.mjs @@ -171,6 +171,28 @@ describe("EdgeEver client HTTP contract", () => { expect(Array.from(new Uint8Array(buffer))).toEqual([1, 2, 3]); }); + test("downloads GitHub plugin assets through the release-asset id route", async () => { + let requestUrl; + const client = createEdgeEverClient({ + fetch: async (input) => { + requestUrl = String(input); + return new Response(new Uint8Array([4, 5])); + }, + }); + + const buffer = await client.downloadGithubPluginAssetById( + "example-owner", + "example-plugin", + "42", + "styles.css", + ); + + expect(requestUrl).toBe( + "/api/v1/plugins/github/example-owner/example-plugin/assets/42/styles.css", + ); + expect(Array.from(new Uint8Array(buffer))).toEqual([4, 5]); + }); + test("reads GitHub plugin metadata through the instance proxy", async () => { const calls = []; const client = createEdgeEverClient({ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c257158e..01a66673 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1238,6 +1238,15 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => { `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/${encodeURIComponent(releaseTag)}/assets/${encodeURIComponent(assetName)}`, ), + downloadGithubPluginAssetById: ( + owner: string, + repository: string, + assetId: string, + assetName: "manifest.json" | "main.js" | "styles.css", + ) => requestArrayBuffer( + `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/assets/${encodeURIComponent(assetId)}/${encodeURIComponent(assetName)}`, + ), + getGithubPluginRepositoryManifest: async (owner: string, repository: string) => { const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/manifest`; const { context, response } = await send(path, undefined, { setJsonContentType: false });