feat(plugins): append a signed community catalog beside official plugins

Official entries stay on the bundled registry. The instance returns a community file only when EDGE_EVER_COMMUNITY_REGISTRY_URL is set, and submission opens from its own marketplace entry.
This commit is contained in:
tianma-if committed 2026-10-06 18:38:37 +08:00
1 parent 40321fe1f8
commit be7bbe25e6
32 files changed
+1524 -23

No files matched your search

+2
View File
@@ -31,6 +31,8 @@ export type Bindings = {
EDGE_EVER_DEMO_MODE?: string;
EDGE_EVER_LOCAL_DEMO_SEED?: string;
EDGE_EVER_ALLOW_UNAUTHENTICATED?: string;
/** HTTPS URL of the signed community registry. Unset disables the catalog. */
EDGE_EVER_COMMUNITY_REGISTRY_URL?: string;
};
export type WorkerBindings = Omit<Bindings, "storage" | "publicNetworkFetch" | "deploymentVersionCreatedAt"> & CloudflareStorageBindings & {
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { fetchCommunityRegistryRelease, resolveCommunityRegistryUrl } from "./community-registry-proxy.ts";
import { registerPluginDistributionRoutes } from "./plugin-distribution-routes.ts";
const registryText = JSON.stringify({ registryVersion: "1", updatedAt: "2026-10-05T00:00:00.000Z", entries: [] });
const signature = Buffer.alloc(64, 7).toString("base64");
describe("community registry proxy", () => {
test("stays disabled until the instance sets a registry URL", () => {
expect(resolveCommunityRegistryUrl(undefined)).toBeNull();
expect(resolveCommunityRegistryUrl(" ")).toBeNull();
expect(() => resolveCommunityRegistryUrl("http://plugins.example/community-registry.json")).toThrow("HTTPS");
expect(resolveCommunityRegistryUrl("https://plugins.example/community-registry.json")?.href)
.toBe("https://plugins.example/community-registry.json");
});
test("returns the configured bytes and does not follow redirects or caller URLs", async () => {
const calls = [];
const app = new Hono();
registerPluginDistributionRoutes(app);
const response = await app.request("/api/v1/plugins/community-registry?url=https://evil.example/community-registry.json", {}, {
EDGE_EVER_COMMUNITY_REGISTRY_URL: "https://plugins.example/community-registry.json",
publicNetworkFetch: async (url) => {
calls.push(String(url));
if (String(url).endsWith(".json.sig")) return new Response(signature);
return new Response(registryText, { headers: { "content-type": "application/json" } });
},
});
expect(response.status).toBe(200);
expect(await response.text()).toBe(registryText);
expect(response.headers.get("x-edgeever-community-registry-signature")).toBe(signature);
expect(response.headers.get("cache-control")).toContain("max-age=60");
expect(calls).toEqual([
"https://plugins.example/community-registry.json",
"https://plugins.example/community-registry.json.sig",
]);
const missing = await app.request("/api/v1/plugins/community-registry", {}, {});
expect(missing.status).toBe(404);
});
test("rejects a redirect instead of fetching the target", async () => {
await expect(fetchCommunityRegistryRelease({
registryUrl: "https://plugins.example/community-registry.json",
request: async () => new Response(null, { status: 302, headers: { location: "https://evil.example/community-registry.json" } }),
})).rejects.toThrow("redirects are not followed");
});
});
+97
View File
@@ -0,0 +1,97 @@
const COMMUNITY_REGISTRY_MAX_BYTES = 1024 * 1024;
const COMMUNITY_REGISTRY_SIGNATURE_MAX_BYTES = 2048;
export const COMMUNITY_REGISTRY_SIGNATURE_HEADER = "x-edgeever-community-registry-signature";
export const resolveCommunityRegistryUrl = (value: string | undefined) => {
const trimmed = value?.trim() ?? "";
if (!trimmed) return null;
let url: URL;
try {
url = new URL(trimmed);
} catch {
throw new Error("Community registry URL is invalid.");
}
const localHttp = url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
if (url.protocol !== "https:" && !localHttp) throw new Error("Community registry URL must use HTTPS.");
if (url.username || url.password) throw new Error("Community registry URL cannot include credentials.");
return url;
};
export const communityRegistrySignatureUrl = (registryUrl: URL) => {
const signature = new URL(registryUrl.href);
signature.pathname = `${registryUrl.pathname}.sig`;
return signature;
};
const encodeBase64 = (bytes: Uint8Array) => {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
};
const decodeBase64 = (value: string) => {
const trimmed = value.replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed)) return null;
try {
const binary = atob(trimmed);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
} catch {
return null;
}
};
const readBoundedBody = async (response: Response, maximumBytes: number, label: string) => {
if (response.status >= 300 && response.status < 400) {
throw new Error(`${label} redirects are not followed.`);
}
if (!response.ok) throw new Error(`${label} failed with HTTP ${response.status}.`);
const declaredSize = Number(response.headers.get("content-length") ?? 0);
if (declaredSize > maximumBytes) throw new Error(`${label} exceeds the allowed size.`);
const buffer = await response.arrayBuffer();
if (buffer.byteLength > maximumBytes) throw new Error(`${label} exceeds the allowed size.`);
return buffer;
};
const normalizeSignature = (bytes: ArrayBuffer) => {
const view = new Uint8Array(bytes);
if (view.byteLength === 64) return encodeBase64(view);
const text = new TextDecoder().decode(view).trim();
const decoded = decodeBase64(text);
if (!decoded || decoded.byteLength !== 64) throw new Error("Community registry signature is invalid.");
return text.replace(/\s+/g, "");
};
export const fetchCommunityRegistryRelease = async ({
registryUrl,
request = fetch,
timeoutMs = 10_000,
}: {
registryUrl: string;
request?: typeof fetch;
timeoutMs?: number;
}) => {
const url = resolveCommunityRegistryUrl(registryUrl);
if (!url) throw new Error("Community registry URL is not configured.");
const signatureUrl = communityRegistrySignatureUrl(url);
const init = {
redirect: "manual" as const,
credentials: "omit" as const,
signal: AbortSignal.timeout(timeoutMs),
};
const [registryResponse, signatureResponse] = await Promise.all([
request(url.href, { ...init, headers: { Accept: "application/json", "User-Agent": "EdgeEver" } }),
request(signatureUrl.href, {
...init,
headers: { Accept: "text/plain, application/octet-stream", "User-Agent": "EdgeEver" },
}),
]);
const registryBytes = await readBoundedBody(registryResponse, COMMUNITY_REGISTRY_MAX_BYTES, "Community registry");
const signatureBytes = await readBoundedBody(signatureResponse, COMMUNITY_REGISTRY_SIGNATURE_MAX_BYTES, "Community registry signature");
return {
registryBytes,
signatureBase64: normalizeSignature(signatureBytes),
};
};
@@ -1,5 +1,6 @@
import type { Hono } from "hono";
import type { AppEnv } from "./api-context";
import { fetchCommunityRegistryRelease } from "./community-registry-proxy";
import { apiError, badRequest, notFound } from "./http-errors";
const GITHUB_API_VERSION = "2022-11-28";
@@ -358,6 +359,34 @@ const respondWithGithubManifestText = (
});
export const registerPluginDistributionRoutes = (app: Hono<AppEnv>) => {
app.get("/api/v1/plugins/community-registry", async (context) => {
const configured = context.env.EDGE_EVER_COMMUNITY_REGISTRY_URL;
if (!configured?.trim()) return notFound(context, "Community registry is not configured.");
try {
const transport = context.env.publicNetworkFetch;
const release = await fetchCommunityRegistryRelease({
registryUrl: configured,
request: (input, init) => {
if (!transport) return fetch(input, init);
const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return transport(target, init ?? {});
},
});
return context.body(release.registryBytes, 200, {
"Cache-Control": "private, max-age=60",
"Content-Type": "application/json; charset=utf-8",
"X-EdgeEver-Community-Registry-Signature": release.signatureBase64,
});
} catch (error) {
return apiError(
context,
"community_registry_unavailable",
error instanceof Error ? error.message : "Community registry is unavailable.",
502,
);
}
});
app.get("/api/v1/plugins/github/:owner/:repository/latest-manifest", async (context) => {
const owner = context.req.param("owner");
const repository = context.req.param("repository");
@@ -16,6 +16,7 @@ import {
import { getPluginDetailPath, hasPluginSettings, isPluginCardCommand } from "@/lib/plugins/plugin-navigation";
import type { PluginUpdateInfo } from "@/lib/plugins/plugin-updates";
import type { RegisteredPluginCommand } from "@/lib/plugins/plugin-host";
import type { MarketplaceRevocation } from "@edgeever/plugin-api";
const sourceBadgeClassName = "bg-slate-100 text-slate-700";
@@ -30,11 +31,13 @@ export const PluginCatalogCard = ({
onUpdate,
onRunCommand,
onUninstall,
revocation,
}: {
item: PluginCatalogItem;
update?: PluginUpdateInfo;
commands: RegisteredPluginCommand[];
pendingId: string | null;
revocation?: MarketplaceRevocation;
onOpenPlugin?: (pluginId: string) => void;
onToggle: (enabled: boolean) => void;
onInstallMarketplace: () => void;
@@ -106,6 +109,14 @@ export const PluginCatalogCard = ({
</div>
) : null}
{description ? <p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{description}</p> : null}
{item.marketplaceEntry?.listing === "community" ? (
<p className="mt-1 text-xs leading-5 text-slate-500">
{item.marketplaceEntry.licenseSpdx
? `${t("plugins.marketplace.license", { spdx: item.marketplaceEntry.licenseSpdx })} · `
: ""}
{t("plugins.marketplace.admissionNotice")}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{repositoryUrl ? (
@@ -128,6 +139,11 @@ export const PluginCatalogCard = ({
</div>
</div>
{revocation ? (
<div role="status" className="mt-2 text-xs leading-5 text-rose-700">
{t("plugins.marketplace.revoked", { reason: revocation.reason })}
</div>
) : null}
{extension?.error ? <div className="mt-2 text-xs text-rose-600">{extension.error}</div> : null}
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-3">
@@ -139,7 +155,7 @@ export const PluginCatalogCard = ({
</Link>
</Button>
) : null}
{update ? (
{update && !revocation ? (
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
@@ -150,7 +166,7 @@ export const PluginCatalogCard = ({
{t("plugins.updates.update")}
</Button>
) : null}
{!extension && item.marketplaceEntry ? (
{!extension && item.marketplaceEntry && !revocation ? (
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
@@ -161,7 +177,7 @@ export const PluginCatalogCard = ({
{pendingId === marketplaceActionId ? t("plugins.installing") : t("plugins.install")}
</Button>
) : null}
{replaceWithVerified && !update ? (
{replaceWithVerified && !update && !revocation ? (
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
@@ -1,21 +1,34 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { BookOpen, CalendarClock, Download, ExternalLink, History, Play, Puzzle, RefreshCw, Settings2, Trash2 } from "lucide-react";
import { BookOpen, CalendarClock, Download, ExternalLink, History, Play, Puzzle, RefreshCw, Settings2, Trash2, Upload } from "lucide-react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { EdgeEverPluginHost, InstalledExtension, RegisteredPluginCommand } from "@/lib/plugins/plugin-host";
import { PluginCatalogCard } from "@/components/plugins/PluginCatalogCard";
import { loadResolvedPluginMarketplace } from "@/lib/plugins/plugin-marketplace";
import { loadDisplayPluginMarketplace } from "@/lib/plugins/community-registry";
import {
COMMUNITY_PLUGINS_REPOSITORY_URL,
COMMUNITY_PLUGINS_README_URL,
communityPluginSubmissionIssueUrl,
precheckCommunityPluginSubmission,
} from "@/lib/plugins/community-plugin-submission";
import { GitHubMark } from "@/components/GitHubRepositoryLink";
import { applyPluginUpdate, checkPluginUpdates, type PluginUpdateInfo } from "@/lib/plugins/plugin-updates";
import { PluginUpdateDialog } from "@/components/plugins/PluginUpdateDialog";
import { PluginSettingsSection } from "@/components/plugins/PluginSettingsSection";
import { buildPluginCatalogItems, getPluginCatalogDescription, getPluginCatalogName, getPluginCatalogSourceKey } from "@/lib/plugins/plugin-catalog";
import type { MarketplaceEntry } from "@edgeever/plugin-api";
import type { MarketplaceEntry, MarketplaceRevocation } from "@edgeever/plugin-api";
import { getPluginDetailPage, getPluginDetailPath, hasPluginSettings, isPluginCardCommand, type PluginDetailPage } from "@/lib/plugins/plugin-navigation";
import type { ScheduledTask } from "@edgeever/shared";
import { api, getOrCreateClientDeviceId } from "@/lib/api";
@@ -147,6 +160,7 @@ const PluginDetailView = ({
onToggle,
onUninstall,
onUpdate,
revocation,
}: {
page: PluginDetailPage;
commands: RegisteredPluginCommand[];
@@ -159,6 +173,7 @@ const PluginDetailView = ({
onToggle: (enabled: boolean) => void;
onUninstall: () => void;
onUpdate: () => void;
revocation?: MarketplaceRevocation;
}) => {
const { t, i18n } = useTranslation();
const { manifest } = extension;
@@ -167,7 +182,7 @@ const PluginDetailView = ({
const locale = i18n.resolvedLanguage ?? i18n.language;
const name = getPluginCatalogName(catalogItem, locale);
const description = getPluginCatalogDescription(catalogItem, locale);
const sourceKey = extension.source.verified ? "verified" : extension.source.kind;
const sourceKey = getPluginCatalogSourceKey(catalogItem) ?? extension.source.kind;
return (
<div className="grid gap-5">
@@ -243,10 +258,15 @@ const PluginDetailView = ({
</section>
) : null}
{revocation ? (
<div role="status" className="text-xs leading-5 text-rose-700">
{t("plugins.marketplace.revoked", { reason: revocation.reason })}
</div>
) : null}
{extension.error ? <div className="text-xs leading-5 text-rose-600">{extension.error}</div> : null}
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
{update ? (
{update && !revocation ? (
<Button size="sm" className="gap-1.5" disabled={pendingId === `update:${id}`} onClick={onUpdate}>
<Download className="h-3.5 w-3.5" />
{t("plugins.updates.update")}
@@ -287,6 +307,9 @@ export const PluginManagerCard = ({
const developerDocsUrl = i18n.resolvedLanguage?.startsWith("zh")
? "https://github.com/tianma-if/edgeever/blob/main/docs/plugin-development.zh-CN.md"
: "https://github.com/tianma-if/edgeever/blob/main/docs/plugin-development.md";
const communityReadmeUrl = i18n.resolvedLanguage?.startsWith("zh")
? `${COMMUNITY_PLUGINS_REPOSITORY_URL}/blob/main/README.zh-CN.md`
: COMMUNITY_PLUGINS_README_URL;
const snapshot = useSyncExternalStore(host.subscribe, host.getSnapshot, host.getSnapshot);
const [manifestUrl, setManifestUrl] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null);
@@ -296,15 +319,41 @@ export const PluginManagerCard = ({
const [error, setError] = useState<string | null>(null);
const [pendingUpdate, setPendingUpdate] = useState<PluginUpdateInfo | null>(null);
const [pendingTrustPluginId, setPendingTrustPluginId] = useState<string | null>(null);
const marketplaceQuery = useQuery({ queryKey: ["plugin-marketplace", "v1"], queryFn: () => loadResolvedPluginMarketplace(), staleTime: 5 * 60_000 });
const [submissionOpen, setSubmissionOpen] = useState(false);
const [submissionRepository, setSubmissionRepository] = useState("");
const [submissionResult, setSubmissionResult] = useState<{ id: string; repositoryUrl: string } | null>(null);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const [checkingSubmission, setCheckingSubmission] = useState(false);
const [refreshingMarketplace, setRefreshingMarketplace] = useState(false);
const marketplaceQuery = useQuery({
queryKey: ["plugin-marketplace", "v2"],
queryFn: () => loadDisplayPluginMarketplace({ refreshCommunity: false }),
staleTime: 5 * 60_000,
});
useEffect(() => {
let active = true;
void loadDisplayPluginMarketplace({ refreshCommunity: true }).then((next) => {
if (active) queryClient.setQueryData(["plugin-marketplace", "v2"], next);
}).catch(() => undefined);
return () => {
active = false;
};
}, [queryClient]);
const extensionVersionKey = snapshot.extensions
.map((extension) => `${extension.manifest.id}:${extension.manifest.version}:${extension.source.kind}`)
.join("|");
const marketplaceVersionKey = marketplaceQuery.data?.updatedAt ?? "unavailable";
const updateQueryKey = ["plugin-updates", extensionVersionKey, marketplaceVersionKey] as const;
const revocationKey = (marketplaceQuery.data?.revocations ?? []).map((item) => item.id).sort().join("|");
const revokedIds = new Set((marketplaceQuery.data?.revocations ?? []).map((item) => item.id));
const updateQueryKey = ["plugin-updates", extensionVersionKey, marketplaceVersionKey, revocationKey] as const;
const updateQuery = useQuery({
queryKey: updateQueryKey,
queryFn: () => checkPluginUpdates(snapshot.extensions, marketplaceQuery.data?.entries ?? []),
queryFn: () => checkPluginUpdates(
snapshot.extensions,
marketplaceQuery.data?.entries ?? [],
undefined,
{ revokedIds },
),
enabled: snapshot.extensions.length > 0 && !marketplaceQuery.isLoading,
staleTime: 5 * 60_000,
refetchInterval: 30 * 60_000,
@@ -342,14 +391,30 @@ export const PluginManagerCard = ({
}
};
const refreshMarketplace = async () => {
const next = await loadDisplayPluginMarketplace({ refreshCommunity: true });
queryClient.setQueryData(["plugin-marketplace", "v2"], next);
return next;
};
const checkForUpdates = async () => {
setManuallyChecking(true);
setError(null);
try {
const refreshedMarketplace = await marketplaceQuery.refetch();
const result = await checkPluginUpdates(snapshot.extensions, refreshedMarketplace.data?.entries ?? []);
const refreshedMarketplace = await refreshMarketplace();
const result = await checkPluginUpdates(
snapshot.extensions,
refreshedMarketplace.entries,
undefined,
{ revokedIds: new Set(refreshedMarketplace.revocations.map((item) => item.id)) },
);
queryClient.setQueryData(
["plugin-updates", extensionVersionKey, refreshedMarketplace.data?.updatedAt ?? "unavailable"],
[
"plugin-updates",
extensionVersionKey,
refreshedMarketplace.updatedAt ?? "unavailable",
refreshedMarketplace.revocations.map((item) => item.id).sort().join("|"),
],
result,
);
setLastManualCheckCount(result.updates.length);
@@ -397,13 +462,13 @@ export const PluginManagerCard = ({
return (
<Card className="w-full min-w-0 shadow-none">
<CardHeader className="p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<Puzzle className="h-4 w-4 shrink-0 text-slate-900" />
{selectedPluginId ? t("plugins.details.title") : t("plugins.title")}
<span className={BETA_BADGE_CLASSNAME}>Beta</span>
</CardTitle>
<div className="flex shrink-0 items-center gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{snapshot.extensions.length > 0 || (marketplaceQuery.data?.entries.length ?? 0) > 0 ? (
<Button
variant="ghost"
@@ -428,6 +493,39 @@ export const PluginManagerCard = ({
) : null}
</Button>
) : null}
{!selectedPluginId ? (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-2 text-slate-600"
aria-label={t("plugins.marketplace.refresh")}
disabled={refreshingMarketplace}
onClick={() => {
setRefreshingMarketplace(true);
setError(null);
void refreshMarketplace()
.catch((refreshError) => {
setError(refreshError instanceof Error ? refreshError.message : String(refreshError));
})
.finally(() => setRefreshingMarketplace(false));
}}
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshingMarketplace ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">{t("plugins.marketplace.refresh")}</span>
</Button>
) : null}
{!selectedPluginId ? (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-2 text-slate-600"
aria-label={t("plugins.marketplace.submissionTitle")}
onClick={() => setSubmissionOpen(true)}
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t("plugins.marketplace.submissionTitle")}</span>
</Button>
) : null}
<Button asChild variant="ghost" size="sm" className="h-8 shrink-0 gap-1.5 px-2 text-slate-600">
<a href={developerDocsUrl} target="_blank" rel="noreferrer" aria-label={t("plugins.developerDocs")}>
<BookOpen className="h-4 w-4" />
@@ -465,6 +563,7 @@ export const PluginManagerCard = ({
marketplaceEntry={catalogItems.find((item) => item.id === selectedExtension.manifest.id)?.marketplaceEntry}
host={host}
update={updateQuery.data?.updates.find((update) => update.pluginId === selectedExtension.manifest.id)}
revocation={(marketplaceQuery.data?.revocations ?? []).find((item) => item.id === selectedExtension.manifest.id)}
commands={snapshot.commands.filter((command) => command.pluginId === selectedExtension.manifest.id)}
pendingId={pendingId}
onToggle={(enabled) => toggleExtension(selectedExtension, enabled)}
@@ -535,6 +634,7 @@ export const PluginManagerCard = ({
() => host.runCommand(item.id, command.id),
)}
onUninstall={() => void run(`remove:${item.id}`, () => host.uninstall(item.id))}
revocation={(marketplaceQuery.data?.revocations ?? []).find((entry) => entry.id === item.id)}
/>
))}
</div>
@@ -550,6 +650,70 @@ export const PluginManagerCard = ({
onConfirm={() => void run(`update:${pendingUpdate.pluginId}`, () => applyUpdate(pendingUpdate))}
/>
) : null}
<Dialog open={submissionOpen} onOpenChange={setSubmissionOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("plugins.marketplace.submissionTitle")}</DialogTitle>
<DialogDescription className="leading-5">{t("plugins.marketplace.submissionHint")}</DialogDescription>
</DialogHeader>
<a
href={communityReadmeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex w-fit items-center gap-1 text-xs text-slate-500 hover:text-slate-950"
>
{t("plugins.marketplace.submissionDocs")}
<ExternalLink className="h-3 w-3" />
</a>
{submissionError ? (
<div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{submissionError}</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row">
<Input
aria-label={t("plugins.marketplace.submissionRepository")}
value={submissionRepository}
onChange={(event) => {
setSubmissionRepository(event.target.value);
setSubmissionResult(null);
setSubmissionError(null);
}}
placeholder={t("plugins.sourcePlaceholder")}
className="focus-visible:border-slate-400 focus-visible:ring-slate-400/25"
/>
<Button
variant="outline"
className="gap-1.5 sm:shrink-0"
disabled={checkingSubmission || !submissionRepository.trim()}
onClick={() => {
setCheckingSubmission(true);
setSubmissionError(null);
setSubmissionResult(null);
void precheckCommunityPluginSubmission(submissionRepository.trim())
.then((result) => setSubmissionResult({ id: result.id, repositoryUrl: result.repositoryUrl }))
.catch((precheckError) => {
setSubmissionError(precheckError instanceof Error ? precheckError.message : String(precheckError));
})
.finally(() => setCheckingSubmission(false));
}}
>
{checkingSubmission ? t("plugins.marketplace.precheckRunning") : t("plugins.marketplace.precheck")}
</Button>
</div>
{submissionResult ? (
<p className="text-xs leading-5 text-slate-600">
{t("plugins.marketplace.precheckPassed", { id: submissionResult.id })}{" "}
<a
href={communityPluginSubmissionIssueUrl(submissionResult.id, submissionResult.repositoryUrl)}
target="_blank"
rel="noreferrer"
className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-900"
>
{t("plugins.marketplace.openSubmission")}
</a>
</p>
) : null}
</DialogContent>
</Dialog>
{pendingTrustPluginId ? (
<AppConfirmDialog
title={t(PLUGIN_TRUST_WARNING_COPY.titleKey)}
@@ -0,0 +1,35 @@
import { isReservedMarketplacePluginId } from "@edgeever/plugin-api";
import { downloadGithubExtension, parseGithubRepositoryUrl } from "@/lib/plugins/github-plugin-distribution";
export const COMMUNITY_PLUGINS_REPOSITORY_URL = "https://github.com/tianma-if/edgeever-plugins";
export const COMMUNITY_PLUGINS_README_URL = `${COMMUNITY_PLUGINS_REPOSITORY_URL}#readme`;
export const communityPluginSubmissionIssueUrl = (pluginId: string, repositoryUrl: string) => {
const url = new URL(`${COMMUNITY_PLUGINS_REPOSITORY_URL}/issues/new`);
url.searchParams.set("template", "plugin-submission.yml");
url.searchParams.set("title", `Plugin ${pluginId}`);
url.searchParams.set("plugin_id", pluginId);
url.searchParams.set("repository_url", repositoryUrl);
return url.href;
};
export const precheckCommunityPluginSubmission = async (input: string) => {
const coordinates = parseGithubRepositoryUrl(input);
if (!coordinates) {
throw new Error("Enter a public GitHub repository URL such as https://github.com/owner/repository.");
}
const downloaded = await downloadGithubExtension(coordinates.repositoryUrl);
if (isReservedMarketplacePluginId(downloaded.manifest.id)) {
throw new Error("Plugin ids under org.edgeever are reserved for EdgeEver.");
}
if (!downloaded.checksums.manifestJson) throw new Error("The GitHub release is missing manifest.json.");
if (downloaded.manifest.type === "plugin" && !downloaded.checksums.mainJs) {
throw new Error("The GitHub release is missing main.js.");
}
return {
id: downloaded.manifest.id,
version: downloaded.manifest.version,
repositoryUrl: downloaded.repositoryUrl,
releaseTag: downloaded.releaseTag,
};
};
@@ -0,0 +1,55 @@
/**
* Ed25519 public keys for the signed community registry.
* The first key is current. The next key is the previous one, kept for one
* client release so a registry signed before rotation still verifies.
* The private key stays in the tianma-if/edgeever-plugins publish environment.
*/
export const COMMUNITY_REGISTRY_PUBLIC_KEYS_BASE64 = [
"vkaOJCCApTIzFvkDRGguUW+hg7tK7oF9WsWoNfUbB8g=",
] as const;
export const decodeCommunityRegistryKey = (value: string) => {
const trimmed = value.trim();
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed)) return null;
try {
const binary = atob(trimmed);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
} catch {
return null;
}
};
export const communityRegistryPublicKeys = (
encodedKeys: readonly string[] = COMMUNITY_REGISTRY_PUBLIC_KEYS_BASE64,
) => encodedKeys.flatMap((value) => {
const bytes = decodeCommunityRegistryKey(value);
return bytes?.byteLength === 32 ? [bytes] : [];
});
export const verifyCommunityRegistrySignature = async (
registryBytes: Uint8Array,
signature: Uint8Array,
publicKeys: readonly Uint8Array[],
) => {
if (signature.byteLength !== 64 || publicKeys.length === 0) return false;
const data = new Uint8Array(registryBytes);
const signatureBytes = new Uint8Array(signature);
for (const publicKey of publicKeys) {
if (publicKey.byteLength !== 32) continue;
try {
const key = await crypto.subtle.importKey(
"raw",
new Uint8Array(publicKey),
{ name: "Ed25519" },
false,
["verify"],
);
if (await crypto.subtle.verify({ name: "Ed25519" }, key, signatureBytes, data)) return true;
} catch {
// A runtime without Ed25519, or a rejected key, fails closed for that key.
}
}
return false;
};
@@ -0,0 +1,128 @@
import type { MarketplaceRevocation } from "@edgeever/plugin-api";
const DATABASE_NAME = "edgeever-community-registry-v1";
const DATABASE_VERSION = 1;
const REGISTRY_STORE = "registries";
const REVOCATION_STORE = "revocations";
export interface CachedCommunityRegistry {
instanceKey: string;
registryBase64: string;
signatureBase64: string;
updatedAt: string;
}
export interface CommunityRegistryStorage {
readRegistry(instanceKey: string): Promise<CachedCommunityRegistry | null>;
writeRegistry(value: CachedCommunityRegistry): Promise<void>;
readRevocations(): Promise<MarketplaceRevocation[]>;
writeRevocations(values: readonly MarketplaceRevocation[]): Promise<void>;
}
const requestResult = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => {
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error ?? new Error("IndexedDB request failed.")), { once: true });
});
const transactionDone = (transaction: IDBTransaction) => new Promise<void>((resolve, reject) => {
transaction.addEventListener("complete", () => resolve(), { once: true });
transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction was aborted.")), { once: true });
transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed.")), { once: true });
});
const openDatabase = () => new Promise<IDBDatabase>((resolve, reject) => {
if (typeof indexedDB === "undefined") {
reject(new Error("IndexedDB is unavailable."));
return;
}
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(REGISTRY_STORE)) {
request.result.createObjectStore(REGISTRY_STORE, { keyPath: "instanceKey" });
}
if (!request.result.objectStoreNames.contains(REVOCATION_STORE)) {
request.result.createObjectStore(REVOCATION_STORE, { keyPath: "id" });
}
});
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error ?? new Error("Community registry storage is unavailable.")), { once: true });
});
export class WebCommunityRegistryStore implements CommunityRegistryStorage {
private databasePromise: Promise<IDBDatabase> | null = null;
private database() {
this.databasePromise ??= openDatabase();
return this.databasePromise;
}
async readRegistry(instanceKey: string) {
const database = await this.database();
const transaction = database.transaction(REGISTRY_STORE, "readonly");
const value = await requestResult(transaction.objectStore(REGISTRY_STORE).get(instanceKey)) as CachedCommunityRegistry | undefined;
await transactionDone(transaction);
return value ?? null;
}
async writeRegistry(value: CachedCommunityRegistry) {
const database = await this.database();
const transaction = database.transaction(REGISTRY_STORE, "readwrite");
transaction.objectStore(REGISTRY_STORE).put(value);
await transactionDone(transaction);
}
async readRevocations() {
const database = await this.database();
const transaction = database.transaction(REVOCATION_STORE, "readonly");
const values = await requestResult(transaction.objectStore(REVOCATION_STORE).getAll()) as MarketplaceRevocation[];
await transactionDone(transaction);
return values;
}
async writeRevocations(values: readonly MarketplaceRevocation[]) {
const database = await this.database();
const transaction = database.transaction(REVOCATION_STORE, "readwrite");
const store = transaction.objectStore(REVOCATION_STORE);
store.clear();
for (const value of values) store.put(value);
await transactionDone(transaction);
}
}
export const createMemoryCommunityRegistryStorage = (): CommunityRegistryStorage => {
const registries = new Map<string, CachedCommunityRegistry>();
let revocations: MarketplaceRevocation[] = [];
return {
readRegistry: async (instanceKey) => registries.get(instanceKey) ?? null,
writeRegistry: async (value) => {
registries.set(value.instanceKey, value);
},
readRevocations: async () => revocations.map((item) => ({ ...item })),
writeRevocations: async (values) => {
revocations = values.map((item) => ({ ...item }));
},
};
};
let defaultStore: CommunityRegistryStorage | null = null;
export const communityRegistryStorage = () => {
if (!defaultStore) {
defaultStore = typeof indexedDB === "undefined"
? createMemoryCommunityRegistryStorage()
: new WebCommunityRegistryStore();
}
return defaultStore;
};
export const readRevokedPluginIds = async (storage = communityRegistryStorage()) => {
try {
const revocations = await storage.readRevocations();
return new Set(revocations.map((item) => item.id));
} catch {
return new Set<string>();
}
};
export const isPluginIdRevoked = async (pluginId: string, storage = communityRegistryStorage()) =>
(await readRevokedPluginIds(storage)).has(pluginId);
@@ -0,0 +1,225 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateKeyPairSync, sign } from "node:crypto";
import { readFileSync } from "node:fs";
import { stubUnavailableGithubInstance } from "./github-plugin-test-api.mjs";
import { createMemoryCommunityRegistryStorage } from "./community-registry-store.ts";
import { loadDisplayPluginMarketplace } from "./community-registry.ts";
import { communityPluginSubmissionIssueUrl } from "./community-plugin-submission.ts";
const bundledEntry = {
id: "org.edgeever.tasks",
name: "Tasks",
description: "Official tasks",
author: "EdgeEver",
publisher: "edgeever",
category: "Productivity",
repositoryUrl: "https://github.com/tianma-if/edgeever-tasks",
distribution: { type: "github", repositoryUrl: "https://github.com/tianma-if/edgeever-tasks" },
verification: { version: "0.6.4", checksums: { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) } },
};
const communityEntry = {
id: "com.example.readwise",
name: "Readwise",
description: "Imports highlights.",
author: "Example",
category: "Import",
repositoryUrl: "https://github.com/example/readwise",
distribution: { type: "github", repositoryUrl: "https://github.com/example/readwise" },
verification: { version: "1.2.0", checksums: { manifestJson: "c".repeat(64), mainJs: "d".repeat(64) } },
apiVersion: "2",
licenseSpdx: "MIT",
};
const registryDocument = (entries, extra = {}) => JSON.stringify({
registryVersion: "1",
updatedAt: "2026-10-05T00:00:00.000Z",
entries,
...extra,
});
const signingKey = () => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const raw = new Uint8Array(publicKey.export({ format: "der", type: "spki" })).subarray(-32);
return { raw, privateKey };
};
const signedRelease = (privateKey, text, updatedAt = "2026-10-05T00:00:00.000Z") => {
const body = text.includes("\"updatedAt\"")
? text
: text;
const bytes = new TextEncoder().encode(body);
const signature = sign(null, bytes, privateKey);
return {
bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
signatureBase64: Buffer.from(signature).toString("base64"),
updatedAt,
};
};
const bundledRequest = async (input) => {
const url = String(input);
if (url.includes("registry.json")) {
return Response.json({
registryVersion: "1",
updatedAt: "2026-10-01T00:00:00.000Z",
entries: [bundledEntry],
});
}
return new Response("missing", { status: 404 });
};
let restoreGithubInstance;
beforeAll(() => {
if (!globalThis.window) {
globalThis.window = {
location: { href: "https://app.example/index.html" },
fetch: () => Promise.reject(new Error("unexpected browser fetch")),
};
}
restoreGithubInstance = stubUnavailableGithubInstance();
});
afterAll(() => { restoreGithubInstance?.(); });
describe("signed community registry", () => {
test("merges a verified catalog and keeps it when a later signature fails", async () => {
const { raw, privateKey } = signingKey();
const storage = createMemoryCommunityRegistryStorage();
const release = signedRelease(privateKey, registryDocument([communityEntry]));
const first = await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => release,
storage,
publicKeys: [raw],
instanceKey: "https://official.example",
});
expect(first.community).toBe("refreshed");
expect(first.entries.map((entry) => entry.id)).toEqual(["org.edgeever.tasks", "com.example.readwise"]);
expect(first.entries[1]).toMatchObject({ listing: "community", licenseSpdx: "MIT" });
expect(first.entries[0].repositoryUrl).toBe(bundledEntry.repositoryUrl);
const retained = await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => ({ bytes: release.bytes, signatureBase64: Buffer.from("nope").toString("base64") }),
storage,
publicKeys: [raw],
instanceKey: "https://official.example",
});
expect(retained.community).toBe("retained");
expect(retained.entries.map((entry) => entry.id)).toEqual(["org.edgeever.tasks", "com.example.readwise"]);
});
test("drops the community catalog when the instance has not configured it", async () => {
const { raw, privateKey } = signingKey();
const storage = createMemoryCommunityRegistryStorage();
const release = signedRelease(privateKey, registryDocument([communityEntry]));
await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => release,
storage,
publicKeys: [raw],
instanceKey: "https://official.example",
});
const disabled = await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => null,
storage,
publicKeys: [raw],
instanceKey: "https://self-hosted.example",
});
expect(disabled.community).toBe("disabled");
expect(disabled.entries.map((entry) => entry.id)).toEqual(["org.edgeever.tasks"]);
});
test("does not let an older signed file replace the cached catalog", async () => {
const { raw, privateKey } = signingKey();
const storage = createMemoryCommunityRegistryStorage();
const newer = signedRelease(privateKey, registryDocument([communityEntry], { updatedAt: "2026-10-06T00:00:00.000Z" }));
const older = signedRelease(
privateKey,
registryDocument([{ ...communityEntry, id: "com.example.other", name: "Other" }], { updatedAt: "2026-10-04T00:00:00.000Z" }),
);
await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => newer,
storage,
publicKeys: [raw],
instanceKey: "https://official.example",
});
const result = await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => older,
storage,
publicKeys: [raw],
instanceKey: "https://official.example",
});
expect(result.community).toBe("cached");
expect(result.entries.map((entry) => entry.id)).toEqual(["org.edgeever.tasks", "com.example.readwise"]);
expect((await storage.readRegistry("https://official.example"))?.updatedAt).toBe("2026-10-06T00:00:00.000Z");
});
test("remembers a revocation after a later signed file omits it", async () => {
const { raw, privateKey } = signingKey();
const storage = createMemoryCommunityRegistryStorage();
const revoked = signedRelease(privateKey, registryDocument([], {
revocations: [{ id: "com.example.readwise", reason: "仓库已转为私有", revokedAt: "2026-10-05T00:00:00.000Z" }],
}));
const first = await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => revoked,
storage,
publicKeys: [raw],
instanceKey: "https://official.example",
});
const returned = signedRelease(privateKey, registryDocument([communityEntry], { updatedAt: "2026-10-07T00:00:00.000Z" }));
const second = await loadDisplayPluginMarketplace({
refreshCommunity: true,
request: bundledRequest,
fetchCommunityRegistry: async () => returned,
storage,
publicKeys: [raw],
instanceKey: "https://official.example",
});
expect(first.revocations.map((item) => item.id)).toEqual(["com.example.readwise"]);
expect(second.entries.map((entry) => entry.id)).toEqual(["org.edgeever.tasks"]);
expect(second.revocations.map((item) => item.id)).toEqual(["com.example.readwise"]);
});
test("keeps startup official sync on the bundled registry", () => {
const host = readFileSync(new URL("./plugin-host.ts", import.meta.url), "utf8");
const workspace = readFileSync(new URL("../../components/WorkspaceApp.tsx", import.meta.url), "utf8");
expect(host).toContain("loadResolvedPluginMarketplace");
expect(host).not.toContain("loadDisplayPluginMarketplace");
expect(host).not.toContain("getCommunityPluginRegistry");
expect(workspace).toContain("loadResolvedPluginMarketplace");
expect(workspace).not.toContain("loadDisplayPluginMarketplace");
expect(workspace).not.toContain("getCommunityPluginRegistry");
});
});
describe("community submission precheck", () => {
test("opens an issue template with the repository and plugin id", () => {
const url = new URL(communityPluginSubmissionIssueUrl("com.example.readwise", "https://github.com/example/readwise"));
expect(url.origin + url.pathname).toBe("https://github.com/tianma-if/edgeever-plugins/issues/new");
expect(url.searchParams.get("template")).toBe("plugin-submission.yml");
expect(url.searchParams.get("plugin_id")).toBe("com.example.readwise");
expect(url.searchParams.get("repository_url")).toBe("https://github.com/example/readwise");
const source = readFileSync(new URL("./community-plugin-submission.ts", import.meta.url), "utf8");
expect(source).toContain("downloadGithubExtension");
expect(source).not.toContain("license");
expect(source).not.toContain("/git/trees/");
});
});
@@ -0,0 +1,188 @@
import {
mergeCommunityMarketplace,
parseMarketplaceRegistry,
rememberMarketplaceRevocations,
type MarketplaceRegistry,
type MarketplaceRevocation,
} from "@edgeever/plugin-api";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import {
communityRegistryPublicKeys,
decodeCommunityRegistryKey,
verifyCommunityRegistrySignature,
} from "@/lib/plugins/community-registry-keys";
import {
communityRegistryStorage,
type CommunityRegistryStorage,
} from "@/lib/plugins/community-registry-store";
import {
loadResolvedPluginMarketplace,
type ResolvedPluginMarketplace,
} from "@/lib/plugins/plugin-marketplace";
import type { GithubAssetDownloader } from "@/lib/plugins/github-plugin-distribution";
export interface SignedCommunityRegistry {
bytes: ArrayBuffer;
signatureBase64: string;
}
export type CommunityCatalogStatus = "disabled" | "cached" | "refreshed" | "retained";
export interface DisplayPluginMarketplace extends ResolvedPluginMarketplace {
revocations: MarketplaceRevocation[];
community: CommunityCatalogStatus;
}
const textDecoder = new TextDecoder();
export const communityRegistryInstanceKey = () => {
if (typeof window === "undefined") return "default";
const configured = getConfiguredDesktopApiBaseUrl();
return configured || window.location.origin;
};
export const encodeRegistryBytes = (bytes: Uint8Array) => {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
};
const isNewerRegistryTimestamp = (candidate: string, current: string | null) => {
if (!current) return true;
const next = Date.parse(candidate);
const previous = Date.parse(current);
if (Number.isNaN(next) || Number.isNaN(previous)) return false;
return next > previous;
};
const readRevocationsSafely = async (storage: CommunityRegistryStorage) => {
try {
return await storage.readRevocations();
} catch {
return [];
}
};
const readVerifiedCachedRegistry = async (
storage: CommunityRegistryStorage,
instanceKey: string,
publicKeys: readonly Uint8Array[],
) => {
try {
const cached = await storage.readRegistry(instanceKey);
if (!cached) return null;
const bytes = decodeCommunityRegistryKey(cached.registryBase64);
const signature = decodeCommunityRegistryKey(cached.signatureBase64);
if (!bytes || !signature) return null;
if (!await verifyCommunityRegistrySignature(bytes, signature, publicKeys)) return null;
return parseMarketplaceRegistry(JSON.parse(textDecoder.decode(bytes)) as unknown);
} catch {
return null;
}
};
const composeDisplayMarketplace = (
bundled: ResolvedPluginMarketplace,
community: MarketplaceRegistry | null,
revocations: readonly MarketplaceRevocation[],
communityStatus: CommunityCatalogStatus,
): DisplayPluginMarketplace => {
const revokedIds = new Set(revocations.map((item) => item.id));
const merged = mergeCommunityMarketplace(bundled, community, revokedIds);
const updatedAt = [bundled.updatedAt, community?.updatedAt]
.filter((value): value is string => Boolean(value))
.sort()
.at(-1) ?? bundled.updatedAt;
return {
...bundled,
entries: merged.entries,
updatedAt,
revocations: [...revocations],
community: communityStatus,
};
};
const fetchCommunityRegistryFromInstance = () => api.getCommunityPluginRegistry();
export const loadDisplayPluginMarketplace = async ({
refreshCommunity = false,
request,
downloadAssetBytes,
fetchCommunityRegistry = fetchCommunityRegistryFromInstance,
storage = communityRegistryStorage(),
publicKeys = communityRegistryPublicKeys(),
instanceKey = communityRegistryInstanceKey(),
}: {
refreshCommunity?: boolean;
request?: typeof fetch;
downloadAssetBytes?: GithubAssetDownloader;
fetchCommunityRegistry?: () => Promise<SignedCommunityRegistry | null>;
storage?: CommunityRegistryStorage;
publicKeys?: readonly Uint8Array[];
instanceKey?: string;
} = {}): Promise<DisplayPluginMarketplace> => {
const bundled = await loadResolvedPluginMarketplace(undefined, request, downloadAssetBytes);
const revocations = await readRevocationsSafely(storage);
const cached = () => readVerifiedCachedRegistry(storage, instanceKey, publicKeys);
if (!refreshCommunity) {
const community = await cached();
return composeDisplayMarketplace(bundled, community, revocations, community ? "cached" : "disabled");
}
let remote: SignedCommunityRegistry | null;
try {
remote = await fetchCommunityRegistry();
} catch {
remote = null;
const community = await cached();
return composeDisplayMarketplace(bundled, community, revocations, "retained");
}
if (!remote) {
return composeDisplayMarketplace(bundled, null, revocations, "disabled");
}
const bytes = new Uint8Array(remote.bytes);
const signature = decodeCommunityRegistryKey(remote.signatureBase64);
const signatureValid = signature
? await verifyCommunityRegistrySignature(bytes, signature, publicKeys)
: false;
if (!signatureValid || !signature) {
const community = await cached();
return composeDisplayMarketplace(bundled, community, revocations, "retained");
}
let parsed: MarketplaceRegistry;
try {
parsed = parseMarketplaceRegistry(JSON.parse(textDecoder.decode(bytes)) as unknown);
} catch {
const community = await cached();
return composeDisplayMarketplace(bundled, community, revocations, "retained");
}
let cachedRecord: Awaited<ReturnType<CommunityRegistryStorage["readRegistry"]>> = null;
try {
cachedRecord = await storage.readRegistry(instanceKey);
} catch {
cachedRecord = null;
}
if (!isNewerRegistryTimestamp(parsed.updatedAt, cachedRecord?.updatedAt ?? null)) {
const community = await cached();
return composeDisplayMarketplace(bundled, community, revocations, "cached");
}
const nextRevocations = rememberMarketplaceRevocations(revocations, parsed.revocations ?? []);
try {
await storage.writeRevocations(nextRevocations);
await storage.writeRegistry({
instanceKey,
registryBase64: encodeRegistryBytes(bytes),
signatureBase64: remote.signatureBase64.trim(),
updatedAt: parsed.updatedAt,
});
} catch {
// The verified catalog can still be shown for this visit when storage is blocked.
}
return composeDisplayMarketplace(bundled, parsed, nextRevocations, "refreshed");
};
@@ -94,6 +94,10 @@ describe("plugin catalog items", () => {
expect(getPluginCatalogSourceKey(official)).toBe("official");
expect(getPluginCatalogSourceKey(community)).toBe("verified");
expect(getPluginCatalogSourceKey({
id: "listed",
marketplaceEntry: marketplaceEntry({ id: "com.example.tasks", publisher: undefined, listing: "community" }),
})).toBe("community");
expect(getPluginCatalogSourceKey(github)).toBe("github");
});
+2 -1
View File
@@ -7,7 +7,7 @@ export interface PluginCatalogItem {
extension?: InstalledExtension;
}
export type PluginCatalogSourceKey = "official" | "verified" | "github" | "manifest" | "marketplace";
export type PluginCatalogSourceKey = "official" | "community" | "verified" | "github" | "manifest" | "marketplace";
export const buildPluginCatalogItems = (
marketplaceEntries: readonly MarketplaceEntry[],
@@ -30,6 +30,7 @@ export const buildPluginCatalogItems = (
export const getPluginCatalogSourceKey = (item: PluginCatalogItem): PluginCatalogSourceKey | null => {
if (item.marketplaceEntry?.publisher === "edgeever") return "official";
if (item.marketplaceEntry?.listing === "community") return "community";
if (item.marketplaceEntry || item.extension?.source.verified) return "verified";
return item.extension?.source.kind ?? null;
};
+4
View File
@@ -38,6 +38,7 @@ import { markdownToDoc } from "@edgeever/shared";
import type { EdgeEverRepository } from "@/lib/repository";
import { WebPluginSecretStore, type PluginSecretStorage } from "@/lib/plugins/plugin-secret-store";
import { WebPluginPackageStore, type CachedPluginPackage, type PluginPackageStorage } from "@/lib/plugins/plugin-package-store";
import { isPluginIdRevoked } from "@/lib/plugins/community-registry-store";
import { downloadGithubExtension, downloadPinnedGithubExtension, extensionManifestsEqual, parseGithubRepositoryUrl, sha256Hex } from "@/lib/plugins/github-plugin-distribution";
import {
catalogInstallSource,
@@ -689,6 +690,9 @@ export class EdgeEverPluginHost {
source: ExtensionInstallSource,
pluginPackage: CachedPluginPackage | null,
) {
if (await isPluginIdRevoked(manifest.id)) {
throw new Error(`Extension ${manifest.id} was revoked and can no longer be installed or updated.`);
}
const previous = this.extensions.find((item) => item.manifest.id === manifest.id);
const wasActive = this.activePlugins.has(manifest.id);
if (pluginPackage) await this.packageStorage.put(pluginPackage);
@@ -150,6 +150,36 @@ describe("plugin update checks", () => {
expect(installedIds).toEqual([pluginManifest.id]);
expect(result.updated.map((update) => update.pluginId)).toEqual([pluginManifest.id]);
});
test("skips official auto-update for a revoked plugin id", async () => {
const extensions = [installed({ source: { kind: "marketplace", verified: true } })];
const entries = [{
id: pluginManifest.id,
name: pluginManifest.name,
description: "Official plugin",
author: "EdgeEver",
publisher: "edgeever",
category: "Productivity",
repositoryUrl: "https://github.com/example/official-plugin",
distribution: { type: "manifest", manifestUrl: "https://example.com/official/manifest.json" },
verification: { version: "1.2.0", checksums: { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) } },
}];
const installedIds = [];
const host = {
getSnapshot: () => ({ extensions }),
installMarketplaceEntry: async (entry) => { installedIds.push(entry.id); },
installFromGithubRepository: async () => { throw new Error("unexpected GitHub install"); },
installFromManifestUrl: async () => { throw new Error("unexpected manifest install"); },
};
const request = async () => Response.json({ ...pluginManifest, version: "1.2.0" });
const result = await updateOfficialMarketplacePlugins(host, entries, request, {
revokedIds: new Set([pluginManifest.id]),
});
expect(installedIds).toEqual([]);
expect(result.updated).toEqual([]);
});
});
describe("official plugin auto-update workspace notice", () => {
+9 -2
View File
@@ -1,5 +1,6 @@
import { parseExtensionManifest, type ExtensionManifest, type MarketplaceEntry, type PluginPermission } from "@edgeever/plugin-api";
import type { InstalledExtension } from "@/lib/plugins/plugin-host";
import { readRevokedPluginIds } from "@/lib/plugins/community-registry-store";
import { loadGithubInstallableManifest } from "@/lib/plugins/github-plugin-distribution";
import { isVersionOutdated } from "@/lib/version-check";
@@ -50,7 +51,9 @@ export const checkInstalledExtensionUpdate = async (
extension: InstalledExtension,
marketplaceEntries: MarketplaceEntry[],
request: typeof fetch = window.fetch.bind(window),
revokedIds?: ReadonlySet<string>,
): Promise<PluginUpdateInfo | null> => {
if (revokedIds?.has(extension.manifest.id)) return null;
const marketplaceEntry = extension.source.kind === "marketplace"
? marketplaceEntries.find((entry) => entry.id === extension.manifest.id)
: undefined;
@@ -89,9 +92,10 @@ export const checkPluginUpdates = async (
extensions: InstalledExtension[],
marketplaceEntries: MarketplaceEntry[],
request: typeof fetch = window.fetch.bind(window),
options?: { revokedIds?: ReadonlySet<string> },
): Promise<PluginUpdateCheckResult> => {
const settled = await Promise.allSettled(
extensions.map((extension) => checkInstalledExtensionUpdate(extension, marketplaceEntries, request))
extensions.map((extension) => checkInstalledExtensionUpdate(extension, marketplaceEntries, request, options?.revokedIds))
);
const updates: PluginUpdateInfo[] = [];
const errors: Record<string, string> = {};
@@ -130,9 +134,12 @@ export const updateOfficialMarketplacePlugins = async (
host: PluginUpdateHost,
marketplaceEntries: MarketplaceEntry[],
request: typeof fetch = window.fetch.bind(window),
options?: { revokedIds?: ReadonlySet<string> },
): Promise<OfficialPluginUpdateResult> => {
const checked = await checkPluginUpdates(host.getSnapshot().extensions, marketplaceEntries, request);
const revokedIds = options?.revokedIds ?? await readRevokedPluginIds();
const checked = await checkPluginUpdates(host.getSnapshot().extensions, marketplaceEntries, request, { revokedIds });
const officialUpdates = checked.updates.filter((update) => {
if (revokedIds.has(update.pluginId)) return false;
if (update.marketplaceEntry?.publisher !== "edgeever") return false;
if (!update.marketplaceEntry.verification.checksums?.manifestJson) return false;
return update.latestManifest.type !== "plugin" || Boolean(update.marketplaceEntry.verification.checksums.mainJs);
+1
View File
@@ -95,6 +95,7 @@ Common environment variables:
| `EDGE_EVER_SESSION_TTL_DAYS` | `400` | Login session lifetime |
| `EDGE_EVER_IDLE_TIMEOUT_SECONDS` | `120` | Bun streaming idle timeout, from 10 to 255 seconds |
| `EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY` | persisted on `/data` | Optional independent AI credential encryption key. If unset, Docker stores the authentication secret or a generated key in `/data/edgeever-secrets.json`. |
| `EDGE_EVER_COMMUNITY_REGISTRY_URL` | unset | Optional HTTPS URL of the signed community plugin catalog. Leave it unset to show only the built-in official plugins. |
For secrets, append `_FILE` to a supported variable and point it at a Docker
secret, for example `EDGE_EVER_AUTH_PASSWORD_FILE=/run/secrets/auth_password`.
+1
View File
@@ -89,6 +89,7 @@ Compose 会创建一个命名卷。所有需要在容器替换后保留的数据
| `EDGE_EVER_SESSION_TTL_DAYS` | `400` | 登录会话有效期 |
| `EDGE_EVER_IDLE_TIMEOUT_SECONDS` | `120` | Bun 流式响应空闲超时,可设为 10 到 255 秒 |
| `EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY` | 写入 `/data` | 可选的独立 AI 凭据加密密钥。未设置时,Docker 会把认证 Secret 或自动生成的密钥写入 `/data/edgeever-secrets.json`。 |
| `EDGE_EVER_COMMUNITY_REGISTRY_URL` | 不设置 | 可选。签名社区插件目录的 HTTPS 地址。不设置时,市场只显示内置官方插件。 |
Secret 可在受支持的变量名后追加 `_FILE`,并指向 Docker secret,例如
`EDGE_EVER_AUTH_PASSWORD_FILE=/run/secrets/auth_password`。密码/hash 和 S3
+2
View File
@@ -71,6 +71,8 @@ The official marketplace only lists free and open-source plugins. Complete human
The marketplace is a verified Registry and does not take ownership of plugin files. For each version, the Registry pins the plugin ID, GitHub repository, version, and SHA-256 hashes for `manifest.json`, `main.js`, and optional `styles.css`. Installation still downloads from the developer's GitHub Release or registered public URL and verifies those hashes again. The optional `"publisher": "edgeever"` marker is reserved for Registry entries maintained by the EdgeEver project; it enables automatic updates and must not be used for community submissions.
Official entries ship inside the app as `extensions/registry.json`. Community entries are appended from a separate signed catalog when the instance sets `EDGE_EVER_COMMUNITY_REGISTRY_URL`. That catalog cannot change an official repository URL or mark an entry as official. A community badge and license mean the listing passed the admission check. They do not mean EdgeEver guarantees the plugin is safe. Self-hosted instances leave the variable unset and show only the built-in official plugins. To submit a plugin, follow [tianma-if/edgeever-plugins](https://github.com/tianma-if/edgeever-plugins).
Registry format:
```json
+2
View File
@@ -71,6 +71,8 @@ https://github.com/owner/edgeever-plugin
插件市场是一个经过校验的 Registry,不接管插件所有权。Registry 为每个版本固定插件 ID、GitHub 仓库、版本号及 `manifest.json`/`main.js`/`styles.css` 的 SHA-256;安装时仍从开发者的 GitHub Release 或登记的公开地址下载,并再次核对校验和。可选的 `"publisher": "edgeever"` 标记仅保留给 EdgeEver 项目维护的 Registry 条目;它会启用自动更新,社区投稿不得使用。
官方条目随应用内置在 `extensions/registry.json`。实例设置 `EDGE_EVER_COMMUNITY_REGISTRY_URL` 后,客户端才会把另一份验签通过的社区目录追加进来。这份目录不能改官方插件的仓库地址,也不能把条目标成官方。社区标记和许可证只说明收录检查通过,不代表 EdgeEver 保证插件安全。自建实例不设置该变量时,市场只显示内置官方插件。提交插件请按 [tianma-if/edgeever-plugins](https://github.com/tianma-if/edgeever-plugins) 的说明操作。
Registry 格式:
```json
+4
View File
@@ -23,3 +23,7 @@ Code-free themes must publish their complete Manifest and use a license that per
The repository, license, source revision, and build information must remain publicly available while a version is distributed through the official marketplace. EdgeEver may reject, suspend, or remove a listing when it no longer satisfies this policy, when its release assets cannot be traced to the declared source, or when its behavior creates a security, privacy, legal, or reliability risk.
Marketplace verification means that a listing has passed the applicable admission and integrity checks at the time of review. It is not a guarantee that a plugin is secure, error-free, or suitable for every user.
## Community catalog
The community catalog uses the same admission meaning. It is published separately from the built-in official list, and a signed community file cannot take over official plugin updates. Direct installation from GitHub or a Manifest URL stays available and is not an admission check.
+4
View File
@@ -23,3 +23,7 @@ EdgeEver 官方插件市场仅收录自由及开源插件。本政策适用于
只要某个版本仍通过官方市场分发,其仓库、许可证、对应源码版本和构建信息就必须持续公开。如果插件不再满足本政策、发布资产无法追溯到声明的源码,或者其行为带来安全、隐私、法律或可靠性风险,EdgeEver 可以拒绝上架、暂停分发或下架该插件。
市场“已验证”仅表示插件在审核时通过了适用的准入与完整性检查,不代表 EdgeEver 保证插件绝对安全、没有错误或适合所有用户。
## 社区目录
社区目录沿用同样的收录含义。它与内置官方列表分开发布,签名后的社区文件也不能接管官方插件更新。直接通过 GitHub 或 Manifest 地址安装的路径保持可用,那条路径不经过收录检查。
+10
View File
@@ -1700,6 +1700,16 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
return response.text();
},
getCommunityPluginRegistry: async () => {
const { context, response } = await send("/api/v1/plugins/community-registry", undefined, { setJsonContentType: false });
if (response.status === 404) return null;
if (!response.ok) await throwRequestError(context, response, "Community registry request failed");
return {
bytes: await response.arrayBuffer(),
signatureBase64: response.headers.get("x-edgeever-community-registry-signature") ?? "",
};
},
getGithubPluginRelease: async (owner: string, repository: string, releaseTag: string) => {
const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/tags/${encodeURIComponent(releaseTag)}`;
const { context, response } = await send(path, undefined, { setJsonContentType: false });
+33
View File
@@ -130,11 +130,35 @@ export interface MarketplaceEntry {
stylesCss?: string;
};
};
/** SPDX license identifier recorded when a community plugin was admitted. */
licenseSpdx?: string;
/** 40-character commit recorded for a community plugin release. */
sourceRevision?: string;
/** Plugin API version this community entry was admitted against. */
apiVersion?: string;
/** Theme API version this community entry was admitted against. */
themeApiVersion?: string;
/** Capability snapshot used to detect a later permission expansion. */
admitted?: {
permissions: string[];
networkHosts: string[];
};
/**
* Set only by the client after a signed community registry is merged.
* Remote files cannot claim this field.
*/
listing?: "community";
}
export interface MarketplaceRevocation {
id: string;
reason: string;
revokedAt: string;
}
export interface MarketplaceRegistry {
registryVersion: typeof MARKETPLACE_REGISTRY_VERSION;
updatedAt: string;
entries: MarketplaceEntry[];
revocations?: MarketplaceRevocation[];
}
export interface PluginNoteSummary {
id: string;
@@ -587,5 +611,14 @@ export declare const defineTheme: <T extends ThemeManifest>(theme: T) => T;
/** Strips unknown fields and clamps sizes so host chrome rendering stays bounded. */
export declare const normalizePluginPanelChrome: (value: PluginPanelChrome | null | undefined) => PluginPanelChrome;
export declare const parseExtensionManifest: (value: unknown) => ExtensionManifest;
export declare const isReservedMarketplacePluginId: (id: string) => boolean;
export declare const parseMarketplaceRegistry: (value: unknown) => MarketplaceRegistry;
/**
* Overlay a signed community registry onto the bundled official list.
* Official entries, their repository URLs, and `publisher: "edgeever"` stay
* on the bundled list. A signature does not make a remote entry official.
*/
export declare const mergeCommunityMarketplace: (bundled: MarketplaceRegistry, community: MarketplaceRegistry | null, revokedIds: ReadonlySet<string>) => MarketplaceRegistry;
/** Revocation ids only grow. A later record may replace the reason, never remove the id. */
export declare const rememberMarketplaceRevocations: (existing: readonly MarketplaceRevocation[], incoming: readonly MarketplaceRevocation[]) => MarketplaceRevocation[];
export {};
+110 -2
View File
@@ -469,6 +469,66 @@ var normalizeChecksum = (value, label) => {
return value.toLocaleLowerCase();
};
var GITHUB_REPOSITORY_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/i;
var SPDX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/;
var COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/;
var API_VERSION_PATTERN = /^\d+(?:\.\d+){0,3}$/;
var NETWORK_HOST_PATTERN = /^(?:\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i;
var isReservedMarketplacePluginId = (id) => id === "org.edgeever" || id.startsWith("org.edgeever.");
var readOptionalToken = (value, pattern, label, normalize) => {
if (value === undefined)
return;
if (typeof value !== "string" || !pattern.test(value))
throw new Error(label);
return normalize ? normalize(value) : value;
};
var readAdmittedSnapshot = (value, id) => {
if (value === undefined)
return;
if (!isRecord(value) || !Array.isArray(value.permissions) || !Array.isArray(value.networkHosts)) {
throw new Error(`Marketplace entry ${id} has an invalid admission snapshot.`);
}
const allowedPermissions = new Set(PLUGIN_PERMISSIONS);
const permissions = [...new Set(value.permissions.map(String))];
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
if (unsupported)
throw new Error(`Marketplace entry ${id} has an unsupported admitted permission.`);
const networkHosts = [...new Set(value.networkHosts.map(String))];
if (networkHosts.some((host) => !NETWORK_HOST_PATTERN.test(host))) {
throw new Error(`Marketplace entry ${id} has an invalid admitted network host.`);
}
return { permissions, networkHosts };
};
var readRevocations = (value) => {
if (value === undefined)
return;
if (!Array.isArray(value))
throw new Error("Marketplace revocations must be an array.");
const ids = new Set;
return value.map((item) => {
if (!isRecord(item) || typeof item.id !== "string" || !ID_PATTERN.test(item.id)) {
throw new Error("Marketplace revocation id is invalid.");
}
if (ids.has(item.id))
throw new Error(`Duplicate marketplace revocation id: ${item.id}`);
ids.add(item.id);
if (typeof item.reason !== "string" || !item.reason.trim() || item.reason.trim().length > 500) {
throw new Error(`Marketplace revocation ${item.id} is missing a reason.`);
}
if (typeof item.revokedAt !== "string" || Number.isNaN(Date.parse(item.revokedAt))) {
throw new Error(`Marketplace revocation ${item.id} has an invalid revokedAt.`);
}
return { id: item.id, reason: item.reason.trim(), revokedAt: item.revokedAt };
});
};
var isInstallableCommunityEntry = (entry) => {
if (entry.apiVersion && entry.themeApiVersion)
return false;
if (entry.apiVersion)
return entry.apiVersion === PLUGIN_API_VERSION;
if (entry.themeApiVersion)
return entry.themeApiVersion === THEME_API_VERSION;
return false;
};
var parseMarketplaceRegistry = (value) => {
if (!isRecord(value) || value.registryVersion !== MARKETPLACE_REGISTRY_VERSION || !Array.isArray(value.entries)) {
throw new Error("Unsupported marketplace registry format.");
@@ -518,6 +578,11 @@ var parseMarketplaceRegistry = (value) => {
})();
if (!checksums?.manifestJson)
throw new Error(`Marketplace entry ${item.id} must pin the manifest.json checksum.`);
const licenseSpdx = readOptionalToken(item.licenseSpdx, SPDX_PATTERN, `Marketplace entry ${item.id} has an invalid SPDX license.`);
const sourceRevision = readOptionalToken(item.sourceRevision, COMMIT_SHA_PATTERN, `Marketplace entry ${item.id} has an invalid source revision.`, (revision) => revision.toLocaleLowerCase());
const apiVersion = readOptionalToken(item.apiVersion, API_VERSION_PATTERN, `Marketplace entry ${item.id} has an invalid apiVersion.`);
const themeApiVersion = readOptionalToken(item.themeApiVersion, API_VERSION_PATTERN, `Marketplace entry ${item.id} has an invalid themeApiVersion.`);
const admitted = readAdmittedSnapshot(item.admitted, item.id);
return {
id: item.id,
name: name.trim(),
@@ -528,15 +593,58 @@ var parseMarketplaceRegistry = (value) => {
category: category.trim(),
repositoryUrl: repositoryUrl.trim(),
distribution,
verification: { version: item.verification.version, checksums }
verification: { version: item.verification.version, checksums },
...licenseSpdx ? { licenseSpdx } : {},
...sourceRevision ? { sourceRevision } : {},
...apiVersion ? { apiVersion } : {},
...themeApiVersion ? { themeApiVersion } : {},
...admitted ? { admitted } : {}
};
});
return { registryVersion: MARKETPLACE_REGISTRY_VERSION, updatedAt: value.updatedAt, entries };
const revocations = readRevocations(value.revocations);
return {
registryVersion: MARKETPLACE_REGISTRY_VERSION,
updatedAt: value.updatedAt,
entries,
...revocations ? { revocations } : {}
};
};
var mergeCommunityMarketplace = (bundled, community, revokedIds) => {
const bundledIds = new Set(bundled.entries.map((entry) => entry.id));
const entries = bundled.entries.filter((entry) => !revokedIds.has(entry.id));
for (const entry of community?.entries ?? []) {
if (entry.publisher)
continue;
if (isReservedMarketplacePluginId(entry.id) || bundledIds.has(entry.id) || revokedIds.has(entry.id))
continue;
if (entry.distribution.type !== "github" || !isInstallableCommunityEntry(entry))
continue;
const { publisher, listing, ...rest } = entry;
entries.push({ ...rest, listing: "community" });
}
return {
registryVersion: bundled.registryVersion,
updatedAt: bundled.updatedAt,
entries,
...bundled.revocations ? { revocations: bundled.revocations } : {}
};
};
var rememberMarketplaceRevocations = (existing, incoming) => {
const byId = new Map(existing.map((item) => [item.id, item]));
for (const item of incoming) {
const current = byId.get(item.id);
if (!current || Date.parse(item.revokedAt) >= Date.parse(current.revokedAt))
byId.set(item.id, item);
}
return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id));
};
export {
rememberMarketplaceRevocations,
parseMarketplaceRegistry,
parseExtensionManifest,
normalizePluginPanelChrome,
mergeCommunityMarketplace,
isReservedMarketplacePluginId,
defineTheme,
definePlugin,
THEME_TOKEN_NAMES,
+115 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test";
import { normalizePluginPanelChrome, parseExtensionManifest, parseMarketplaceRegistry } from "./index.ts";
import {
mergeCommunityMarketplace,
normalizePluginPanelChrome,
parseExtensionManifest,
parseMarketplaceRegistry,
rememberMarketplaceRevocations,
} from "./index.ts";
describe("extension manifests", () => {
test("normalizes a plugin manifest", () => {
@@ -294,6 +300,114 @@ describe("marketplace registry", () => {
};
expect(() => parseMarketplaceRegistry({ registryVersion: "1", updatedAt: "2026-08-16T00:00:00Z", entries: [entry, entry] })).toThrow("Duplicate");
});
test("keeps community admission fields and ignores a remote listing claim", () => {
const registry = parseMarketplaceRegistry({
registryVersion: "1",
updatedAt: "2026-10-05T00:00:00.000Z",
entries: [{
id: "com.example.readwise",
name: "Readwise",
description: "Imports highlights.",
author: "Example",
category: "Import",
repositoryUrl: "https://github.com/example/readwise",
distribution: { type: "github", repositoryUrl: "https://github.com/example/readwise" },
verification: { version: "1.2.0", checksums: { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) } },
licenseSpdx: "MIT",
sourceRevision: "a".repeat(40),
apiVersion: "2",
admitted: { permissions: ["notes:write"], networkHosts: ["readwise.io"] },
listing: "community",
publisher: undefined,
}],
revocations: [{ id: "com.example.old", reason: "仓库已转为私有", revokedAt: "2026-10-05T00:00:00.000Z" }],
});
expect(registry.entries[0]).toMatchObject({
licenseSpdx: "MIT",
sourceRevision: "a".repeat(40),
apiVersion: "2",
admitted: { permissions: ["notes:write"], networkHosts: ["readwise.io"] },
});
expect(registry.entries[0]).not.toHaveProperty("listing");
expect(registry.entries[0]).not.toHaveProperty("publisher");
expect(registry.revocations).toEqual([
{ id: "com.example.old", reason: "仓库已转为私有", revokedAt: "2026-10-05T00:00:00.000Z" },
]);
});
});
describe("community marketplace merge", () => {
const bundledEntry = {
id: "org.edgeever.tasks",
name: "Tasks",
description: "Official tasks",
author: "EdgeEver",
publisher: "edgeever",
category: "Productivity",
repositoryUrl: "https://github.com/tianma-if/edgeever-tasks",
distribution: { type: "github", repositoryUrl: "https://github.com/tianma-if/edgeever-tasks" },
verification: { version: "0.6.4", checksums: { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) } },
};
const communityEntry = {
id: "com.example.readwise",
name: "Readwise",
description: "Imports highlights.",
author: "Example",
category: "Import",
repositoryUrl: "https://github.com/example/readwise",
distribution: { type: "github", repositoryUrl: "https://github.com/example/readwise" },
verification: { version: "1.2.0", checksums: { manifestJson: "c".repeat(64), mainJs: "d".repeat(64) } },
apiVersion: "2",
licenseSpdx: "MIT",
};
const bundled = parseMarketplaceRegistry({
registryVersion: "1",
updatedAt: "2026-10-01T00:00:00.000Z",
entries: [bundledEntry],
});
test("keeps the bundled entry when a signed file repeats its id or claims the official publisher", () => {
const community = parseMarketplaceRegistry({
registryVersion: "1",
updatedAt: "2026-10-05T00:00:00.000Z",
entries: [
communityEntry,
{ ...communityEntry, id: "org.edgeever.tasks", apiVersion: "2" },
{ ...communityEntry, id: "com.example.official", publisher: "edgeever", apiVersion: "2" },
{ ...communityEntry, id: "org.edgeever.plugins.extra", apiVersion: "2" },
{ ...communityEntry, id: "com.example.future", apiVersion: "9" },
{
...communityEntry,
id: "com.example.manifest",
distribution: { type: "manifest", manifestUrl: "https://example.com/manifest.json" },
},
],
});
const merged = mergeCommunityMarketplace(bundled, community, new Set());
expect(merged.entries.map((entry) => entry.id)).toEqual(["org.edgeever.tasks", "com.example.readwise"]);
expect(merged.entries[0]).toMatchObject({ publisher: "edgeever", repositoryUrl: bundledEntry.repositoryUrl });
expect(merged.entries[1]).toMatchObject({ listing: "community", licenseSpdx: "MIT" });
expect(merged.entries[1]).not.toHaveProperty("publisher");
});
test("hides revoked ids and remembers them after a later file omits the revocation", () => {
const community = parseMarketplaceRegistry({
registryVersion: "1",
updatedAt: "2026-10-05T00:00:00.000Z",
entries: [communityEntry],
revocations: [{ id: "com.example.readwise", reason: "仓库已转为私有", revokedAt: "2026-10-05T00:00:00.000Z" }],
});
const remembered = rememberMarketplaceRevocations([], community.revocations);
const merged = mergeCommunityMarketplace(bundled, community, new Set(remembered.map((item) => item.id)));
const later = rememberMarketplaceRevocations(remembered, []);
expect(merged.entries.map((entry) => entry.id)).toEqual(["org.edgeever.tasks"]);
expect(later.map((item) => item.id)).toEqual(["com.example.readwise"]);
});
});
describe("panel chrome", () => {
+150 -1
View File
@@ -163,12 +163,37 @@ export interface MarketplaceEntry {
stylesCss?: string;
};
};
/** SPDX license identifier recorded when a community plugin was admitted. */
licenseSpdx?: string;
/** 40-character commit recorded for a community plugin release. */
sourceRevision?: string;
/** Plugin API version this community entry was admitted against. */
apiVersion?: string;
/** Theme API version this community entry was admitted against. */
themeApiVersion?: string;
/** Capability snapshot used to detect a later permission expansion. */
admitted?: {
permissions: string[];
networkHosts: string[];
};
/**
* Set only by the client after a signed community registry is merged.
* Remote files cannot claim this field.
*/
listing?: "community";
}
export interface MarketplaceRevocation {
id: string;
reason: string;
revokedAt: string;
}
export interface MarketplaceRegistry {
registryVersion: typeof MARKETPLACE_REGISTRY_VERSION;
updatedAt: string;
entries: MarketplaceEntry[];
revocations?: MarketplaceRevocation[];
}
export interface PluginNoteSummary {
@@ -963,6 +988,67 @@ const normalizeChecksum = (value: unknown, label: string) => {
return value.toLocaleLowerCase();
};
const GITHUB_REPOSITORY_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/i;
const SPDX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/;
const API_VERSION_PATTERN = /^\d+(?:\.\d+){0,3}$/;
const NETWORK_HOST_PATTERN = /^(?:\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i;
export const isReservedMarketplacePluginId = (id: string) =>
id === "org.edgeever" || id.startsWith("org.edgeever.");
const readOptionalToken = (
value: unknown,
pattern: RegExp,
label: string,
normalize?: (value: string) => string,
) => {
if (value === undefined) return undefined;
if (typeof value !== "string" || !pattern.test(value)) throw new Error(label);
return normalize ? normalize(value) : value;
};
const readAdmittedSnapshot = (value: unknown, id: string): MarketplaceEntry["admitted"] => {
if (value === undefined) return undefined;
if (!isRecord(value) || !Array.isArray(value.permissions) || !Array.isArray(value.networkHosts)) {
throw new Error(`Marketplace entry ${id} has an invalid admission snapshot.`);
}
const allowedPermissions = new Set<string>(PLUGIN_PERMISSIONS);
const permissions = [...new Set(value.permissions.map(String))];
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
if (unsupported) throw new Error(`Marketplace entry ${id} has an unsupported admitted permission.`);
const networkHosts = [...new Set(value.networkHosts.map(String))];
if (networkHosts.some((host) => !NETWORK_HOST_PATTERN.test(host))) {
throw new Error(`Marketplace entry ${id} has an invalid admitted network host.`);
}
return { permissions, networkHosts };
};
const readRevocations = (value: unknown): MarketplaceRevocation[] | undefined => {
if (value === undefined) return undefined;
if (!Array.isArray(value)) throw new Error("Marketplace revocations must be an array.");
const ids = new Set<string>();
return value.map((item) => {
if (!isRecord(item) || typeof item.id !== "string" || !ID_PATTERN.test(item.id)) {
throw new Error("Marketplace revocation id is invalid.");
}
if (ids.has(item.id)) throw new Error(`Duplicate marketplace revocation id: ${item.id}`);
ids.add(item.id);
if (typeof item.reason !== "string" || !item.reason.trim() || item.reason.trim().length > 500) {
throw new Error(`Marketplace revocation ${item.id} is missing a reason.`);
}
if (typeof item.revokedAt !== "string" || Number.isNaN(Date.parse(item.revokedAt))) {
throw new Error(`Marketplace revocation ${item.id} has an invalid revokedAt.`);
}
return { id: item.id, reason: item.reason.trim(), revokedAt: item.revokedAt };
});
};
const isInstallableCommunityEntry = (entry: MarketplaceEntry) => {
if (entry.apiVersion && entry.themeApiVersion) return false;
if (entry.apiVersion) return entry.apiVersion === PLUGIN_API_VERSION;
if (entry.themeApiVersion) return entry.themeApiVersion === THEME_API_VERSION;
return false;
};
export const parseMarketplaceRegistry = (value: unknown): MarketplaceRegistry => {
if (!isRecord(value) || value.registryVersion !== MARKETPLACE_REGISTRY_VERSION || !Array.isArray(value.entries)) {
@@ -1011,6 +1097,16 @@ export const parseMarketplaceRegistry = (value: unknown): MarketplaceRegistry =>
})) as MarketplaceEntry["verification"]["checksums"]
: (() => { throw new Error(`Marketplace entry ${item.id} checksums must be an object.`); })();
if (!checksums?.manifestJson) throw new Error(`Marketplace entry ${item.id} must pin the manifest.json checksum.`);
const licenseSpdx = readOptionalToken(item.licenseSpdx, SPDX_PATTERN, `Marketplace entry ${item.id} has an invalid SPDX license.`);
const sourceRevision = readOptionalToken(
item.sourceRevision,
COMMIT_SHA_PATTERN,
`Marketplace entry ${item.id} has an invalid source revision.`,
(revision) => revision.toLocaleLowerCase(),
);
const apiVersion = readOptionalToken(item.apiVersion, API_VERSION_PATTERN, `Marketplace entry ${item.id} has an invalid apiVersion.`);
const themeApiVersion = readOptionalToken(item.themeApiVersion, API_VERSION_PATTERN, `Marketplace entry ${item.id} has an invalid themeApiVersion.`);
const admitted = readAdmittedSnapshot(item.admitted, item.id);
return {
id: item.id,
name: name.trim(),
@@ -1022,7 +1118,60 @@ export const parseMarketplaceRegistry = (value: unknown): MarketplaceRegistry =>
repositoryUrl: repositoryUrl.trim(),
distribution,
verification: { version: item.verification.version, checksums },
...(licenseSpdx ? { licenseSpdx } : {}),
...(sourceRevision ? { sourceRevision } : {}),
...(apiVersion ? { apiVersion } : {}),
...(themeApiVersion ? { themeApiVersion } : {}),
...(admitted ? { admitted } : {}),
};
});
return { registryVersion: MARKETPLACE_REGISTRY_VERSION, updatedAt: value.updatedAt, entries };
const revocations = readRevocations(value.revocations);
return {
registryVersion: MARKETPLACE_REGISTRY_VERSION,
updatedAt: value.updatedAt,
entries,
...(revocations ? { revocations } : {}),
};
};
/**
* Overlay a signed community registry onto the bundled official list.
* Official entries, their repository URLs, and `publisher: "edgeever"` stay
* on the bundled list. A signature does not make a remote entry official.
*/
export const mergeCommunityMarketplace = (
bundled: MarketplaceRegistry,
community: MarketplaceRegistry | null,
revokedIds: ReadonlySet<string>,
): MarketplaceRegistry => {
const bundledIds = new Set(bundled.entries.map((entry) => entry.id));
const entries = bundled.entries.filter((entry) => !revokedIds.has(entry.id));
for (const entry of community?.entries ?? []) {
if (entry.publisher) continue;
if (isReservedMarketplacePluginId(entry.id) || bundledIds.has(entry.id) || revokedIds.has(entry.id)) continue;
if (entry.distribution.type !== "github" || !isInstallableCommunityEntry(entry)) continue;
const { publisher, listing, ...rest } = entry;
void publisher;
void listing;
entries.push({ ...rest, listing: "community" });
}
return {
registryVersion: bundled.registryVersion,
updatedAt: bundled.updatedAt,
entries,
...(bundled.revocations ? { revocations: bundled.revocations } : {}),
};
};
/** Revocation ids only grow. A later record may replace the reason, never remove the id. */
export const rememberMarketplaceRevocations = (
existing: readonly MarketplaceRevocation[],
incoming: readonly MarketplaceRevocation[],
): MarketplaceRevocation[] => {
const byId = new Map(existing.map((item) => [item.id, item]));
for (const item of incoming) {
const current = byId.get(item.id);
if (!current || Date.parse(item.revokedAt) >= Date.parse(current.revokedAt)) byId.set(item.id, item);
}
return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id));
};
+12
View File
@@ -1015,6 +1015,7 @@ export const enUS = {
panelDescription: "This interface is provided by an enabled trusted plugin.",
sources: {
verified: "Verified",
community: "Community",
github: "GitHub sideload",
manifest: "Manifest sideload",
marketplace: "Marketplace install",
@@ -1028,6 +1029,17 @@ export const enUS = {
officialAutoUpdate: "Official · auto-updates",
installVerified: "Replace with verified version",
openRepository: "Open the source repository for {{name}}",
license: "License {{spdx}}",
admissionNotice: "Passed the admission check. This is not a safety guarantee.",
revoked: "This plugin was removed from the catalog: {{reason}}. Disable it.",
submissionTitle: "Submit a community plugin",
submissionDocs: "Community plugin repository",
submissionHint: "This precheck only confirms that the current client can read a public repository Manifest and Release. License and source checks run in the community repository.",
submissionRepository: "GitHub repository URL to submit",
precheck: "Precheck",
precheckRunning: "Prechecking…",
precheckPassed: "Precheck passed. Add plugins/{{id}}.json in the community repository, using this same repository URL and plugin ID.",
openSubmission: "Open the submission instructions",
},
toolbar: {
open: "Plugins",
+12
View File
@@ -1015,6 +1015,7 @@ export const ja = {
panelDescription: "この画面は、有効な信頼済みプラグインが提供します。",
sources: {
verified: "検証済み",
community: "コミュニティ",
github: "GitHub サイドロード",
manifest: "Manifest サイドロード",
marketplace: "マーケットプレイス導入",
@@ -1028,6 +1029,17 @@ export const ja = {
officialAutoUpdate: "公式 · 自動更新",
installVerified: "検証済みバージョンに置き換え",
openRepository: "{{name}} のソースリポジトリを開く",
license: "ライセンス {{spdx}}",
admissionNotice: "収録時の確認であり、安全性の保証ではありません。",
revoked: "このプラグインはカタログから削除されました:{{reason}}。無効にしてください。",
submissionTitle: "コミュニティプラグインを提出",
submissionDocs: "コミュニティプラグインのリポジトリ",
submissionHint: "この事前確認は、公開リポジトリの Manifest と Release を現在のクライアントが読めることだけを見ます。ライセンスとソースの確認はコミュニティリポジトリで行います。",
submissionRepository: "提出する GitHub リポジトリの URL",
precheck: "事前確認",
precheckRunning: "事前確認中…",
precheckPassed: "事前確認に通りました。コミュニティリポジトリに plugins/{{id}}.json を追加し、リポジトリ URL とプラグイン ID を一致させてください。",
openSubmission: "提出手順を開く",
},
toolbar: {
open: "プラグイン",
+12
View File
@@ -1015,6 +1015,7 @@ export const zhCN = {
panelDescription: "此界面由已启用的受信任插件提供。",
sources: {
verified: "已验证",
community: "社区",
github: "GitHub 自由安装",
manifest: "Manifest 自由安装",
marketplace: "市场安装",
@@ -1028,6 +1029,17 @@ export const zhCN = {
officialAutoUpdate: "官方 · 自动更新",
installVerified: "替换为验证版本",
openRepository: "打开 {{name}} 的源代码仓库",
license: "许可证 {{spdx}}",
admissionNotice: "收录检查通过,不代表安全保证。",
revoked: "此插件已下架:{{reason}}。请停用它。",
submissionTitle: "提交社区插件",
submissionDocs: "社区插件仓库说明",
submissionHint: "预检只确认当前客户端能读取公开仓库的 Manifest 和 Release。许可证与源码检查在社区仓库的校验脚本里完成。",
submissionRepository: "要提交的 GitHub 仓库地址",
precheck: "预检",
precheckRunning: "正在预检…",
precheckPassed: "预检通过。请在社区仓库新增 plugins/{{id}}.json,并让其中的仓库地址和插件 ID 与这里一致。",
openSubmission: "打开提交说明",
},
toolbar: {
open: "插件",
+1
View File
@@ -337,6 +337,7 @@ const runtimeVars = {
// Persistent local development uses real sessions. Only the isolated local
// demo stays auth-free; remote deployments always fail closed.
EDGE_EVER_ALLOW_UNAUTHENTICATED: isLocalCommand ? String(envValue("LOCAL_DEMO_SEED") === "true") : undefined,
EDGE_EVER_COMMUNITY_REGISTRY_URL: envValue("COMMUNITY_REGISTRY_URL"),
};
const runtimeVarLines = Object.entries(runtimeVars)
.filter(([, value]) => Boolean(value))
+1
View File
@@ -101,6 +101,7 @@ const env = {
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY_PREVIOUS: runtimeEnvironment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY_PREVIOUS,
EDGE_EVER_DEMO_MODE: runtimeEnvironment.EDGE_EVER_DEMO_MODE,
EDGE_EVER_ALLOW_UNAUTHENTICATED: runtimeEnvironment.EDGE_EVER_ALLOW_UNAUTHENTICATED,
EDGE_EVER_COMMUNITY_REGISTRY_URL: runtimeEnvironment.EDGE_EVER_COMMUNITY_REGISTRY_URL,
};
const executionContext = {