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:
1 parent
40321fe1f8
commit
be7bbe25e6
32 files changed
+1524
-23
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -23,3 +23,7 @@ EdgeEver 官方插件市场仅收录自由及开源插件。本政策适用于
|
||||
只要某个版本仍通过官方市场分发,其仓库、许可证、对应源码版本和构建信息就必须持续公开。如果插件不再满足本政策、发布资产无法追溯到声明的源码,或者其行为带来安全、隐私、法律或可靠性风险,EdgeEver 可以拒绝上架、暂停分发或下架该插件。
|
||||
|
||||
市场“已验证”仅表示插件在审核时通过了适用的准入与完整性检查,不代表 EdgeEver 保证插件绝对安全、没有错误或适合所有用户。
|
||||
|
||||
## 社区目录
|
||||
|
||||
社区目录沿用同样的收录含义。它与内置官方列表分开发布,签名后的社区文件也不能接管官方插件更新。直接通过 GitHub 或 Manifest 地址安装的路径保持可用,那条路径不经过收录检查。
|
||||
@@ -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 });
|
||||
|
||||
Vendored
+33
@@ -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 {};
|
||||
Vendored
+110
-2
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "プラグイン",
|
||||
|
||||
@@ -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: "插件",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user