From be7bbe25e6b1d888bb7bb923ef06d1b3556abe97 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 6 Oct 2026 18:38:19 +0800 Subject: [PATCH] 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. --- apps/api/src/api-context.ts | 2 + .../api/src/community-registry-proxy.test.mjs | 50 ++++ apps/api/src/community-registry-proxy.ts | 97 ++++++++ apps/api/src/plugin-distribution-routes.ts | 29 +++ .../components/plugins/PluginCatalogCard.tsx | 22 +- .../components/settings/PluginManagerCard.tsx | 190 ++++++++++++++- .../plugins/community-plugin-submission.ts | 35 +++ .../lib/plugins/community-registry-keys.ts | 55 +++++ .../lib/plugins/community-registry-store.ts | 128 ++++++++++ .../lib/plugins/community-registry.test.mjs | 225 ++++++++++++++++++ .../web/src/lib/plugins/community-registry.ts | 188 +++++++++++++++ .../src/lib/plugins/plugin-catalog.test.mjs | 4 + apps/web/src/lib/plugins/plugin-catalog.ts | 3 +- apps/web/src/lib/plugins/plugin-host.ts | 4 + .../src/lib/plugins/plugin-updates.test.mjs | 30 +++ apps/web/src/lib/plugins/plugin-updates.ts | 11 +- docs/deploy-docker.md | 1 + docs/deploy-docker.zh-CN.md | 1 + docs/plugin-development.md | 2 + docs/plugin-development.zh-CN.md | 2 + docs/plugin-marketplace-policy.md | 4 + docs/plugin-marketplace-policy.zh-CN.md | 4 + packages/client/src/index.ts | 10 + packages/plugin-api/dist/index.d.ts | 33 +++ packages/plugin-api/dist/index.js | 112 ++++++++- packages/plugin-api/src/index.test.mjs | 116 ++++++++- packages/plugin-api/src/index.ts | 151 +++++++++++- packages/shared/src/i18n/en-US.ts | 12 + packages/shared/src/i18n/ja.ts | 12 + packages/shared/src/i18n/zh-CN.ts | 12 + scripts/run-wrangler.mjs | 1 + scripts/self-hosted-server.mjs | 1 + 32 files changed, 1524 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/community-registry-proxy.test.mjs create mode 100644 apps/api/src/community-registry-proxy.ts create mode 100644 apps/web/src/lib/plugins/community-plugin-submission.ts create mode 100644 apps/web/src/lib/plugins/community-registry-keys.ts create mode 100644 apps/web/src/lib/plugins/community-registry-store.ts create mode 100644 apps/web/src/lib/plugins/community-registry.test.mjs create mode 100644 apps/web/src/lib/plugins/community-registry.ts diff --git a/apps/api/src/api-context.ts b/apps/api/src/api-context.ts index 51ebec5e..7e1ca33b 100644 --- a/apps/api/src/api-context.ts +++ b/apps/api/src/api-context.ts @@ -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 & CloudflareStorageBindings & { diff --git a/apps/api/src/community-registry-proxy.test.mjs b/apps/api/src/community-registry-proxy.test.mjs new file mode 100644 index 00000000..1ab4a801 --- /dev/null +++ b/apps/api/src/community-registry-proxy.test.mjs @@ -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"); + }); +}); diff --git a/apps/api/src/community-registry-proxy.ts b/apps/api/src/community-registry-proxy.ts new file mode 100644 index 00000000..c4ee847b --- /dev/null +++ b/apps/api/src/community-registry-proxy.ts @@ -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), + }; +}; diff --git a/apps/api/src/plugin-distribution-routes.ts b/apps/api/src/plugin-distribution-routes.ts index ca1b509c..6d8e78e3 100644 --- a/apps/api/src/plugin-distribution-routes.ts +++ b/apps/api/src/plugin-distribution-routes.ts @@ -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) => { + 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"); diff --git a/apps/web/src/components/plugins/PluginCatalogCard.tsx b/apps/web/src/components/plugins/PluginCatalogCard.tsx index d1ebb6f5..ac28ad51 100644 --- a/apps/web/src/components/plugins/PluginCatalogCard.tsx +++ b/apps/web/src/components/plugins/PluginCatalogCard.tsx @@ -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 = ({ ) : null} {description ?

{description}

: null} + {item.marketplaceEntry?.listing === "community" ? ( +

+ {item.marketplaceEntry.licenseSpdx + ? `${t("plugins.marketplace.license", { spdx: item.marketplaceEntry.licenseSpdx })} · ` + : ""} + {t("plugins.marketplace.admissionNotice")} +

+ ) : null}
{repositoryUrl ? ( @@ -128,6 +139,11 @@ export const PluginCatalogCard = ({
+ {revocation ? ( +
+ {t("plugins.marketplace.revoked", { reason: revocation.reason })} +
+ ) : null} {extension?.error ?
{extension.error}
: null}
@@ -139,7 +155,7 @@ export const PluginCatalogCard = ({ ) : null} - {update ? ( + {update && !revocation ? ( + ) : null} + {!selectedPluginId ? ( + + ) : null}
@@ -550,6 +650,70 @@ export const PluginManagerCard = ({ onConfirm={() => void run(`update:${pendingUpdate.pluginId}`, () => applyUpdate(pendingUpdate))} /> ) : null} + + + + {t("plugins.marketplace.submissionTitle")} + {t("plugins.marketplace.submissionHint")} + + + {t("plugins.marketplace.submissionDocs")} + + + {submissionError ? ( +
{submissionError}
+ ) : null} +
+ { + setSubmissionRepository(event.target.value); + setSubmissionResult(null); + setSubmissionError(null); + }} + placeholder={t("plugins.sourcePlaceholder")} + className="focus-visible:border-slate-400 focus-visible:ring-slate-400/25" + /> + +
+ {submissionResult ? ( +

+ {t("plugins.marketplace.precheckPassed", { id: submissionResult.id })}{" "} + + {t("plugins.marketplace.openSubmission")} + +

+ ) : null} + + {pendingTrustPluginId ? ( { + 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, + }; +}; diff --git a/apps/web/src/lib/plugins/community-registry-keys.ts b/apps/web/src/lib/plugins/community-registry-keys.ts new file mode 100644 index 00000000..838bbc4e --- /dev/null +++ b/apps/web/src/lib/plugins/community-registry-keys.ts @@ -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; +}; diff --git a/apps/web/src/lib/plugins/community-registry-store.ts b/apps/web/src/lib/plugins/community-registry-store.ts new file mode 100644 index 00000000..0f673448 --- /dev/null +++ b/apps/web/src/lib/plugins/community-registry-store.ts @@ -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; + writeRegistry(value: CachedCommunityRegistry): Promise; + readRevocations(): Promise; + writeRevocations(values: readonly MarketplaceRevocation[]): Promise; +} + +const requestResult = (request: IDBRequest) => new Promise((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((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((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 | 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(); + 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(); + } +}; + +export const isPluginIdRevoked = async (pluginId: string, storage = communityRegistryStorage()) => + (await readRevokedPluginIds(storage)).has(pluginId); diff --git a/apps/web/src/lib/plugins/community-registry.test.mjs b/apps/web/src/lib/plugins/community-registry.test.mjs new file mode 100644 index 00000000..9dcaceb5 --- /dev/null +++ b/apps/web/src/lib/plugins/community-registry.test.mjs @@ -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/"); + }); +}); diff --git a/apps/web/src/lib/plugins/community-registry.ts b/apps/web/src/lib/plugins/community-registry.ts new file mode 100644 index 00000000..d2ddb339 --- /dev/null +++ b/apps/web/src/lib/plugins/community-registry.ts @@ -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; + storage?: CommunityRegistryStorage; + publicKeys?: readonly Uint8Array[]; + instanceKey?: string; +} = {}): Promise => { + 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> = 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"); +}; diff --git a/apps/web/src/lib/plugins/plugin-catalog.test.mjs b/apps/web/src/lib/plugins/plugin-catalog.test.mjs index 2970fce5..ac39f8bd 100644 --- a/apps/web/src/lib/plugins/plugin-catalog.test.mjs +++ b/apps/web/src/lib/plugins/plugin-catalog.test.mjs @@ -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"); }); diff --git a/apps/web/src/lib/plugins/plugin-catalog.ts b/apps/web/src/lib/plugins/plugin-catalog.ts index 7eaac871..0e295d09 100644 --- a/apps/web/src/lib/plugins/plugin-catalog.ts +++ b/apps/web/src/lib/plugins/plugin-catalog.ts @@ -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; }; diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts index 185229c3..5d3f389a 100644 --- a/apps/web/src/lib/plugins/plugin-host.ts +++ b/apps/web/src/lib/plugins/plugin-host.ts @@ -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); diff --git a/apps/web/src/lib/plugins/plugin-updates.test.mjs b/apps/web/src/lib/plugins/plugin-updates.test.mjs index ebbab781..ee821676 100644 --- a/apps/web/src/lib/plugins/plugin-updates.test.mjs +++ b/apps/web/src/lib/plugins/plugin-updates.test.mjs @@ -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", () => { diff --git a/apps/web/src/lib/plugins/plugin-updates.ts b/apps/web/src/lib/plugins/plugin-updates.ts index cf3fec07..90e8d488 100644 --- a/apps/web/src/lib/plugins/plugin-updates.ts +++ b/apps/web/src/lib/plugins/plugin-updates.ts @@ -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, ): Promise => { + 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 }, ): Promise => { 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 = {}; @@ -130,9 +134,12 @@ export const updateOfficialMarketplacePlugins = async ( host: PluginUpdateHost, marketplaceEntries: MarketplaceEntry[], request: typeof fetch = window.fetch.bind(window), + options?: { revokedIds?: ReadonlySet }, ): Promise => { - 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); diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md index e11900c2..2545834f 100644 --- a/docs/deploy-docker.md +++ b/docs/deploy-docker.md @@ -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`. diff --git a/docs/deploy-docker.zh-CN.md b/docs/deploy-docker.zh-CN.md index a1acc2c2..9f58eff2 100644 --- a/docs/deploy-docker.zh-CN.md +++ b/docs/deploy-docker.zh-CN.md @@ -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 diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 79efa810..441796d1 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -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 diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index 40010b20..287acd5d 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -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 diff --git a/docs/plugin-marketplace-policy.md b/docs/plugin-marketplace-policy.md index 349c49a1..48df868f 100644 --- a/docs/plugin-marketplace-policy.md +++ b/docs/plugin-marketplace-policy.md @@ -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. diff --git a/docs/plugin-marketplace-policy.zh-CN.md b/docs/plugin-marketplace-policy.zh-CN.md index cef1e89d..b88fb008 100644 --- a/docs/plugin-marketplace-policy.zh-CN.md +++ b/docs/plugin-marketplace-policy.zh-CN.md @@ -23,3 +23,7 @@ EdgeEver 官方插件市场仅收录自由及开源插件。本政策适用于 只要某个版本仍通过官方市场分发,其仓库、许可证、对应源码版本和构建信息就必须持续公开。如果插件不再满足本政策、发布资产无法追溯到声明的源码,或者其行为带来安全、隐私、法律或可靠性风险,EdgeEver 可以拒绝上架、暂停分发或下架该插件。 市场“已验证”仅表示插件在审核时通过了适用的准入与完整性检查,不代表 EdgeEver 保证插件绝对安全、没有错误或适合所有用户。 + +## 社区目录 + +社区目录沿用同样的收录含义。它与内置官方列表分开发布,签名后的社区文件也不能接管官方插件更新。直接通过 GitHub 或 Manifest 地址安装的路径保持可用,那条路径不经过收录检查。 diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0168affe..7bbb28f0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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 }); diff --git a/packages/plugin-api/dist/index.d.ts b/packages/plugin-api/dist/index.d.ts index d47e9c6a..fcd7b990 100644 --- a/packages/plugin-api/dist/index.d.ts +++ b/packages/plugin-api/dist/index.d.ts @@ -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: (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) => 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 {}; diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js index d36caee2..492a8705 100644 --- a/packages/plugin-api/dist/index.js +++ b/packages/plugin-api/dist/index.js @@ -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, diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs index 01ec8348..7fa2640e 100644 --- a/packages/plugin-api/src/index.test.mjs +++ b/packages/plugin-api/src/index.test.mjs @@ -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", () => { diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index f315f2b5..bcf6ebc3 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -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(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(); + 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, +): 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)); }; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 0a51c97b..8c03ff7b 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 4a58aef8..f67e5dd4 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -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: "プラグイン", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 3bd44244..2995d616 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -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: "插件", diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index cd9d4314..0f07cd98 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -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)) diff --git a/scripts/self-hosted-server.mjs b/scripts/self-hosted-server.mjs index 084e2b32..8ae5747c 100644 --- a/scripts/self-hosted-server.mjs +++ b/scripts/self-hosted-server.mjs @@ -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 = {