From 6a7afa94a0c0e6e204c0e928b52bf7cc59b68d4c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 11 Oct 2026 13:29:05 -0500 Subject: [PATCH] feat: community and custom plugin registries --- CHANGELOG.md | 2 + README.md | 2 +- env.catalog.json | 5 + src/backend/database/routes/plugins.ts | 340 +++++++++++++- src/backend/plugins/install-counts.ts | 47 +- src/backend/plugins/loader.ts | 20 +- src/backend/plugins/manage.ts | 225 ++++++++-- src/backend/plugins/registries.ts | 414 ++++++++++++++++++ src/backend/plugins/registry-index.ts | 160 +++++-- src/backend/plugins/trust.ts | 68 +++ src/backend/sync/client/plugins.ts | 11 +- src/backend/sync/server/routes.ts | 6 +- .../tests/database/routes/plugins.test.ts | 198 +++++++++ .../tests/plugins/install-counts.test.ts | 48 +- src/backend/tests/plugins/manage.test.ts | 230 +++++++++- src/backend/tests/plugins/registries.test.ts | 259 +++++++++++ .../tests/plugins/registry-index.test.ts | 112 ++++- .../tests/plugins/signed-plugins-gate.test.ts | 46 +- src/backend/tests/plugins/trust.test.ts | 61 +++ src/backend/tests/sync/client/plugins.test.ts | 17 + src/ui/api/plugins-api.ts | 74 +++- src/ui/lib/docs-pages.json | 4 +- src/ui/locales/en.json | 41 +- src/ui/plugins/PluginDetail.tsx | 5 + src/ui/plugins/PluginsTab.tsx | 62 ++- src/ui/plugins/RegistriesSection.tsx | 390 +++++++++++++++++ src/ui/plugins/plugin-bits.tsx | 26 ++ src/ui/plugins/plugin-model.ts | 19 +- src/ui/plugins/use-plugins-manager.ts | 3 + src/ui/tests/plugins/PluginsTab.test.tsx | 7 +- .../tests/plugins/RegistriesSection.test.tsx | 135 ++++++ 31 files changed, 2903 insertions(+), 134 deletions(-) create mode 100644 src/backend/plugins/registries.ts create mode 100644 src/backend/tests/plugins/registries.test.ts create mode 100644 src/ui/plugins/RegistriesSection.tsx create mode 100644 src/ui/tests/plugins/RegistriesSection.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d757b0f93..40329098e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Slovak, Croatian, Slovenian, Lithuanian, Latvian, Estonian and Malay - The desktop app can update itself. Pick Automatic, Ask first or Never in Settings, Updates. Store and package manager installs keep updating the way they do now +- Community plugins. Turn on the community registry in Plugins, Registries to browse and install plugins made by other people, each version reviewed before it is listed +- Custom plugin registries. Add your own registry by its index URL; its signing key is pinned when you add it ### Changed diff --git a/README.md b/README.md index 786a96996..597ab9b7a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | | [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | -Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry). +Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
diff --git a/env.catalog.json b/env.catalog.json index 9f0934845..431df1b61 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -361,6 +361,11 @@ "group": "plugins", "description": "Use another plugin index instead of the official registry." }, + { + "name": "TERMIX_COMMUNITY_REGISTRY_URL", + "group": "plugins", + "description": "Use another plugin index instead of the community registry. Its plugins still have to be signed with the community key." + }, { "name": "TERMIX_PLUGIN_STATS_URL", "group": "plugins", diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 5dffe395a..d37fa1002 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -23,6 +23,7 @@ import { PluginManageError, } from "../../plugins/manage.js"; import { parsePluginChannel } from "../../plugins/registry-index.js"; +import { RegistryError } from "../../plugins/registries.js"; import { pluginDocsUrl, pluginEnvVars, @@ -404,6 +405,7 @@ async function auditPluginAction( action: string, pluginId: string, details: Record = {}, + resourceType = "plugin", ): Promise { const userId = (req as AuthenticatedRequest).userId as string; const { ipAddress, userAgent } = getRequestMeta(req); @@ -412,7 +414,7 @@ async function auditPluginAction( userId, username: await getAuditUsername(userId), action, - resourceType: "plugin", + resourceType, resourceId: pluginId, resourceName: pluginId, details: JSON.stringify(details), @@ -431,7 +433,7 @@ function sendManageError( fallback: string, operation: string, ): void { - if (error instanceof PluginManageError) { + if (error instanceof PluginManageError || error instanceof RegistryError) { res.status(error.status).json({ error: error.message, code: error.code, @@ -582,6 +584,304 @@ router.post( }, ); +/** + * @openapi + * /plugins/registries: + * get: + * summary: List the plugin registries + * description: The official registry, which is always on, the community registry and whether it is on, and every custom registry an admin added with its pinned key fingerprint. + * tags: + * - Plugins + * responses: + * 200: + * description: The registries. + * 403: + * description: The caller lacks admin.plugins.manage. + */ +router.get( + "/registries", + authenticateJWT, + requireManagePlugins, + async (_req: Request, res: Response) => { + try { + const { readRegistriesConfig } = + await import("../../plugins/registries.js"); + const { officialSource, communitySource } = + await import("../../plugins/registry-index.js"); + const config = await readRegistriesConfig(); + res.json({ + managedByServer: await isLinkedDesktop(), + official: { id: "official", url: officialSource().url }, + community: { + id: "community", + url: communitySource().url, + enabled: config.community.enabled, + }, + custom: config.custom.map((entry) => ({ + id: entry.id, + name: entry.name, + url: entry.url, + keyId: entry.keyId, + enabled: entry.enabled, + addedAt: entry.addedAt, + })), + }); + } catch (error) { + sendManageError( + res, + error, + "Failed to read plugin registries", + "plugin_registry", + ); + } + }, +); + +/** + * @openapi + * /plugins/registries/preview: + * post: + * summary: Read a registry index before adding it + * description: Fetches the index at url and returns its name, the id it would get, how many plugins it lists and the fingerprint of the key it signs with. Nothing is saved. The index must list a publicKey. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - url + * properties: + * url: + * type: string + * responses: + * 200: + * description: What the registry would be added as. + * 400: + * description: Not an https URL, or the index has no publicKey. + * 403: + * description: The caller lacks admin.plugins.manage. + * 409: + * description: The registry is already added. + * 502: + * description: The index could not be read. + */ +router.post( + "/registries/preview", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + if (await refuseOnLinkedDesktop(res)) return; + try { + const { previewRegistry } = await import("../../plugins/registries.js"); + res.json(await previewRegistry(req.body?.url)); + } catch (error) { + sendManageError( + res, + error, + "Failed to read the registry", + "plugin_registry", + ); + } + }, +); + +/** + * @openapi + * /plugins/registries: + * post: + * summary: Add a custom plugin registry + * description: Reads the index again and pins the key it names. keyId must be the fingerprint the preview showed, otherwise the answer is 409 KEY_MISMATCH and nothing is saved. Every plugin installed from it must be signed by that key. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - url + * - keyId + * properties: + * url: + * type: string + * keyId: + * type: string + * responses: + * 200: + * description: The added registry. + * 400: + * description: Not an https URL, no publicKey, or too many registries. + * 403: + * description: The caller lacks admin.plugins.manage. + * 409: + * description: Already added, or the key changed since the preview. + * 502: + * description: The index could not be read. + */ +router.post( + "/registries", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + if (await refuseOnLinkedDesktop(res)) return; + try { + const { addRegistry } = await import("../../plugins/registries.js"); + const entry = await addRegistry(req.body?.url, req.body?.keyId); + await auditPluginAction( + req, + "add_plugin_registry", + entry.id, + { url: entry.url, keyId: entry.keyId }, + "plugin_registry", + ); + res.json({ + id: entry.id, + name: entry.name, + url: entry.url, + keyId: entry.keyId, + enabled: entry.enabled, + addedAt: entry.addedAt, + }); + } catch (error) { + sendManageError( + res, + error, + "Failed to add the registry", + "plugin_registry", + ); + } + }, +); + +/** + * @openapi + * /plugins/registries/{id}: + * patch: + * summary: Turn a plugin registry on or off + * description: Works for community and custom registries. Plugins already installed from a registry that is off keep running but get no updates. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - enabled + * properties: + * enabled: + * type: boolean + * responses: + * 200: + * description: The registry was changed. + * 400: + * description: Invalid body, or the official registry. + * 403: + * description: The caller lacks admin.plugins.manage. + * 404: + * description: No such registry. + */ +router.patch( + "/registries/:id", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const id = String(req.params.id); + const enabled = req.body?.enabled; + if (typeof enabled !== "boolean") { + res.status(400).json({ error: "enabled must be a boolean" }); + return; + } + if (await refuseOnLinkedDesktop(res)) return; + try { + const { setRegistryEnabled } = + await import("../../plugins/registries.js"); + await setRegistryEnabled(id, enabled); + await auditPluginAction( + req, + enabled ? "enable_plugin_registry" : "disable_plugin_registry", + id, + {}, + "plugin_registry", + ); + res.json({ id, enabled }); + } catch (error) { + sendManageError( + res, + error, + "Failed to change the registry", + "plugin_registry", + ); + } + }, +); + +/** + * @openapi + * /plugins/registries/{id}: + * delete: + * summary: Remove a custom plugin registry + * description: Refused with 409 REGISTRY_IN_USE while plugins installed from it remain, since its key has to stay trusted for them to load. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The registry was removed. + * 400: + * description: Official and community can only be turned off. + * 403: + * description: The caller lacks admin.plugins.manage. + * 404: + * description: No such registry. + * 409: + * description: Plugins from it are still installed. + */ +router.delete( + "/registries/:id", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const id = String(req.params.id); + if (await refuseOnLinkedDesktop(res)) return; + try { + const { removeRegistry } = await import("../../plugins/registries.js"); + await removeRegistry(id); + await auditPluginAction( + req, + "remove_plugin_registry", + id, + {}, + "plugin_registry", + ); + res.json({ id }); + } catch (error) { + sendManageError( + res, + error, + "Failed to remove the registry", + "plugin_registry", + ); + } + }, +); + /** * @openapi * /plugins/{id}/state: @@ -1609,10 +1909,11 @@ function readVersion(body: unknown): string | undefined | null { * @openapi * /plugins/registry: * get: - * summary: List the plugins in the official registry + * summary: List the plugins in every registry that is on * description: > - * Fetches the official registry index (cached for 15 minutes unless - * refresh is set) and merges it with what is installed here: the + * Fetches the index of every registry that is on (each cached for 15 + * minutes unless refresh is set), with each one's status under + * registries, and merges them with what is installed here: the * installed version, the newest release this build can run, whether an * update asks for new capabilities, and the pin and auto-update choices. * tags: @@ -1753,10 +2054,10 @@ router.post( * @openapi * /plugins/{id}/install: * post: - * summary: Install a plugin from the official registry + * summary: Install a plugin from a registry * description: > - * Downloads the release, checks its sha256 and signature against a key - * this build trusts, loads it, grants the capabilities it declares and + * Downloads the release, checks its sha256 and signature against the keys of + * the registry it comes from, loads it, grants the capabilities it declares and * starts it along with any installed dependency that is off. Without a * version the newest compatible release is used; naming an older one * pins the plugin there. A bundled plugin at its shipped version needs @@ -1786,6 +2087,9 @@ router.post( * type: string * enum: [stable, beta] * description: The channel to follow. beta installs the newest beta when it is newer than stable. + * registryId: + * type: string + * description: The registry to install from, official when unset. It must be turned on, and only the official registry can offer an official plugin id. * capabilities: * type: array * items: @@ -1798,9 +2102,9 @@ router.post( * 403: * description: The caller lacks admin.plugins.manage. * 404: - * description: Not in the official registry. + * description: Not in that registry. * 409: - * description: Already installed, incompatible, the consent does not match, or managed by a linked server. + * description: Already installed, incompatible, the consent does not match, the registry is off, the id belongs to an official plugin, or managed by a linked server. * 502: * description: The registry could not be reached. */ @@ -1816,15 +2120,19 @@ router.post( req.body?.channel === undefined ? "stable" : parsePluginChannel(req.body.channel); + const registryId = + req.body?.registryId === undefined ? undefined : req.body.registryId; if ( !ID_PATTERN.test(pluginId) || version === null || capabilities === null || - channel === null + channel === null || + (registryId !== undefined && + (typeof registryId !== "string" || !ID_PATTERN.test(registryId))) ) { - res - .status(400) - .json({ error: "Invalid plugin id, version, channel or capabilities" }); + res.status(400).json({ + error: "Invalid plugin id, version, channel, registry or capabilities", + }); return; } if (await refuseOnLinkedDesktop(res)) return; @@ -1834,11 +2142,13 @@ router.post( const result = await installPlugin(pluginId, { version, channel, + registryId, userId, capabilities, }); await auditPluginAction(req, "install_plugin", pluginId, { version: result.version, + registryId: registryId ?? "official", capabilities, }); res.json(result); @@ -1859,7 +2169,7 @@ router.post( * post: * summary: Update or change the installed version of a plugin * description: > - * Swaps in another release from the official registry, newer or older. + * Swaps in another release from the registry the plugin came from, newer or older. * If the release declares capabilities the installed one does not, the * request must set acceptCapabilities and list exactly those added * capabilities in capabilities, otherwise it answers 409 with code diff --git a/src/backend/plugins/install-counts.ts b/src/backend/plugins/install-counts.ts index a0dd5d62b..59b9aad23 100644 --- a/src/backend/plugins/install-counts.ts +++ b/src/backend/plugins/install-counts.ts @@ -15,7 +15,12 @@ */ import { pluginLogger } from "../utils/logger.js"; -import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js"; +import { + OFFICIAL_REGISTRY_ID, + fetchRegistryStats, + officialSource, + type RegistrySource, +} from "./registry-index.js"; export const TELEMETRY_SOURCE = "aggregate-telemetry"; export const DOWNLOADS_SOURCE = "github-releases"; @@ -43,23 +48,33 @@ export function preferredCount(entry: { /** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */ export async function syncInstallCounts(): Promise { + let changed = 0; + let sources: RegistrySource[] = [officialSource()]; try { - const stats = await fetchRegistryStats(); - if (!stats) return 0; - const entries = [...stats.plugins].map(([pluginId, entry]) => ({ - pluginId, - ...preferredCount(entry), - })); - return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries); - } catch (error) { - pluginLogger.warn( - `Could not sync plugin install counts: ${ - error instanceof Error ? error.message : String(error) - }`, - { operation: "plugin_registry" }, - ); - return 0; + const { listSources } = await import("./registries.js"); + sources = await listSources(); + } catch { + // Official only when the registry choices cannot be read. } + for (const source of sources) { + try { + const stats = await fetchRegistryStats(source); + if (!stats) continue; + const entries = [...stats.plugins].map(([pluginId, entry]) => ({ + pluginId, + ...preferredCount(entry), + })); + changed += await (await repository()).upsertMany(source.id, entries); + } catch (error) { + pluginLogger.warn( + `Could not sync plugin install counts from ${source.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + { operation: "plugin_registry" }, + ); + } + } + return changed; } export async function readInstallCounts( diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 8521a8e61..862ac6537 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -28,7 +28,11 @@ import { getPluginsDir, getUnpackedPluginsDir, } from "./paths.js"; -import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js"; +import { + requireSignedPlugins, + verifyAnyTrusted, + type KeyTier, +} from "./trust.js"; import { moveWithRetry } from "./fs-retry.js"; import { createPluginContext, @@ -77,6 +81,8 @@ export interface LoadedPlugin { artifact?: string; /** Key id that signed the artifact, when a trusted key signed it. */ signedBy?: string; + /** Which set of keys that key belongs to. */ + signedTier?: KeyTier; } export interface PluginLoaderOptions { @@ -345,10 +351,11 @@ export class PluginLoader { const buffer = await fs.promises.readFile(file); const sigFile = `${file}.sig`; let signedBy: string | undefined; + let signedTier: KeyTier | undefined; if (fs.existsSync(sigFile)) { const sha256 = crypto.createHash("sha256").update(buffer).digest("hex"); - const result = verifyPluginArtifact( + const result = verifyAnyTrusted( buffer, sha256, await fs.promises.readFile(sigFile, "utf8"), @@ -357,6 +364,7 @@ export class PluginLoader { throw new Error(`signature check failed: ${result.reason}`); } signedBy = result.keyId; + signedTier = result.tier; } else if (requireSignedPlugins()) { throw new Error( `TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`, @@ -402,7 +410,7 @@ export class PluginLoader { assertBundledUpdate( id, raw?.version, - signedBy, + signedTier === "official" ? signedBy : undefined, this.bundledVersions.get(id) ?? replaced?.manifest.version, this.pinned.get(id), ); @@ -427,6 +435,7 @@ export class PluginLoader { const plugin = await this.load(target, isUpdate ? "bundled" : "user"); plugin.artifact = file; plugin.signedBy = signedBy; + plugin.signedTier = signedTier; if (replaced) { pluginLogger.info( `Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`, @@ -617,7 +626,8 @@ export class PluginLoader { // A release signed by a trusted key is official whether it shipped in // the image or was downloaded, so it may adopt its 2.8 tables too. const applied = await migratePlugin(plugin.id, plugin.dir, { - bundled: plugin.source === "bundled" || Boolean(plugin.signedBy), + bundled: + plugin.source === "bundled" || plugin.signedTier === "official", }); // A new table may be where core data is waiting to move. if (applied.length > 0) { @@ -849,7 +859,7 @@ export function assertBundledUpdate( ): void { if (!signedBy) { throw new Error( - `a bundled plugin already uses the id "${id}"; only a signed update can replace it`, + `a bundled plugin already uses the id "${id}"; only an official signed update can replace it`, ); } if (typeof version !== "string" || !semver.valid(version)) { diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index da377dbfe..5ef9a1fec 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -1,8 +1,9 @@ /** * Installing, updating, uninstalling and switching plugins at runtime. * - * The official registry is the one real source. With developer mode on, an - * admin can also install a .tmxplug from a file; that skips the download, + * Plugins come from the official registry, plus the community and custom + * registries an admin turned on (see registries.ts). With developer mode on, + * an admin can also install a .tmxplug from a file; that skips the download, * sha256 and signature checks and stays marked unverified. Bundled plugins ship in the * image and can still be uninstalled: their files are deleted and the id is * recorded in the settings table, so the shipped copy is ignored from then @@ -38,11 +39,21 @@ import { getRegistryStatus, isApiCompatible, latestOnChannel, + officialSource, parsePluginChannel, type PluginChannel, type RegistryIndex, + type RegistryPlugin, + type RegistrySource, + type RegistryTier, type RegistryVersion, } from "./registry-index.js"; +import { + findSource, + listSources, + loadCustomRegistryKeys, + pinnedKeyOf, +} from "./registries.js"; import { invalidatePluginPermissionCache } from "./permissions.js"; import { moveWithRetry } from "./fs-retry.js"; import { readInstallCounts } from "./install-counts.js"; @@ -127,6 +138,7 @@ async function readPins(): Promise> { export async function applyStoredPluginChoices( loader: PluginLoader, ): Promise { + await loadCustomRegistryKeys(); try { loader.setUninstalled(await readUninstalled()); loader.setPinned(await readPins()); @@ -208,12 +220,28 @@ function addedCapabilities( return next.filter((capability) => !have.has(capability)); } +export interface RegistryListingSource { + id: string; + name: string; + tier: RegistryTier; + url: string; + lastCheckedAt: string | null; + error: string | null; + plugins: number; +} + export interface RegistryListing { + /** The official registry, kept for older clients. */ registry: ReturnType & { id: string }; + /** Every registry read for this listing, official first. */ + registries: RegistryListingSource[]; /** A linked desktop runs what its server runs, so nothing here can change. */ managedByServer: boolean; plugins: Array<{ id: string; + registryId: string; + registryName: string; + tier: RegistryTier; name: string; description: string; author: string; @@ -251,22 +279,112 @@ export interface RegistryListing { }>; } +/** A custom registry whose index now names another key than the pinned one. */ +async function keyChanged( + source: RegistrySource, + index: RegistryIndex, +): Promise { + if (source.tier !== "custom" || !index.publicKey) return false; + return index.publicKey !== (await pinnedKeyOf(source.id)); +} + +const KEY_CHANGED_MESSAGE = + "the registry now names a different signing key; add it again to trust the new key"; + +/** + * Ids only the official registry may offer: what ships in the image and what + * the official index lists. Other registries can never take them over. + */ +function reservedIds(official: RegistryIndex | null): Set { + const ids = new Set(getPluginRuntime().loader.bundledIds()); + for (const plugin of official?.plugins ?? []) ids.add(plugin.id); + return ids; +} + export async function listRegistry( options: { force?: boolean } = {}, ): Promise { - const index = await fetchRegistryIndex(options); + const sources = await listSources(); + const results = await Promise.allSettled( + sources.map((source) => fetchRegistryIndex(source, options)), + ); + const official = results[0]; + if (official.status === "rejected") throw official.reason; + const { createCurrentPluginRepository } = await repos(); const records = new Map( (await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]), ); - const counts = await readInstallCounts(); + const counts = new Map( + await Promise.all( + sources + .filter((source) => source.tier !== "custom") + .map( + async (source) => + [source.id, await readInstallCounts(source.id)] as const, + ), + ), + ); const { loader } = getPluginRuntime(); const shipped = loader.bundledIds(); + const reserved = reservedIds(official.value); + + const registries: RegistryListingSource[] = []; + // Per id, every registry that lists it, in source order. + const offers = new Map< + string, + Array<{ source: RegistrySource; plugin: RegistryPlugin }> + >(); + for (let i = 0; i < sources.length; i++) { + const source = sources[i]; + const result = results[i]; + const status = getRegistryStatus(source); + let error = status.error; + let listed = 0; + if (result.status === "fulfilled") { + if (await keyChanged(source, result.value)) { + error = KEY_CHANGED_MESSAGE; + } else { + for (const plugin of result.value.plugins) { + if (source.tier !== "official" && reserved.has(plugin.id)) continue; + const list = offers.get(plugin.id) ?? []; + list.push({ source, plugin }); + offers.set(plugin.id, list); + listed++; + } + } + } else { + error = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + } + registries.push({ + id: source.id, + name: + result.status === "fulfilled" && result.value.name + ? result.value.name + : source.name, + tier: source.tier, + url: source.url, + lastCheckedAt: status.lastCheckedAt, + error, + plugins: listed, + }); + } + + const chosen: Array<{ source: RegistrySource; plugin: RegistryPlugin }> = []; + for (const [id, list] of offers) { + // An installed plugin is always shown from the registry it came from. + const from = records.get(id)?.registryId; + chosen.push(list.find((offer) => offer.source.id === from) ?? list[0]); + } return { registry: { id: OFFICIAL_REGISTRY_ID, ...getRegistryStatus() }, + registries, managedByServer: await isLinkedDesktop(), - plugins: index.plugins.map((plugin) => { + plugins: chosen.map(({ source, plugin }) => { const record = records.get(plugin.id); const live = loader.get(plugin.id); const channel = channelOf(record); @@ -283,6 +401,9 @@ export async function listRegistry( ); return { id: plugin.id, + registryId: source.id, + registryName: registries.find((entry) => entry.id === source.id)!.name, + tier: source.tier, name: plugin.name, description: plugin.description, author: plugin.author, @@ -318,8 +439,9 @@ export async function listRegistry( pinnedVersion: record?.pinnedVersion ?? null, autoUpdate: Boolean(record?.autoUpdate), bundled: shipped.has(plugin.id), - installCount: counts.get(plugin.id)?.count ?? null, - installCountSource: counts.get(plugin.id)?.source ?? null, + installCount: counts.get(source.id)?.get(plugin.id)?.count ?? null, + installCountSource: + counts.get(source.id)?.get(plugin.id)?.source ?? null, }; }), }; @@ -362,7 +484,8 @@ async function grantDeclared( invalidatePluginPermissionCache(plugin.id); } -type SwapSource = { release: RegistryVersion } | { upload: string }; +type SwapSource = + { release: RegistryVersion; source: RegistrySource } | { upload: string }; /** * Puts a registry release, or an uploaded file, in place of whatever copy of @@ -377,6 +500,7 @@ async function swapIn( const bundledVersion = loader.bundledVersion(pluginId); const useBundled = "release" in source && + source.source.tier === "official" && bundledVersion !== undefined && bundledVersion === source.release.version && isBundledOnDisk(pluginId); @@ -385,7 +509,7 @@ async function swapIn( const staged = useBundled ? null : "release" in source - ? await downloadRelease(pluginId, source.release) + ? await downloadRelease(pluginId, source.release, source.source.keys) : { file: source.upload, signatureFile: `${source.upload}.sig` }; const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`); @@ -654,6 +778,8 @@ export interface InstallOptions { version?: string; /** Install only: the channel to follow from the start. */ channel?: PluginChannel; + /** Install only: the registry to install from. Official when unset. */ + registryId?: string; userId: string | null; acceptCapabilities?: boolean; /** @@ -684,33 +810,68 @@ function assertConsent( } } +function unreachable(error: unknown): PluginManageError { + return new PluginManageError( + `Could not reach the plugin registry: ${ + error instanceof Error ? error.message : String(error) + }`, + 502, + "REGISTRY_UNREACHABLE", + ); +} + async function requireRelease( pluginId: string, version: string | undefined, channel: PluginChannel, + registryId: string = OFFICIAL_REGISTRY_ID, ): Promise<{ - index: RegistryIndex; + source: RegistrySource; release: RegistryVersion; latest: RegistryVersion | null; }> { + const source = await findSource(registryId); + if (!source) { + throw new PluginManageError( + `The registry "${registryId}" is turned off or was removed`, + 409, + "REGISTRY_OFF", + ); + } let index: RegistryIndex; try { - index = await fetchRegistryIndex(); + index = await fetchRegistryIndex(source); } catch (error) { + throw unreachable(error); + } + if (await keyChanged(source, index)) { throw new PluginManageError( - `Could not reach the plugin registry: ${ - error instanceof Error ? error.message : String(error) - }`, - 502, - "REGISTRY_UNREACHABLE", + `${source.name}: ${KEY_CHANGED_MESSAGE}`, + 409, + "KEY_CHANGED", ); } + if (source.tier !== "official") { + let official: RegistryIndex; + try { + official = await fetchRegistryIndex(officialSource()); + } catch (error) { + throw unreachable(error); + } + if (reservedIds(official).has(pluginId)) { + throw new PluginManageError( + `${pluginId} is an official plugin and can only come from the official registry`, + 409, + "RESERVED_ID", + ); + } + } const found = findRelease(index, pluginId, version, channel); if (!found) { throw new PluginManageError( version - ? `${pluginId} ${version} is not in the official registry` - : `${pluginId} is not in the official registry`, + ? `${pluginId} ${version} is not in ${source.name}` + : `${pluginId} is not in ${source.name}`, 404, "NOT_IN_REGISTRY", ); @@ -723,12 +884,20 @@ async function requireRelease( ); } return { - index, + source, release: found.release, latest: latestOnChannel(found.plugin, channel), }; } +/** What a registry install is recorded as. */ +function tierFor(plugin: LoadedPlugin, source: RegistrySource): string { + if (source.tier === "official" && plugin.source === "bundled") { + return "bundled"; + } + return source.tier; +} + /** The version to hold at: an explicit pick that is not the newest. */ function pinFor( release: RegistryVersion, @@ -763,10 +932,11 @@ export function installPlugin( } const channel = options.channel ?? "stable"; - const { release, latest } = await requireRelease( + const { source, release, latest } = await requireRelease( pluginId, options.version, channel, + options.registryId, ); assertConsent(options.capabilities, release.capabilities); const pin = pinFor(release, latest, options.version); @@ -774,7 +944,7 @@ export function installPlugin( let plugin: LoadedPlugin; try { - plugin = await swapIn(pluginId, { release }); + plugin = await swapIn(pluginId, { release, source }); } catch (error) { await applyPin(pluginId, null); throw new PluginManageError( @@ -785,8 +955,8 @@ export function installPlugin( } await repository.update(pluginId, { - registryId: OFFICIAL_REGISTRY_ID, - tier: plugin.source === "bundled" ? "bundled" : "official", + registryId: source.id, + tier: tierFor(plugin, source), pinnedVersion: pin, channel, }); @@ -832,10 +1002,11 @@ async function updateUnlocked( throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); } - const { release, latest } = await requireRelease( + const { source, release, latest } = await requireRelease( pluginId, options.version, channelOf(record), + record.registryId ?? OFFICIAL_REGISTRY_ID, ); if (release.version === live.manifest.version) { throw new PluginManageError( @@ -867,7 +1038,7 @@ async function updateUnlocked( let plugin: LoadedPlugin; try { - plugin = await swapIn(pluginId, { release }); + plugin = await swapIn(pluginId, { release, source }); } catch (error) { await applyPin(pluginId, record.pinnedVersion ?? null); if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId); @@ -879,8 +1050,8 @@ async function updateUnlocked( } await repository.update(pluginId, { - registryId: OFFICIAL_REGISTRY_ID, - tier: plugin.source === "bundled" ? "bundled" : "official", + registryId: source.id, + tier: tierFor(plugin, source), pinnedVersion: pin, }); await grantDeclared(plugin, options.userId); diff --git a/src/backend/plugins/registries.ts b/src/backend/plugins/registries.ts new file mode 100644 index 000000000..ad690e8b7 --- /dev/null +++ b/src/backend/plugins/registries.ts @@ -0,0 +1,414 @@ +/** + * Which registries this server reads plugins from. + * + * Official is always on. Community is off until an admin turns it on. Custom + * registries are added by URL: their index names the key they sign with, the + * admin confirms its fingerprint, and that key is pinned. A later key change + * in the index is never followed, the admin has to add the registry again. + * + * Stored as one JSON value in the settings table. + */ + +import { pluginLogger } from "../utils/logger.js"; +import { + OFFICIAL_REGISTRY_ID, + COMMUNITY_REGISTRY_ID, + communitySource, + fetchIndexAt, + officialSource, + type RegistrySource, +} from "./registry-index.js"; +import { + isPluginPublicKey, + pluginKeyId, + setCustomPluginKeys, + type TrustedPluginKey, +} from "./trust.js"; + +const CONFIG_KEY = "plugin_registries"; +/** Keys a linked desktop copied from its server, so mirrored plugins load. */ +const SYNCED_KEYS_KEY = "plugin_registry_synced_keys"; +const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const MAX_ID_LENGTH = 40; +const MAX_NAME_LENGTH = 80; +const MAX_CUSTOM = 20; + +export interface CustomRegistry { + id: string; + name: string; + url: string; + publicKey: string; + keyId: string; + enabled: boolean; + addedAt: string; +} + +export interface RegistriesConfig { + community: { enabled: boolean }; + custom: CustomRegistry[]; +} + +export class RegistryError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string, + readonly details?: Record, + ) { + super(message); + } +} + +async function settings() { + const { createCurrentSettingsRepository } = + await import("../database/repositories/factory.js"); + return createCurrentSettingsRepository(); +} + +function parseCustom(raw: unknown): CustomRegistry | null { + if (!raw || typeof raw !== "object") return null; + const entry = raw as Record; + if ( + typeof entry.id !== "string" || + !ID_PATTERN.test(entry.id) || + typeof entry.url !== "string" || + !isPluginPublicKey(entry.publicKey) + ) { + return null; + } + return { + id: entry.id, + name: typeof entry.name === "string" ? entry.name : entry.id, + url: entry.url, + publicKey: entry.publicKey, + keyId: pluginKeyId(entry.publicKey), + enabled: entry.enabled !== false, + addedAt: typeof entry.addedAt === "string" ? entry.addedAt : "", + }; +} + +export function parseRegistriesConfig(raw: string | null): RegistriesConfig { + const config: RegistriesConfig = { + community: { enabled: false }, + custom: [], + }; + if (!raw) return config; + try { + const parsed = JSON.parse(raw) as Record; + const community = parsed?.community as Record | undefined; + config.community.enabled = community?.enabled === true; + if (Array.isArray(parsed?.custom)) { + config.custom = parsed.custom + .map(parseCustom) + .filter((entry): entry is CustomRegistry => entry !== null); + } + } catch { + // A broken value falls back to official only. + } + return config; +} + +export async function readRegistriesConfig(): Promise { + return parseRegistriesConfig(await (await settings()).get(CONFIG_KEY)); +} + +async function writeRegistriesConfig(config: RegistriesConfig): Promise { + await (await settings()).set(CONFIG_KEY, JSON.stringify(config)); + await applyCustomKeys(config); +} + +function toKey(publicKey: string): TrustedPluginKey { + return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" }; +} + +async function readSyncedKeys(): Promise { + const raw = await (await settings()).get(SYNCED_KEYS_KEY); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isPluginPublicKey) : []; + } catch { + return []; + } +} + +/** + * Trusts the key of every custom registry, on or off: a plugin installed + * from a registry that was later turned off still has to load. + */ +async function applyCustomKeys(config: RegistriesConfig): Promise { + const keys = new Map(); + for (const entry of config.custom) { + keys.set(entry.keyId, toKey(entry.publicKey)); + } + for (const publicKey of await readSyncedKeys()) { + keys.set(pluginKeyId(publicKey), toKey(publicKey)); + } + setCustomPluginKeys([...keys.values()]); +} + +/** Called before the boot scan so plugins from custom registries load. */ +export async function loadCustomRegistryKeys(): Promise { + try { + await applyCustomKeys(await readRegistriesConfig()); + } catch (error) { + pluginLogger.warn( + `Could not read plugin registries: ${ + error instanceof Error ? error.message : String(error) + }`, + { operation: "plugin_init" }, + ); + } +} + +/** A linked desktop trusts the keys of its server's custom registries. */ +export async function setSyncedRegistryKeys( + publicKeys: readonly unknown[], +): Promise { + const valid = [...new Set(publicKeys.filter(isPluginPublicKey))].sort(); + const current = (await readSyncedKeys()).sort(); + if (valid.join() !== current.join()) { + await (await settings()).set(SYNCED_KEYS_KEY, JSON.stringify(valid)); + } + await applyCustomKeys(await readRegistriesConfig()); +} + +export function customSource(entry: CustomRegistry): RegistrySource { + return { + id: entry.id, + name: entry.name, + url: entry.url, + tier: "custom", + keys: [toKey(entry.publicKey)], + }; +} + +/** Every registry plugins can come from right now, official first. */ +export async function listSources(): Promise { + const config = await readRegistriesConfig(); + const sources = [officialSource()]; + if (config.community.enabled) sources.push(communitySource()); + for (const entry of config.custom) { + if (entry.enabled) sources.push(customSource(entry)); + } + return sources; +} + +export async function findSource(id: string): Promise { + return (await listSources()).find((source) => source.id === id) ?? null; +} + +/** The pinned key of a custom registry, for spotting a changed index key. */ +export async function pinnedKeyOf(id: string): Promise { + const config = await readRegistriesConfig(); + return config.custom.find((entry) => entry.id === id)?.publicKey ?? null; +} + +export interface RegistryPreview { + id: string; + name: string; + url: string; + keyId: string; + plugins: number; +} + +function normalizeUrl(input: unknown): string { + if (typeof input !== "string" || !input.trim()) { + throw new RegistryError("Enter the index URL", 400, "INVALID_URL"); + } + let url: URL; + try { + url = new URL(input.trim()); + } catch { + throw new RegistryError("That is not a valid URL", 400, "INVALID_URL"); + } + if (url.protocol !== "https:") { + throw new RegistryError("The index must use https", 400, "INVALID_URL"); + } + url.hash = ""; + return url.toString(); +} + +function baseId(raw: string): string { + const cleaned = raw + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_ID_LENGTH); + return ID_PATTERN.test(cleaned) ? cleaned : "custom"; +} + +function uniqueId(raw: string, config: RegistriesConfig): string { + const taken = new Set([ + OFFICIAL_REGISTRY_ID, + COMMUNITY_REGISTRY_ID, + ...config.custom.map((entry) => entry.id), + ]); + const base = baseId(raw); + if (!taken.has(base)) return base; + for (let n = 2; ; n++) { + const candidate = `${base}-${n}`; + if (!taken.has(candidate)) return candidate; + } +} + +async function inspect( + input: unknown, + config: RegistriesConfig, +): Promise { + const url = normalizeUrl(input); + if ( + url === officialSource().url || + url === communitySource().url || + config.custom.some((entry) => entry.url === url) + ) { + throw new RegistryError( + "This registry is already added", + 409, + "ALREADY_ADDED", + ); + } + let index; + try { + index = await fetchIndexAt(url); + } catch (error) { + throw new RegistryError( + `Could not read the registry: ${ + error instanceof Error ? error.message : String(error) + }`, + 502, + "REGISTRY_UNREACHABLE", + ); + } + if (!index.publicKey) { + throw new RegistryError( + "The index has no publicKey, so its plugins cannot be checked", + 400, + "NO_PUBLIC_KEY", + ); + } + return { + id: uniqueId(index.registry || "custom", config), + name: (index.name || index.registry || url).slice(0, MAX_NAME_LENGTH), + url, + keyId: pluginKeyId(index.publicKey), + publicKey: index.publicKey, + plugins: index.plugins.length, + }; +} + +/** Reads an index so the admin can check its name and key before adding. */ +export async function previewRegistry(url: unknown): Promise { + const { publicKey: _publicKey, ...preview } = await inspect( + url, + await readRegistriesConfig(), + ); + return preview; +} + +/** + * Adds a custom registry. `keyId` is the fingerprint the admin saw in the + * preview; the index is read again and must still name that key. + */ +export async function addRegistry( + url: unknown, + keyId: unknown, +): Promise { + const config = await readRegistriesConfig(); + if (config.custom.length >= MAX_CUSTOM) { + throw new RegistryError( + `At most ${MAX_CUSTOM} custom registries can be added`, + 400, + "TOO_MANY", + ); + } + const preview = await inspect(url, config); + if (typeof keyId !== "string" || keyId !== preview.keyId) { + throw new RegistryError( + "The registry key changed since the preview. Check it again", + 409, + "KEY_MISMATCH", + { keyId: preview.keyId }, + ); + } + const entry: CustomRegistry = { + id: preview.id, + name: preview.name, + url: preview.url, + publicKey: preview.publicKey, + keyId: preview.keyId, + enabled: true, + addedAt: new Date().toISOString(), + }; + config.custom.push(entry); + await writeRegistriesConfig(config); + pluginLogger.info(`Added plugin registry ${entry.id} (${entry.url})`, { + operation: "plugin_registry", + }); + return entry; +} + +export async function setRegistryEnabled( + id: string, + enabled: boolean, +): Promise { + const config = await readRegistriesConfig(); + if (id === OFFICIAL_REGISTRY_ID) { + throw new RegistryError( + "The official registry is always on", + 400, + "OFFICIAL", + ); + } + if (id === COMMUNITY_REGISTRY_ID) { + config.community.enabled = enabled; + } else { + const entry = config.custom.find((candidate) => candidate.id === id); + if (!entry) { + throw new RegistryError("Registry not found", 404, "NOT_FOUND"); + } + entry.enabled = enabled; + } + await writeRegistriesConfig(config); +} + +/** + * Removes a custom registry. Refused while plugins from it are installed: + * its key has to stay trusted for them to load. + */ +export async function removeRegistry(id: string): Promise { + if (id === OFFICIAL_REGISTRY_ID || id === COMMUNITY_REGISTRY_ID) { + throw new RegistryError( + "Built in registries can only be turned off", + 400, + "BUILT_IN", + ); + } + const config = await readRegistriesConfig(); + if (!config.custom.some((candidate) => candidate.id === id)) { + throw new RegistryError("Registry not found", 404, "NOT_FOUND"); + } + const { createCurrentPluginRepository } = + await import("../database/repositories/factory.js"); + const using = (await createCurrentPluginRepository().listAll()) + .filter((record) => record.registryId === id) + .map((record) => record.id); + if (using.length > 0) { + throw new RegistryError( + "Uninstall the plugins from this registry first", + 409, + "REGISTRY_IN_USE", + { plugins: using }, + ); + } + config.custom = config.custom.filter((candidate) => candidate.id !== id); + await writeRegistriesConfig(config); + pluginLogger.info(`Removed plugin registry ${id}`, { + operation: "plugin_registry", + }); +} + +/** Public keys of the custom registries, for a linked desktop. */ +export async function customRegistryKeys(): Promise { + return (await readRegistriesConfig()).custom.map((entry) => entry.publicKey); +} diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index e836c128d..6d98c3058 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -1,11 +1,13 @@ /** - * The official plugin registry: fetching its index and downloading a release. + * Plugin registries: fetching an index and downloading a release. * - * The index is a plain JSON file in Termix-SSH/Termix-Registry. Nothing in it - * is trusted on its own: every download is checked against the sha256 and - * Ed25519 signature listed for it, and the signature must come from a key - * pinned in trust.ts. A tampered index can at most point at a file that then - * fails the check. + * An index is a plain JSON file. Official and community live in + * Termix-SSH/Termix-Registry, custom ones anywhere an admin points to. + * Nothing in an index is trusted on its own: every download is checked + * against the sha256 and Ed25519 signature listed for it, and the signature + * must come from that registry's keys (built in for official and community, + * pinned when an admin added a custom one). A tampered index can at most + * point at a file that then fails the check. */ import crypto from "node:crypto"; @@ -20,13 +22,33 @@ import { } from "@termix-ssh/plugin-sdk/manifest"; import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js"; import { pluginLogger } from "../utils/logger.js"; -import { verifyPluginArtifact } from "./trust.js"; +import { + COMMUNITY_PLUGIN_KEYS, + TRUSTED_PLUGIN_KEYS, + isPluginPublicKey, + verifyPluginArtifact, + type TrustedPluginKey, +} from "./trust.js"; import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js"; import { getPluginsDir } from "./paths.js"; export const OFFICIAL_REGISTRY_ID = "official"; export const OFFICIAL_REGISTRY_URL = "https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json"; +export const COMMUNITY_REGISTRY_ID = "community"; +export const COMMUNITY_REGISTRY_URL = + "https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json"; + +export type RegistryTier = "official" | "community" | "custom"; + +/** Where an index lives and which keys may sign what it lists. */ +export interface RegistrySource { + id: string; + name: string; + url: string; + tier: RegistryTier; + keys: readonly TrustedPluginKey[]; +} const CACHE_MS = 15 * 60_000; const MAX_INDEX_BYTES = 5 * 1024 * 1024; @@ -83,6 +105,8 @@ export interface RegistryIndex { registry: string; name: string; updatedAt?: string; + /** The key the registry signs with, base64. Only read when adding one. */ + publicKey?: string; plugins: RegistryPlugin[]; } @@ -90,6 +114,26 @@ export function getRegistryUrl(): string { return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL; } +export function officialSource(): RegistrySource { + return { + id: OFFICIAL_REGISTRY_ID, + name: "Termix Official Plugins", + url: getRegistryUrl(), + tier: "official", + keys: TRUSTED_PLUGIN_KEYS, + }; +} + +export function communitySource(): RegistrySource { + return { + id: COMMUNITY_REGISTRY_ID, + name: "Termix Community Plugins", + url: process.env.TERMIX_COMMUNITY_REGISTRY_URL || COMMUNITY_REGISTRY_URL, + tier: "community", + keys: COMMUNITY_PLUGIN_KEYS, + }; +} + function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -174,6 +218,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex { registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID, name: asString(index.name) ?? "", updatedAt: asString(index.updatedAt), + publicKey: isPluginPublicKey(index.publicKey) ? index.publicKey : undefined, plugins, }; } @@ -228,8 +273,18 @@ async function readLimited(response: Response, limit: number): Promise { return Buffer.concat(chunks); } -let cache: { index: RegistryIndex; at: number; hash: string } | null = null; -let inflight: Promise | null = null; +interface CacheEntry { + index: RegistryIndex; + at: number; +} + +const caches = new Map(); +const inflight = new Map>(); +const lastErrors = new Map(); + +function cacheKey(source: RegistrySource): string { + return `${source.id} ${source.url}`; +} export interface RegistryStatus { url: string; @@ -237,51 +292,67 @@ export interface RegistryStatus { error: string | null; } -let lastError: string | null = null; - -export function getRegistryStatus(): RegistryStatus { +export function getRegistryStatus( + source: RegistrySource = officialSource(), +): RegistryStatus { + const cache = caches.get(cacheKey(source)); return { - url: getRegistryUrl(), + url: source.url, lastCheckedAt: cache ? new Date(cache.at).toISOString() : null, - error: lastError, + error: lastErrors.get(cacheKey(source)) ?? null, }; } +/** Fetches and parses an index with no caching, for previewing a new one. */ +export async function fetchIndexAt(url: string): Promise { + const response = await fetchFollowing(url); + const body = await readLimited(response, MAX_INDEX_BYTES); + let raw: unknown; + try { + raw = JSON.parse(body.toString("utf8")); + } catch { + throw new Error("the registry index is not valid JSON"); + } + return parseRegistryIndex(raw); +} + export async function fetchRegistryIndex( + source: RegistrySource = officialSource(), options: { force?: boolean } = {}, ): Promise { + const key = cacheKey(source); + const cache = caches.get(key); if (!options.force && cache && Date.now() - cache.at < CACHE_MS) { return cache.index; } - if (inflight) return inflight; + const running = inflight.get(key); + if (running) return running; - inflight = (async () => { + const request = (async () => { try { - const response = await fetchFollowing(getRegistryUrl()); - const body = await readLimited(response, MAX_INDEX_BYTES); - const index = parseRegistryIndex(JSON.parse(body.toString("utf8"))); - cache = { - index, - at: Date.now(), - hash: crypto.createHash("sha256").update(body).digest("hex"), - }; - lastError = null; + const index = await fetchIndexAt(source.url); + caches.set(key, { index, at: Date.now() }); + lastErrors.delete(key); return index; } catch (error) { - lastError = error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); + lastErrors.set(key, message); // A stale index beats none when the registry is briefly unreachable. - if (cache) { - pluginLogger.warn(`Plugin registry check failed: ${lastError}`, { - operation: "plugin_registry", - }); - return cache.index; + const stale = caches.get(key); + if (stale) { + pluginLogger.warn( + `Plugin registry ${source.id} check failed: ${message}`, + { operation: "plugin_registry" }, + ); + return stale.index; } throw error; } finally { - inflight = null; + inflight.delete(key); } })(); - return inflight; + inflight.set(key, request); + return request; } export interface RegistryStats { @@ -289,11 +360,14 @@ export interface RegistryStats { } /** stats.json sits next to index.json and is rebuilt daily by the registry. */ -export function getStatsUrl(): string | null { - if (process.env.TERMIX_PLUGIN_STATS_URL) { +export function getStatsUrl( + source: RegistrySource = officialSource(), +): string | null { + if (source.tier === "custom") return null; + if (source.tier === "official" && process.env.TERMIX_PLUGIN_STATS_URL) { return process.env.TERMIX_PLUGIN_STATS_URL; } - const url = getRegistryUrl(); + const url = source.url; return /\/index\.json$/.test(url) ? url.replace(/\/index\.json$/, "/stats.json") : null; @@ -325,8 +399,10 @@ export function parseRegistryStats(raw: unknown): RegistryStats { return { plugins }; } -export async function fetchRegistryStats(): Promise { - const url = getStatsUrl(); +export async function fetchRegistryStats( + source: RegistrySource = officialSource(), +): Promise { + const url = getStatsUrl(source); if (!url) return null; const response = await fetchFollowing(url); const body = await readLimited(response, MAX_INDEX_BYTES); @@ -335,9 +411,9 @@ export async function fetchRegistryStats(): Promise { /** Test seam. */ export function resetRegistryCache(): void { - cache = null; - inflight = null; - lastError = null; + caches.clear(); + inflight.clear(); + lastErrors.clear(); } /** Every release a channel may install, newest first. */ @@ -414,6 +490,7 @@ export function assertArtifactMatches( export async function downloadRelease( pluginId: string, release: RegistryVersion, + keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS, ): Promise<{ file: string; signatureFile: string }> { if (!isApiCompatible(release.api)) { throw new Error( @@ -433,6 +510,7 @@ export async function downloadRelease( buffer, release.sha256, release.signature, + keys, ); if (verified.ok === false) { throw new Error(`${pluginId} ${release.version}: ${verified.reason}`); diff --git a/src/backend/plugins/trust.ts b/src/backend/plugins/trust.ts index 7b24d116a..714ce9e32 100644 --- a/src/backend/plugins/trust.ts +++ b/src/backend/plugins/trust.ts @@ -28,6 +28,49 @@ export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [ }, ]; +/** + * Signs community plugins after a person reviewed them. Its own tier on + * purpose: it can never replace a bundled plugin or count as official. + */ +export const COMMUNITY_PLUGIN_KEYS: readonly TrustedPluginKey[] = [ + { + id: "be5b24ea07b36624", + publicKey: "LYdHHGJeinCZ7UA8R/o5i1P5W3+dzktTDn5bxp4nPpI=", + addedIn: "26.10.1", + }, +]; + +export type KeyTier = "official" | "community" | "custom"; + +/** Keys of registries an admin added, pinned when they were added. */ +let customKeys: readonly TrustedPluginKey[] = []; + +export function setCustomPluginKeys(keys: readonly TrustedPluginKey[]): void { + customKeys = keys.filter((key) => toPublicKey(key.publicKey) !== null); +} + +export function getCustomPluginKeys(): readonly TrustedPluginKey[] { + return customKeys; +} + +/** First 16 hex chars of the sha256 of a raw base64 public key. */ +export function pluginKeyId(publicKey: string): string { + return crypto + .createHash("sha256") + .update(Buffer.from(publicKey, "base64")) + .digest("hex") + .slice(0, 16); +} + +/** Whether a string is a raw base64 Ed25519 public key. */ +export function isPluginPublicKey(value: unknown): value is string { + return ( + typeof value === "string" && + /^[A-Za-z0-9+/]{43}=$/.test(value) && + toPublicKey(value) !== null + ); +} + export type ArtifactVerification = { ok: true; keyId: string } | { ok: false; reason: string }; @@ -86,6 +129,31 @@ export function verifyPluginArtifact( return { ok: false, reason: "signature does not match a trusted key" }; } +/** + * Checks a file against every key this build trusts, official first, and + * says which tier signed it. + */ +export function verifyAnyTrusted( + buffer: Buffer, + sha256: string, + signature: string, +): { ok: true; keyId: string; tier: KeyTier } | { ok: false; reason: string } { + const tiers: Array<[KeyTier, readonly TrustedPluginKey[]]> = [ + ["official", TRUSTED_PLUGIN_KEYS], + ["community", COMMUNITY_PLUGIN_KEYS], + ["custom", customKeys], + ]; + let reason = "signature does not match a trusted key"; + for (const [tier, keys] of tiers) { + if (keys.length === 0) continue; + const result = verifyPluginArtifact(buffer, sha256, signature, keys); + if (result.ok === true) return { ok: true, keyId: result.keyId, tier }; + reason = result.reason; + if (reason !== "signature does not match a trusted key") break; + } + return { ok: false, reason }; +} + /** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */ export function requireSignedPlugins(): boolean { return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true"; diff --git a/src/backend/sync/client/plugins.ts b/src/backend/sync/client/plugins.ts index 6d6fbcc2c..8a0669954 100644 --- a/src/backend/sync/client/plugins.ts +++ b/src/backend/sync/client/plugins.ts @@ -21,6 +21,7 @@ import { unloadPlugin, } from "../../plugins/index.js"; import { getPluginsDir } from "../../plugins/paths.js"; +import { setSyncedRegistryKeys } from "../../plugins/registries.js"; import type { LoadedPlugin } from "../../plugins/loader.js"; import { createCurrentPluginPermissionGrantRepository, @@ -283,10 +284,12 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { } export async function mirrorPlugins(link: SyncLink): Promise { - const { plugins } = await remoteJson<{ plugins: RemotePlugin[] }>( - link, - "/sync/v2/plugins", - ); + const { plugins, registryKeys } = await remoteJson<{ + plugins: RemotePlugin[]; + registryKeys?: unknown[]; + }>(link, "/sync/v2/plugins"); + // Before any download: a plugin from a custom registry needs its key. + await setSyncedRegistryKeys(Array.isArray(registryKeys) ? registryKeys : []); lastRemote = plugins; const remoteIds = new Set(plugins.map((plugin) => plugin.id)); diff --git a/src/backend/sync/server/routes.ts b/src/backend/sync/server/routes.ts index 5e7caff85..1c3ce4923 100644 --- a/src/backend/sync/server/routes.ts +++ b/src/backend/sync/server/routes.ts @@ -472,7 +472,7 @@ async function listPluginsForDesktop() { * /sync/v2/plugins: * get: * summary: The features this server runs, for a linked desktop to mirror - * description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. + * description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop. * tags: * - Sync * responses: @@ -509,7 +509,9 @@ router.get( ), }); } - res.json({ plugins }); + const { customRegistryKeys } = + await import("../../plugins/registries.js"); + res.json({ plugins, registryKeys: await customRegistryKeys() }); } catch (error) { syncLogger.error("Failed to list plugins for sync", error, { operation: "sync_plugins", diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index 1d0b5a002..558c27447 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PluginManageError } from "../../../plugins/manage.js"; +import { RegistryError } from "../../../plugins/registries.js"; import express from "express"; import type { AddressInfo } from "node:net"; import type { Server } from "node:http"; @@ -194,6 +195,19 @@ vi.mock("../../../utils/audit-logger.js", () => ({ getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), })); +const registriesMock = vi.hoisted(() => ({ + readRegistriesConfig: vi.fn(), + previewRegistry: vi.fn(), + addRegistry: vi.fn(), + setRegistryEnabled: vi.fn(), + removeRegistry: vi.fn(), +})); + +vi.mock("../../../plugins/registries.js", async (importOriginal) => ({ + ...(await importOriginal()), + ...registriesMock, +})); + const onboardingMock = vi.hoisted(() => ({ describeOnboardingPlugins: vi.fn(), applyOnboardingChoices: vi.fn(), @@ -767,6 +781,190 @@ describe("plugins route", () => { }); }); + describe("plugin registries", () => { + const call = ( + method: string, + path: string, + body?: unknown, + user = "admin-1", + ) => + fetch(`${baseUrl}${path}`, { + method, + headers: { "content-type": "application/json", "x-test-user-id": user }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + beforeEach(async () => { + for (const fn of Object.values(registriesMock)) fn.mockReset(); + manageMock.isLinkedDesktop.mockReset(); + manageMock.isLinkedDesktop.mockResolvedValue(false); + auditMock.logAudit.mockClear(); + }); + + it("is admin only", async () => { + for (const [method, path, body] of [ + ["GET", "/plugins/registries", undefined], + [ + "POST", + "/plugins/registries/preview", + { url: "https://x.com/i.json" }, + ], + ["POST", "/plugins/registries", { url: "https://x.com/i.json" }], + ["PATCH", "/plugins/registries/community", { enabled: true }], + ["DELETE", "/plugins/registries/acme", undefined], + ] as const) { + const res = await call(method, path, body, "user-2"); + expect(res.status).toBe(403); + } + expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled(); + expect(registriesMock.removeRegistry).not.toHaveBeenCalled(); + }); + + it("lists the registries without the full public keys", async () => { + registriesMock.readRegistriesConfig.mockResolvedValue({ + community: { enabled: true }, + custom: [ + { + id: "acme", + name: "Acme", + url: "https://acme.example/index.json", + publicKey: "secret-looking", + keyId: "abcd", + enabled: true, + addedAt: "2026-10-11T00:00:00Z", + }, + ], + }); + const res = await call("GET", "/plugins/registries"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.community.enabled).toBe(true); + expect(body.custom[0]).toEqual({ + id: "acme", + name: "Acme", + url: "https://acme.example/index.json", + keyId: "abcd", + enabled: true, + addedAt: "2026-10-11T00:00:00Z", + }); + }); + + it("previews and adds a registry and audits the add", async () => { + registriesMock.previewRegistry.mockResolvedValue({ + id: "acme", + name: "Acme", + url: "https://acme.example/index.json", + keyId: "abcd", + plugins: 2, + }); + const preview = await call("POST", "/plugins/registries/preview", { + url: "https://acme.example/index.json", + }); + expect(preview.status).toBe(200); + expect((await preview.json()).keyId).toBe("abcd"); + + registriesMock.addRegistry.mockResolvedValue({ + id: "acme", + name: "Acme", + url: "https://acme.example/index.json", + publicKey: "k", + keyId: "abcd", + enabled: true, + addedAt: "2026-10-11T00:00:00Z", + }); + const added = await call("POST", "/plugins/registries", { + url: "https://acme.example/index.json", + keyId: "abcd", + }); + expect(added.status).toBe(200); + expect(registriesMock.addRegistry).toHaveBeenCalledWith( + "https://acme.example/index.json", + "abcd", + ); + expect(auditMock.logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "add_plugin_registry", + resourceType: "plugin_registry", + resourceId: "acme", + }), + ); + }); + + it("passes registry errors through with their code", async () => { + registriesMock.addRegistry.mockRejectedValue( + new RegistryError("changed", 409, "KEY_MISMATCH", { keyId: "new" }), + ); + const res = await call("POST", "/plugins/registries", { + url: "https://acme.example/index.json", + keyId: "old", + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "changed", + code: "KEY_MISMATCH", + keyId: "new", + }); + }); + + it("turns a registry on and off and checks the body", async () => { + expect( + (await call("PATCH", "/plugins/registries/community", { enabled: "x" })) + .status, + ).toBe(400); + const res = await call("PATCH", "/plugins/registries/community", { + enabled: true, + }); + expect(res.status).toBe(200); + expect(registriesMock.setRegistryEnabled).toHaveBeenCalledWith( + "community", + true, + ); + }); + + it("refuses to remove a registry still in use", async () => { + registriesMock.removeRegistry.mockRejectedValue( + new RegistryError("in use", 409, "REGISTRY_IN_USE", { + plugins: ["tool"], + }), + ); + const res = await call("DELETE", "/plugins/registries/acme"); + expect(res.status).toBe(409); + expect((await res.json()).plugins).toEqual(["tool"]); + }); + + it("leaves registries to the server on a linked desktop", async () => { + manageMock.isLinkedDesktop.mockResolvedValue(true); + const res = await call("PATCH", "/plugins/registries/community", { + enabled: true, + }); + expect(res.status).toBe(409); + expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled(); + }); + + it("installs from the registry the caller names", async () => { + manageMock.installPlugin.mockReset(); + manageMock.installPlugin.mockResolvedValue({ + id: "neat", + version: "1.0.0", + state: "active", + }); + const res = await call("POST", "/plugins/neat/install", { + registryId: "community", + capabilities: [], + }); + expect(res.status).toBe(200); + expect(manageMock.installPlugin).toHaveBeenCalledWith( + "neat", + expect.objectContaining({ registryId: "community" }), + ); + const bad = await call("POST", "/plugins/neat/install", { + registryId: "../x", + capabilities: [], + }); + expect(bad.status).toBe(400); + }); + }); + describe("install, update, uninstall", () => { const post = (path: string, body: unknown = {}, user = "admin-1") => fetch(`${baseUrl}${path}`, { diff --git a/src/backend/tests/plugins/install-counts.test.ts b/src/backend/tests/plugins/install-counts.test.ts index a3ad0e962..5ba052378 100644 --- a/src/backend/tests/plugins/install-counts.test.ts +++ b/src/backend/tests/plugins/install-counts.test.ts @@ -5,18 +5,36 @@ const state = vi.hoisted(() => ({ plugins: Map; }, fail: false, + sources: [{ id: "official", tier: "official" }] as Array<{ + id: string; + tier: string; + }>, + statsBySource: {} as Record< + string, + { + plugins: Map< + string, + { downloads: number; activeInstalls: number | null } + >; + } | null + >, upserted: [] as Array<{ registryId: string; entries: unknown[] }>, rows: [] as Array<{ pluginId: string; count: number; source: string }>, })); vi.mock("../../plugins/registry-index.js", () => ({ OFFICIAL_REGISTRY_ID: "official", - fetchRegistryStats: async () => { + officialSource: () => ({ id: "official", tier: "official" }), + fetchRegistryStats: async (source: { id: string }) => { if (state.fail) throw new Error("offline"); - return state.stats; + return state.statsBySource[source.id] ?? state.stats; }, })); +vi.mock("../../plugins/registries.js", () => ({ + listSources: async () => state.sources, +})); + vi.mock("../../database/repositories/factory.js", () => ({ createCurrentPluginInstallCountRepository: () => ({ upsertMany: async (registryId: string, entries: unknown[]) => { @@ -38,10 +56,36 @@ const { beforeEach(() => { state.stats = null; state.fail = false; + state.sources = [{ id: "official", tier: "official" }]; + state.statsBySource = {}; state.upserted = []; state.rows = []; }); +describe("syncInstallCounts with more registries", () => { + it("stores counts per registry and skips custom ones", async () => { + state.sources = [ + { id: "official", tier: "official" }, + { id: "community", tier: "community" }, + { id: "mine", tier: "custom" }, + ]; + state.statsBySource = { + official: { + plugins: new Map([["docker", { downloads: 5, activeInstalls: null }]]), + }, + community: { + plugins: new Map([["neat", { downloads: 3, activeInstalls: 2 }]]), + }, + mine: null, + }; + expect(await syncInstallCounts()).toBe(2); + expect(state.upserted.map((entry) => entry.registryId)).toEqual([ + "official", + "community", + ]); + }); +}); + describe("preferredCount", () => { it("prefers active installs and falls back to downloads", () => { expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({ diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index 0161b2fa5..485f41106 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -4,6 +4,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -33,6 +34,8 @@ const db = vi.hoisted(() => ({ const registry = vi.hoisted(() => ({ index: { registry: "official", name: "", plugins: [] as unknown[] }, + /** Indexes of the other registries, by id. Missing means unreachable. */ + others: {} as Record>, download: vi.fn(), })); @@ -97,7 +100,14 @@ vi.mock("../../plugins/registry-index.js", async (importOriginal) => { await importOriginal(); return { ...actual, - fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index), + fetchRegistryIndex: async (source?: { id: string }) => { + if (!source || source.id === "official") { + return actual.parseRegistryIndex(registry.index); + } + const other = registry.others[source.id]; + if (!other) throw new Error(`${source.id} is offline`); + return actual.parseRegistryIndex(other); + }, downloadRelease: registry.download, }; }); @@ -216,6 +226,7 @@ beforeEach(() => { db.settings.clear(); db.grants.length = 0; registry.index.plugins = []; + registry.others = {}; registry.download.mockReset(); }); @@ -856,3 +867,220 @@ describe("release notes", () => { expect(manage.readReleaseNotes(undefined)).toBeUndefined(); }); }); + +describe("more registries", () => { + const customKey = (() => { + const { publicKey } = crypto.generateKeyPairSync("ed25519"); + return publicKey + .export({ format: "der", type: "spki" }) + .subarray(12) + .toString("base64"); + })(); + + function turnOnCommunity(): void { + db.settings.set( + "plugin_registries", + JSON.stringify({ community: { enabled: true }, custom: [] }), + ); + } + + it("lists community plugins only when it is on, never official ids", async () => { + registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])]; + registry.others.community = { + registry: "community", + name: "Community", + plugins: [ + registryEntry("docker", [{ version: "9.0.0" }]), + registryEntry("neat", [{ version: "1.0.0" }]), + ], + }; + await boot(); + + let listing = await manage.listRegistry(); + expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]); + expect(listing.registries.map((r) => r.id)).toEqual(["official"]); + + turnOnCommunity(); + listing = await manage.listRegistry(); + expect( + listing.plugins.map((p) => [p.id, p.registryId, p.tier, p.latestVersion]), + ).toEqual([ + ["docker", "official", "official", "1.0.0"], + ["neat", "community", "community", "1.0.0"], + ]); + expect(listing.registries.find((r) => r.id === "community")).toMatchObject({ + tier: "community", + plugins: 1, + error: null, + }); + }); + + it("keeps listing official plugins when another registry is down", async () => { + registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])]; + turnOnCommunity(); + await boot(); + const listing = await manage.listRegistry(); + expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]); + expect(listing.registries.find((r) => r.id === "community")?.error).toMatch( + /offline/, + ); + }); + + it("installs from the community registry with its keys and records it", async () => { + registry.others.community = { + registry: "community", + plugins: [registryEntry("neat", [{ version: "1.0.0" }])], + }; + turnOnCommunity(); + await boot(); + registry.download.mockImplementation(() => stageArtifact("neat")); + + const result = await manage.installPlugin("neat", { + userId: "admin", + registryId: "community", + capabilities: DEFAULT_CAPS, + }); + expect(result.state).toBe("active"); + const keys = registry.download.mock.calls[0][2] as Array<{ id: string }>; + expect(keys.map((key) => key.id)).toEqual(["be5b24ea07b36624"]); + expect(db.rows.get("neat")).toMatchObject({ + registryId: "community", + tier: "community", + }); + }); + + it("updates a plugin from the registry it came from", async () => { + registry.others.community = { + registry: "community", + plugins: [ + registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]), + ], + }; + turnOnCommunity(); + await boot(); + registry.download.mockImplementation(() => + stageArtifact("neat", { version: "1.0.0" }), + ); + await manage.installPlugin("neat", { + userId: "admin", + registryId: "community", + version: "1.0.0", + capabilities: DEFAULT_CAPS, + }); + await manage.setPluginOptions("neat", { pinned: false }); + + registry.download.mockReset(); + registry.download.mockImplementation(() => + stageArtifact("neat", { version: "1.1.0" }), + ); + const result = await manage.updatePlugin("neat", { userId: "admin" }); + expect(result.version).toBe("1.1.0"); + const [, release, keys] = registry.download.mock.calls[0]; + expect(release.url).toContain("neat-1.1.0"); + expect(keys.map((key: { id: string }) => key.id)).toEqual([ + "be5b24ea07b36624", + ]); + expect(db.rows.get("neat")).toMatchObject({ registryId: "community" }); + }); + + it("stops updating a community plugin once the official registry lists its id", async () => { + registry.others.community = { + registry: "community", + plugins: [ + registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]), + ], + }; + turnOnCommunity(); + await boot(); + registry.download.mockImplementation(() => stageArtifact("neat")); + await manage.installPlugin("neat", { + userId: "admin", + registryId: "community", + version: "1.0.0", + capabilities: DEFAULT_CAPS, + }); + registry.index.plugins = [registryEntry("neat", [{ version: "5.0.0" }])]; + await expect( + manage.updatePlugin("neat", { userId: "admin", version: "1.1.0" }), + ).rejects.toMatchObject({ code: "RESERVED_ID" }); + }); + + it("refuses an official id from another registry", async () => { + registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])]; + registry.others.community = { + registry: "community", + plugins: [registryEntry("docker", [{ version: "9.0.0" }])], + }; + turnOnCommunity(); + await boot(); + await expect( + manage.installPlugin("docker", { + userId: "admin", + registryId: "community", + capabilities: DEFAULT_CAPS, + }), + ).rejects.toMatchObject({ code: "RESERVED_ID" }); + expect(registry.download).not.toHaveBeenCalled(); + }); + + it("refuses a registry that is off", async () => { + registry.others.community = { + registry: "community", + plugins: [registryEntry("neat", [{ version: "1.0.0" }])], + }; + await boot(); + await expect( + manage.installPlugin("neat", { + userId: "admin", + registryId: "community", + capabilities: DEFAULT_CAPS, + }), + ).rejects.toMatchObject({ code: "REGISTRY_OFF" }); + }); + + it("stops trusting a custom registry whose index names a new key", async () => { + const other = crypto + .generateKeyPairSync("ed25519") + .publicKey.export({ format: "der", type: "spki" }) + .subarray(12) + .toString("base64"); + db.settings.set( + "plugin_registries", + JSON.stringify({ + community: { enabled: false }, + custom: [ + { + id: "mine", + name: "Mine", + url: "https://example.com/index.json", + publicKey: customKey, + enabled: true, + addedAt: "2026-10-11T00:00:00Z", + }, + ], + }), + ); + registry.others.mine = { + registry: "mine", + publicKey: customKey, + plugins: [registryEntry("tool", [{ version: "1.0.0" }])], + }; + await boot(); + let listing = await manage.listRegistry(); + expect(listing.plugins.find((p) => p.id === "tool")?.tier).toBe("custom"); + + registry.others.mine.publicKey = other; + listing = await manage.listRegistry(); + expect(listing.plugins.find((p) => p.id === "tool")).toBeUndefined(); + expect(listing.registries.find((r) => r.id === "mine")?.error).toMatch( + /different signing key/, + ); + await expect( + manage.installPlugin("tool", { + userId: "admin", + registryId: "mine", + capabilities: DEFAULT_CAPS, + }), + ).rejects.toMatchObject({ code: "KEY_CHANGED" }); + }); +}); diff --git a/src/backend/tests/plugins/registries.test.ts b/src/backend/tests/plugins/registries.test.ts new file mode 100644 index 000000000..905ee0243 --- /dev/null +++ b/src/backend/tests/plugins/registries.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import crypto from "node:crypto"; + +const state = vi.hoisted(() => ({ + settings: new Map(), + plugins: [] as Array<{ id: string; registryId: string | null }>, + indexes: new Map(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + set: async (key: string, value: string) => + void state.settings.set(key, value), + }), + createCurrentPluginRepository: () => ({ + listAll: async () => state.plugins, + }), +})); + +vi.mock("../../plugins/registry-index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + fetchIndexAt: async (url: string) => { + const raw = state.indexes.get(url); + if (!raw) throw new Error("answered 404"); + return actual.parseRegistryIndex(raw); + }, + }; +}); + +const registries = await import("../../plugins/registries.js"); +const { getCustomPluginKeys, pluginKeyId } = + await import("../../plugins/trust.js"); + +function newKey(): string { + return crypto + .generateKeyPairSync("ed25519") + .publicKey.export({ format: "der", type: "spki" }) + .subarray(12) + .toString("base64"); +} + +const URL_A = "https://example.com/a/index.json"; + +function serve(url: string, index: Record): void { + state.indexes.set(url, { plugins: [], ...index }); +} + +beforeEach(async () => { + state.settings.clear(); + state.plugins = []; + state.indexes.clear(); + await registries.loadCustomRegistryKeys(); +}); + +describe("registry sources", () => { + it("is official only by default", async () => { + const sources = await registries.listSources(); + expect(sources.map((source) => source.id)).toEqual(["official"]); + }); + + it("adds community when it is turned on", async () => { + await registries.setRegistryEnabled("community", true); + const sources = await registries.listSources(); + expect(sources.map((source) => [source.id, source.tier])).toEqual([ + ["official", "official"], + ["community", "community"], + ]); + await registries.setRegistryEnabled("community", false); + expect((await registries.listSources()).map((s) => s.id)).toEqual([ + "official", + ]); + }); + + it("never turns the official registry off", async () => { + await expect( + registries.setRegistryEnabled("official", false), + ).rejects.toMatchObject({ code: "OFFICIAL" }); + }); + + it("reads a broken stored value as official only", () => { + expect(registries.parseRegistriesConfig("{nope")).toEqual({ + community: { enabled: false }, + custom: [], + }); + }); +}); + +describe("adding a custom registry", () => { + it("previews the name, plugin count and key, then pins that key", async () => { + const key = newKey(); + serve(URL_A, { + registry: "acme", + name: "Acme Plugins", + publicKey: key, + plugins: [ + { + id: "tool", + versions: [ + { + version: "1.0.0", + api: "1", + url: "https://example.com/tool.tmxplug", + sha256: "a".repeat(64), + signature: "sig", + }, + ], + }, + ], + }); + const preview = await registries.previewRegistry(URL_A); + expect(preview).toEqual({ + id: "acme", + name: "Acme Plugins", + url: URL_A, + keyId: pluginKeyId(key), + plugins: 1, + }); + + const added = await registries.addRegistry(URL_A, preview.keyId); + expect(added).toMatchObject({ id: "acme", enabled: true, publicKey: key }); + expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]); + const sources = await registries.listSources(); + expect(sources.at(-1)).toMatchObject({ + id: "acme", + tier: "custom", + url: URL_A, + }); + expect(sources.at(-1)?.keys.map((k) => k.publicKey)).toEqual([key]); + }); + + it("refuses when the key changed after the preview", async () => { + serve(URL_A, { registry: "acme", publicKey: newKey() }); + const preview = await registries.previewRegistry(URL_A); + serve(URL_A, { registry: "acme", publicKey: newKey() }); + await expect( + registries.addRegistry(URL_A, preview.keyId), + ).rejects.toMatchObject({ code: "KEY_MISMATCH" }); + expect((await registries.readRegistriesConfig()).custom).toEqual([]); + }); + + it("refuses an index without a key", async () => { + serve(URL_A, { registry: "acme" }); + await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({ + code: "NO_PUBLIC_KEY", + }); + }); + + it("refuses plain http and junk", async () => { + await expect( + registries.previewRegistry("http://example.com/index.json"), + ).rejects.toMatchObject({ code: "INVALID_URL" }); + await expect(registries.previewRegistry("nope")).rejects.toMatchObject({ + code: "INVALID_URL", + }); + await expect(registries.previewRegistry(42)).rejects.toMatchObject({ + code: "INVALID_URL", + }); + }); + + it("refuses an index it cannot read", async () => { + await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({ + code: "REGISTRY_UNREACHABLE", + }); + }); + + it("refuses the same URL twice and the built in ones", async () => { + const key = newKey(); + serve(URL_A, { registry: "acme", publicKey: key }); + await registries.addRegistry(URL_A, pluginKeyId(key)); + await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({ + code: "ALREADY_ADDED", + }); + await expect( + registries.previewRegistry( + "https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json", + ), + ).rejects.toMatchObject({ code: "ALREADY_ADDED" }); + }); + + it("never takes a reserved or used id", async () => { + const urlB = "https://example.com/b/index.json"; + const urlC = "https://example.com/c/index.json"; + const keyA = newKey(); + const keyB = newKey(); + const keyC = newKey(); + serve(URL_A, { registry: "official", publicKey: keyA }); + serve(urlB, { registry: "community", publicKey: keyB }); + serve(urlC, { registry: "Official", publicKey: keyC }); + expect((await registries.addRegistry(URL_A, pluginKeyId(keyA))).id).toBe( + "official-2", + ); + expect((await registries.addRegistry(urlB, pluginKeyId(keyB))).id).toBe( + "community-2", + ); + expect((await registries.addRegistry(urlC, pluginKeyId(keyC))).id).toBe( + "official-3", + ); + }); +}); + +describe("turning off and removing a custom registry", () => { + async function addAcme(): Promise { + const key = newKey(); + serve(URL_A, { registry: "acme", publicKey: key }); + await registries.addRegistry(URL_A, pluginKeyId(key)); + return key; + } + + it("keeps trusting the key of a registry that is off", async () => { + const key = await addAcme(); + await registries.setRegistryEnabled("acme", false); + expect((await registries.listSources()).map((s) => s.id)).toEqual([ + "official", + ]); + expect(await registries.findSource("acme")).toBeNull(); + expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]); + }); + + it("refuses to remove a registry with installed plugins", async () => { + await addAcme(); + state.plugins = [{ id: "tool", registryId: "acme" }]; + await expect(registries.removeRegistry("acme")).rejects.toMatchObject({ + code: "REGISTRY_IN_USE", + details: { plugins: ["tool"] }, + }); + }); + + it("removes it and stops trusting its key", async () => { + await addAcme(); + await registries.removeRegistry("acme"); + expect((await registries.readRegistriesConfig()).custom).toEqual([]); + expect(getCustomPluginKeys()).toEqual([]); + }); + + it("only turns built in registries off", async () => { + await expect(registries.removeRegistry("community")).rejects.toMatchObject({ + code: "BUILT_IN", + }); + await expect(registries.removeRegistry("nope")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +describe("keys from a linked server", () => { + it("trusts them next to the local ones and survives a reload", async () => { + const key = newKey(); + await registries.setSyncedRegistryKeys([key, "junk", 42]); + expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]); + await registries.loadCustomRegistryKeys(); + expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]); + await registries.setSyncedRegistryKeys([]); + expect(getCustomPluginKeys()).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index 0efacbdb7..5c25f5c2f 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -17,14 +17,27 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => { await importOriginal(); return { ...actual, - verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) => - actual.verifyPluginArtifact(buffer, sha256, signature, [ - testKey.value as never, - ]), + // The built in official keys are swapped for the test key. Keys a + // caller passes for another registry are used as they are. + verifyPluginArtifact: ( + buffer: Buffer, + sha256: string, + signature: string, + keys?: readonly unknown[], + ) => + actual.verifyPluginArtifact( + buffer, + sha256, + signature, + keys && keys !== actual.TRUSTED_PLUGIN_KEYS + ? (keys as never) + : [testKey.value as never], + ), }; }); import { + communitySource, downloadRelease, fetchRegistryIndex, fetchRegistryStats, @@ -35,6 +48,7 @@ import { parseRegistryIndex, parseRegistryStats, resetRegistryCache, + type RegistrySource, type RegistryVersion, } from "../../plugins/registry-index.js"; @@ -91,6 +105,25 @@ afterEach(() => { fs.rmSync(dataDir, { recursive: true, force: true }); }); +describe("parseRegistryIndex publicKey", () => { + const key = publicKey + .export({ format: "der", type: "spki" }) + .subarray(12) + .toString("base64"); + + it("keeps a valid key and drops anything else", () => { + expect(parseRegistryIndex({ publicKey: key, plugins: [] }).publicKey).toBe( + key, + ); + expect( + parseRegistryIndex({ publicKey: "not a key", plugins: [] }).publicKey, + ).toBeUndefined(); + expect( + parseRegistryIndex({ publicKey: 42, plugins: [] }).publicKey, + ).toBeUndefined(); + }); +}); + describe("parseRegistryIndex", () => { it("keeps valid entries and sorts versions newest first", () => { const index = parseRegistryIndex({ @@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => { fetchMock.mockResolvedValueOnce(response(body)); const first = await fetchRegistryIndex(); fetchMock.mockResolvedValueOnce(response("down", 503)); - expect(await fetchRegistryIndex({ force: true })).toBe(first); + expect(await fetchRegistryIndex(undefined, { force: true })).toBe(first); + }); + + it("caches each registry on its own", async () => { + const other: RegistrySource = { + id: "mine", + name: "Mine", + url: "https://example.com/mine/index.json", + tier: "custom", + keys: [], + }; + fetchMock + .mockResolvedValueOnce(response(body)) + .mockResolvedValueOnce( + response(JSON.stringify({ registry: "mine", plugins: [] })), + ); + const official = await fetchRegistryIndex(); + const mine = await fetchRegistryIndex(other); + expect(official.plugins).toHaveLength(1); + expect(mine.plugins).toHaveLength(0); + expect(fetchMock.mock.calls[1][0]).toBe(other.url); + expect(await fetchRegistryIndex(other)).toBe(mine); + expect(fetchMock).toHaveBeenCalledTimes(2); }); it("throws when there is nothing cached", async () => { @@ -384,6 +439,38 @@ describe("downloadRelease", () => { ).rejects.toThrow(/sha256/); }); + it("checks against the keys of the registry it came from", async () => { + const signed = sign(artifact); + const own = crypto.generateKeyPairSync("ed25519"); + const ownKey = { + id: "own", + publicKey: own.publicKey + .export({ format: "der", type: "spki" }) + .subarray(12) + .toString("base64"), + addedIn: "admin", + }; + fetchMock.mockResolvedValueOnce(response(artifact)); + await expect( + downloadRelease("docker", version({ ...signed, size: artifact.length }), [ + ownKey, + ]), + ).rejects.toThrow(/trusted key/); + + const digest = crypto.createHash("sha256").update(artifact).digest(); + fetchMock.mockResolvedValueOnce(response(artifact)); + const result = await downloadRelease( + "docker", + version({ + sha256: digest.toString("hex"), + signature: crypto.sign(null, digest, own.privateKey).toString("base64"), + size: artifact.length, + }), + [ownKey], + ); + expect(fs.readFileSync(result.file)).toEqual(artifact); + }); + it("refuses a release built for another plugin API", async () => { await expect( downloadRelease("docker", version({ api: "2" })), @@ -407,6 +494,21 @@ describe("registry stats", () => { ); }); + it("has a community stats file and none for custom registries", () => { + expect(getStatsUrl(communitySource())).toBe( + "https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/stats.json", + ); + expect( + getStatsUrl({ + id: "mine", + name: "Mine", + url: "https://example.com/index.json", + tier: "custom", + keys: [], + }), + ).toBeNull(); + }); + it("keeps valid counts and drops the rest", () => { const stats = parseRegistryStats({ plugins: { diff --git a/src/backend/tests/plugins/signed-plugins-gate.test.ts b/src/backend/tests/plugins/signed-plugins-gate.test.ts index 0ba3fb3da..8916366ce 100644 --- a/src/backend/tests/plugins/signed-plugins-gate.test.ts +++ b/src/backend/tests/plugins/signed-plugins-gate.test.ts @@ -15,6 +15,8 @@ import { PluginLoader } from "../../plugins/loader.js"; const testKey = vi.hoisted(() => ({ privateKey: null as crypto.KeyObject | null, key: { id: "test", publicKey: "", addedIn: "2.9.0" }, + communityPrivateKey: null as crypto.KeyObject | null, + community: { id: "community", publicKey: "", addedIn: "26.10.1" }, })); vi.mock("../../plugins/trust.js", async (importActual) => { @@ -23,6 +25,17 @@ vi.mock("../../plugins/trust.js", async (importActual) => { ...actual, verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) => actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]), + verifyAnyTrusted: (buffer: Buffer, sha256: string, signature: string) => { + const official = actual.verifyPluginArtifact(buffer, sha256, signature, [ + testKey.key, + ]); + if (official.ok === true) return { ...official, tier: "official" }; + const community = actual.verifyPluginArtifact(buffer, sha256, signature, [ + testKey.community, + ]); + if (community.ok === true) return { ...community, tier: "community" }; + return community; + }, }; }); @@ -42,6 +55,12 @@ vi.mock("../../database/repositories/factory.js", () => ({ .export({ format: "der", type: "spki" }) .subarray(12) .toString("base64"); + const community = crypto.generateKeyPairSync("ed25519"); + testKey.communityPrivateKey = community.privateKey; + testKey.community.publicKey = community.publicKey + .export({ format: "der", type: "spki" }) + .subarray(12) + .toString("base64"); } const cleanups: Array<() => void> = []; @@ -68,7 +87,7 @@ function setup() { async function writeArtifact( dir: string, id: string, - sig: "valid" | "wrong-key" | "none", + sig: "valid" | "community" | "wrong-key" | "none", version = "1.0.0", ): Promise { const build = tempRoot("termix-build-"); @@ -82,7 +101,9 @@ async function writeArtifact( const signer = sig === "valid" ? testKey.privateKey - : crypto.generateKeyPairSync("ed25519").privateKey; + : sig === "community" + ? testKey.communityPrivateKey + : crypto.generateKeyPairSync("ed25519").privateKey; const digest = crypto .createHash("sha256") .update(fs.readFileSync(file)) @@ -171,6 +192,27 @@ describe("updating a bundled plugin", () => { expect(plugin?.source).toBe("bundled"); expect(plugin?.artifact).toBe(file); expect(plugin?.signedBy).toBe("test"); + expect(plugin?.signedTier).toBe("official"); + }); + + it("keeps the bundled copy for a community signed newer archive", async () => { + const { userDir } = setup(); + await writeArtifact(userDir, "bundled-one", "community", "2.0.0"); + const loader = new PluginLoader(); + await loader.loadAll(); + expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0"); + }); + + it("loads a community signed plugin and records its tier", async () => { + const { userDir } = setup(); + process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true"; + await writeArtifact(userDir, "community-one", "community"); + const loader = new PluginLoader(); + await loader.loadAll(); + const plugin = loader.get("community-one"); + expect(plugin?.source).toBe("user"); + expect(plugin?.signedBy).toBe("community"); + expect(plugin?.signedTier).toBe("community"); }); it("keeps the bundled copy for an unsigned newer archive", async () => { diff --git a/src/backend/tests/plugins/trust.test.ts b/src/backend/tests/plugins/trust.test.ts index 5e7156e47..882462e49 100644 --- a/src/backend/tests/plugins/trust.test.ts +++ b/src/backend/tests/plugins/trust.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import crypto from "node:crypto"; import { + COMMUNITY_PLUGIN_KEYS, + TRUSTED_PLUGIN_KEYS, + isPluginPublicKey, + pluginKeyId, requireSignedPlugins, + setCustomPluginKeys, + verifyAnyTrusted, verifyPluginArtifact, type TrustedPluginKey, } from "../../plugins/trust.js"; @@ -97,6 +103,61 @@ describe("verifyPluginArtifact", () => { }); }); +describe("key tiers", () => { + const artifact = Buffer.from("a plugin archive"); + + it("derives key ids the same way the built in keys were made", () => { + for (const key of [...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS]) { + expect(pluginKeyId(key.publicKey)).toBe(key.id); + expect(isPluginPublicKey(key.publicKey)).toBe(true); + } + expect(isPluginPublicKey("bm9wZQ==")).toBe(false); + expect(isPluginPublicKey(42)).toBe(false); + }); + + it("keeps the community key apart from the official ones", () => { + const official = new Set(TRUSTED_PLUGIN_KEYS.map((key) => key.id)); + for (const key of COMMUNITY_PLUGIN_KEYS) { + expect(official.has(key.id)).toBe(false); + } + }); + + it("verifies a custom key only while it is set, as the custom tier", () => { + const { key, privateKey } = keyPair(); + const { sha256, signature } = signed(artifact, privateKey); + expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false); + + setCustomPluginKeys([key]); + expect(verifyAnyTrusted(artifact, sha256, signature)).toEqual({ + ok: true, + keyId: "test", + tier: "custom", + }); + + setCustomPluginKeys([]); + expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false); + }); + + it("drops a custom key that is not a valid public key", () => { + setCustomPluginKeys([{ id: "bad", publicKey: "bm9wZQ==", addedIn: "x" }]); + const { privateKey } = keyPair(); + const { sha256, signature } = signed(artifact, privateKey); + expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false); + setCustomPluginKeys([]); + }); + + it("stops at a sha256 mismatch", () => { + const { key, privateKey } = keyPair(); + setCustomPluginKeys([key]); + const { signature } = signed(artifact, privateKey); + expect(verifyAnyTrusted(artifact, "0".repeat(64), signature)).toEqual({ + ok: false, + reason: "sha256 does not match the file", + }); + setCustomPluginKeys([]); + }); +}); + describe("requireSignedPlugins", () => { it("is on only for the exact value true", () => { const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS; diff --git a/src/backend/tests/sync/client/plugins.test.ts b/src/backend/tests/sync/client/plugins.test.ts index dc442b16d..569776a35 100644 --- a/src/backend/tests/sync/client/plugins.test.ts +++ b/src/backend/tests/sync/client/plugins.test.ts @@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({ install: vi.fn(), fetch: vi.fn(), loadBundled: vi.fn(), + syncedKeys: vi.fn(async (_keys: unknown[]) => {}), })); vi.mock("../../../utils/logger.js", () => ({ @@ -29,6 +30,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), createCurrentPluginPermissionGrantRepository: () => ({}), })); +vi.mock("../../../plugins/registries.js", () => ({ + setSyncedRegistryKeys: state.syncedKeys, +})); vi.mock("../../../sync/client/http.js", () => ({ remoteFetch: state.fetch, remoteJson: vi.fn(), @@ -100,6 +104,19 @@ afterEach(() => { }); describe("mirrorPlugins", () => { + it("trusts the server's custom registry keys before downloading", async () => { + vi.mocked(remoteJson).mockResolvedValue({ + plugins: [], + registryKeys: ["a-key"], + }); + await mirrorPlugins({} as never); + expect(state.syncedKeys).toHaveBeenCalledWith(["a-key"]); + + vi.mocked(remoteJson).mockResolvedValue({ plugins: [] }); + await mirrorPlugins({} as never); + expect(state.syncedKeys).toHaveBeenLastCalledWith([]); + }); + it("keeps the installed copy when the download fails", async () => { serverHas("1.1.0"); state.fetch.mockResolvedValue({ ok: false, status: 502 }); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 308beac7d..d93d52ac5 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -291,8 +291,14 @@ export interface RegistryPluginVersion { size: number; } +export type RegistryTier = "official" | "community" | "custom"; + export interface RegistryPluginEntry { id: string; + /** Which registry this entry comes from. */ + registryId?: string; + registryName?: string; + tier?: RegistryTier; name: string; description: string; author: string; @@ -320,6 +326,16 @@ export interface RegistryPluginEntry { installCountSource: string | null; } +export interface RegistryListingSource { + id: string; + name: string; + tier: RegistryTier; + url: string; + lastCheckedAt: string | null; + error: string | null; + plugins: number; +} + export interface RegistryListing { registry: { id: string; @@ -327,6 +343,7 @@ export interface RegistryListing { lastCheckedAt: string | null; error: string | null; }; + registries?: RegistryListingSource[]; managedByServer: boolean; plugins: RegistryPluginEntry[]; } @@ -375,7 +392,7 @@ export async function getPluginRegistry( /** `capabilities` is exactly what the consent prompt showed. */ export async function installPlugin( pluginId: string, - options: { version?: string; capabilities: string[] }, + options: { version?: string; capabilities: string[]; registryId?: string }, ): Promise<{ id: string; version: string; state: string }> { try { const response = await rbacApi.post( @@ -407,6 +424,61 @@ export async function updatePlugin( } } +export interface CustomRegistryInfo { + id: string; + name: string; + url: string; + keyId: string; + enabled: boolean; + addedAt: string; +} + +export interface RegistriesState { + managedByServer: boolean; + official: { id: string; url: string }; + community: { id: string; url: string; enabled: boolean }; + custom: CustomRegistryInfo[]; +} + +export interface RegistryPreview { + id: string; + name: string; + url: string; + keyId: string; + plugins: number; +} + +export async function getRegistries(): Promise { + const response = await rbacApi.get("/plugins/registries"); + return response.data; +} + +export async function previewRegistry(url: string): Promise { + const response = await rbacApi.post("/plugins/registries/preview", { url }); + return response.data; +} + +export async function addRegistry( + url: string, + keyId: string, +): Promise { + const response = await rbacApi.post("/plugins/registries", { url, keyId }); + return response.data; +} + +export async function setRegistryEnabled( + id: string, + enabled: boolean, +): Promise { + await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, { + enabled, + }); +} + +export async function removeRegistry(id: string): Promise { + await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`); +} + export async function updateAllPlugins(): Promise { try { const response = await rbacApi.post("/plugins/update-all"); diff --git a/src/ui/lib/docs-pages.json b/src/ui/lib/docs-pages.json index ecfc2a689..907cdbad5 100644 --- a/src/ui/lib/docs-pages.json +++ b/src/ui/lib/docs-pages.json @@ -38,5 +38,7 @@ "pluginCatalog": "plugins", "cli": "cli", "develop": "develop", - "communityRegistry": "develop/community-registry" + "communityRegistry": "develop/community-registry", + "pluginRegistries": "guide/plugins#registries", + "hostRegistry": "develop/registries" } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 53da633bf..d2bc00b7d 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1899,7 +1899,8 @@ "tabs": { "installed": "Installed", "browse": "Browse", - "updates": "Updates" + "updates": "Updates", + "registries": "Registries" }, "status": { "summary": "{{installed}} installed, {{running}} running", @@ -2084,8 +2085,44 @@ "noneInstalled": "No plugins installed yet. Browse to add some.", "nothingHere": "Nothing here.", "nothingHereHint": "Try another category or search.", - "communitySoon": "Community plugins are coming in a later update. Plugin authors can already submit theirs to the community registry.", "communitySubmit": "How to submit", + "registries": { + "intro": "Registries are lists of plugins you can install. The official one is always on. You can turn on the community registry or add your own.", + "hostYourOwn": "Host your own registry", + "add": "Add registry", + "addTitle": "Add a registry", + "addHint": "Paste the link to the registry's index.json. Termix reads it and shows the key it signs plugins with. Only add registries from people you trust.", + "urlLabel": "Index URL", + "check": "Check", + "addConfirm": "Trust and add", + "added": "Added {{name}}", + "customWarning": "Plugins from this registry are not reviewed by the Termix team. Only the owner of the registry decides what is in it. Every download must be signed by the key above.", + "keyFingerprint": "Key {{keyId}}", + "pluginCount_one": "{{count}} plugin", + "pluginCount_other": "{{count}} plugins", + "officialName": "Official", + "communityName": "Community", + "alwaysOn": "Always on", + "communityHint": "Plugins made by other people. Each version is checked by a person and by automated checks before it is listed.", + "communityConfirmTitle": "Turn on community plugins?", + "communityConfirmBody": "Community plugins are made by other people, not the Termix team. Every version is checked by a person and by automated checks before it is listed, but mistakes can still get through. Only install plugins you trust and check what each one asks for.", + "communityConfirm": "Turn on", + "communityBadge": "Community", + "communityBadgeHint": "From the community registry. Made by other people and reviewed before it was listed.", + "customBadge": "Custom", + "customBadgeHint": "From {{name}}, a registry an admin added. Not reviewed by the Termix team.", + "remove": "Remove", + "removeTitle": "Remove {{name}}?", + "removeBody": "Its plugins will no longer show in Browse. You can add it again later.", + "removed": "Removed {{name}}", + "inUse": "Uninstall the plugins from {{name}} first: {{list}}", + "changeFailed": "Could not change the registry: {{error}}", + "loadFailed": "Could not load registries: {{error}}", + "unreachable": "Could not read it: {{error}}", + "filter": "Registry", + "allRegistries": "All registries", + "browseHint": "Turn on community plugins or add your own registry in the Registries tab." + }, "upToDate": "Everything is up to date.", "lastChecked": "Last checked {{time}}", "updatesReady_one": "{{count}} update ready.", diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index 5aaaf6d12..8a23f6114 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -33,6 +33,7 @@ import { CapabilityRow, InstallCountFact, PluginIconBox, + SourceBadge, UnverifiedBadge, } from "./plugin-bits"; import { @@ -108,6 +109,10 @@ export function PluginDetail({ {plugin.unverified && } + {plugin.isBeta && } {plugin.author && {plugin.author}} {plugin.version ? ( diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index d4a8e1d0d..14bdeb276 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -6,6 +6,7 @@ import { Columns3, Download, Globe, + Library, Puzzle, RefreshCw, RotateCcw, @@ -42,8 +43,10 @@ import { BetaBadge, InstallCountFact, PluginIconBox, + SourceBadge, UnverifiedBadge, } from "./plugin-bits"; +import { RegistriesSection } from "./RegistriesSection"; import { PluginConsentPrompt } from "./PluginConsentPrompt"; import { PluginDetail } from "./PluginDetail"; import { @@ -60,7 +63,7 @@ import { usePluginsManager, type PluginsManager } from "./use-plugins-manager"; import { docsUrl } from "@/lib/docs"; import { DocsLink } from "@/components/docs-link"; -type Section = "installed" | "browse" | "updates"; +type Section = "installed" | "browse" | "updates" | "registries"; const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const; type GridSize = (typeof GRID_SIZES)[number]; @@ -146,6 +149,11 @@ function PluginsTabBody() { icon: , count: updates.filter((p) => !p.pinnedVersion).length || undefined, }, + { + id: "registries", + label: t("plugins.manager.tabs.registries"), + icon: , + }, ]; return ( @@ -217,12 +225,14 @@ function PluginsTabBody() { layout={layout} layoutControls={layoutControls} /> - ) : ( + ) : section === "updates" ? ( + ) : ( + )} @@ -442,6 +452,7 @@ function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) { {plugin.name} {plugin.unverified && } + {plugin.isBeta && } ); @@ -704,6 +715,10 @@ function InstalledCard({ facts={ <> {plugin.unverified && } + {plugin.isBeta && } {plugin.author && {plugin.author}} v{plugin.version} @@ -935,14 +950,20 @@ function BrowseSection({ }) { const { t } = useTranslation(); const [category, setCategory] = useState(null); + const [registry, setRegistry] = useState(null); const [sort, setSort] = useState("popular"); const categories = useMemo(() => categoriesOf(plugins), [plugins]); + const registries = manager.registries; + const registryFilter = registries.some((entry) => entry.id === registry) + ? registry + : null; const rows = sortPlugins( plugins.filter( (p) => matchesQuery(p, query) && - (category === null || p.category === category), + (category === null || p.category === category) && + (registryFilter === null || p.registryId === registryFilter), ), sort, ); @@ -1011,6 +1032,35 @@ function BrowseSection({ placeholder={t("plugins.manager.searchPlugins")} >
+ {registries.length > 1 && ( + + )} {layoutControls}
@@ -1063,7 +1113,7 @@ function BrowseSection({ )}

- {t("plugins.manager.communitySoon")}{" "} + {t("plugins.manager.registries.browseHint")}{" "} {t("plugins.manager.communitySubmit")} @@ -1091,6 +1141,10 @@ function BrowseCard({ onOpen={onOpen} facts={ <> + {plugin.author && {plugin.author}} {plugin.latestVersion && v{plugin.latestVersion}} {plugin.category && {plugin.category}} diff --git a/src/ui/plugins/RegistriesSection.tsx b/src/ui/plugins/RegistriesSection.tsx new file mode 100644 index 000000000..1ed3a1f84 --- /dev/null +++ b/src/ui/plugins/RegistriesSection.tsx @@ -0,0 +1,390 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Library, Trash2 } from "lucide-react"; +import { Input } from "@/components/input"; +import { FakeSwitch } from "@/components/section-card"; +import { PANEL } from "@/components/panel-layout"; +import { + AddButton, + FormFooter, + ListBadge, + ListRow, + ListRowAction, +} from "@/components/list-kit"; +import { InlineView, useConfirm } from "@/components/surface/surface-scope"; +import { DocsLink } from "@/components/docs-link"; +import { + addRegistry, + getRegistries, + previewRegistry, + removeRegistry, + setRegistryEnabled, + type RegistriesState, + type RegistryListingSource, + type RegistryPreview, +} from "@/api/plugins-api"; +import type { PluginsManager } from "./use-plugins-manager"; + +function errorOf(error: unknown): { + message: string; + code?: string; + data: Record; +} { + const data = (error as { response?: { data?: Record } }) + ?.response?.data; + return { + message: + typeof data?.error === "string" + ? data.error + : error instanceof Error + ? error.message + : String(error), + code: typeof data?.code === "string" ? data.code : undefined, + data: data ?? {}, + }; +} + +export function RegistriesSection({ manager }: { manager: PluginsManager }) { + const { t } = useTranslation(); + const confirm = useConfirm(); + const [state, setState] = useState(null); + const [adding, setAdding] = useState(false); + const [busy, setBusy] = useState(null); + + const load = useCallback(async () => { + try { + setState(await getRegistries()); + } catch (error) { + toast.error( + t("plugins.manager.registries.loadFailed", { + error: errorOf(error).message, + }), + ); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const statusOf = (id: string): RegistryListingSource | undefined => + manager.registries.find((entry) => entry.id === id); + + const toggle = async (id: string, enabled: boolean) => { + if (enabled && id === "community") { + const ok = await confirm({ + title: t("plugins.manager.registries.communityConfirmTitle"), + description: t("plugins.manager.registries.communityConfirmBody"), + confirmLabel: t("plugins.manager.registries.communityConfirm"), + destructive: false, + }); + if (!ok) return; + } + setBusy(id); + try { + await setRegistryEnabled(id, enabled); + await load(); + await manager.refresh(); + } catch (error) { + toast.error( + t("plugins.manager.registries.changeFailed", { + error: errorOf(error).message, + }), + ); + } finally { + setBusy(null); + } + }; + + const remove = async (id: string, name: string) => { + const ok = await confirm({ + title: t("plugins.manager.registries.removeTitle", { name }), + description: t("plugins.manager.registries.removeBody"), + confirmLabel: t("plugins.manager.registries.remove"), + }); + if (!ok) return; + setBusy(id); + try { + await removeRegistry(id); + toast.success(t("plugins.manager.registries.removed", { name })); + await load(); + await manager.refresh(); + } catch (error) { + const info = errorOf(error); + toast.error( + info.code === "REGISTRY_IN_USE" + ? t("plugins.manager.registries.inUse", { + name, + list: ((info.data.plugins as string[]) ?? []).join(", "), + }) + : t("plugins.manager.registries.changeFailed", { + error: info.message, + }), + ); + } finally { + setBusy(null); + } + }; + + const readOnly = manager.managedByServer || state?.managedByServer; + + const meta = (id: string, url: string, extra?: string) => { + const status = statusOf(id); + const parts = [url]; + if (status && !status.error) { + parts.push( + t("plugins.manager.registries.pluginCount", { count: status.plugins }), + ); + } + if (extra) parts.push(extra); + return parts.join(" | "); + }; + + const errorLine = (id: string) => { + const error = statusOf(id)?.error; + return error ? ( + + {t("plugins.manager.registries.unreachable", { error })} + + ) : null; + }; + + return ( +

+
+

+ {t("plugins.manager.registries.intro")}{" "} + + {t("plugins.manager.registries.hostYourOwn")} + +

+ {!readOnly && ( + setAdding(true)} + /> + )} +
+ + {state && ( +
+ } + title={t("plugins.manager.registries.officialName")} + badges={ + + {t("plugins.manager.registries.alwaysOn")} + + } + meta={meta("official", state.official.url)} + > + {errorLine("official")} + + + } + tone={state.community.enabled ? "brand" : "muted"} + dimmed={!state.community.enabled} + title={t("plugins.manager.registries.communityName")} + meta={meta("community", state.community.url)} + trailing={ + void toggle("community", next)} + /> + } + > + + {t("plugins.manager.registries.communityHint")}{" "} + + {t("plugins.manager.communitySubmit")} + + + {state.community.enabled && errorLine("community")} + + + {state.custom.map((entry, index) => ( + } + tone={entry.enabled ? "warning" : "muted"} + dimmed={!entry.enabled} + title={entry.name} + badges={ + + {t("plugins.manager.registries.customBadge")} + + } + meta={meta( + entry.id, + entry.url, + t("plugins.manager.registries.keyFingerprint", { + keyId: entry.keyId, + }), + )} + trailing={ + void toggle(entry.id, next)} + /> + } + actions={ + readOnly ? undefined : ( + void remove(entry.id, entry.name)} + > + + + ) + } + > + {entry.enabled && errorLine(entry.id)} + + ))} +
+ )} + + { + setAdding(false); + await load(); + await manager.refresh(); + }} + /> +
+ ); +} + +function AddRegistryView({ + open, + onOpenChange, + onAdded, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdded: () => Promise; +}) { + const { t } = useTranslation(); + const [url, setUrl] = useState(""); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [working, setWorking] = useState(false); + + useEffect(() => { + if (!open) { + setUrl(""); + setPreview(null); + setError(null); + } + }, [open]); + + const check = async () => { + setWorking(true); + setError(null); + setPreview(null); + try { + setPreview(await previewRegistry(url.trim())); + } catch (failure) { + setError(errorOf(failure).message); + } finally { + setWorking(false); + } + }; + + const add = async () => { + if (!preview) return; + setWorking(true); + setError(null); + try { + const added = await addRegistry(preview.url, preview.keyId); + toast.success( + t("plugins.manager.registries.added", { name: added.name }), + ); + await onAdded(); + } catch (failure) { + const info = errorOf(failure); + if (info.code === "KEY_MISMATCH") setPreview(null); + setError(info.message); + } finally { + setWorking(false); + } + }; + + return ( + } + footer={ + onOpenChange(false)} + onSave={() => void (preview ? add() : check())} + saveLabel={ + preview + ? t("plugins.manager.registries.addConfirm") + : t("plugins.manager.registries.check") + } + saving={working} + disabled={!url.trim()} + /> + } + > +
+

+ {t("plugins.manager.registries.addHint")} +

+ { + setUrl(e.target.value); + setPreview(null); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && url.trim() && !working) { + e.preventDefault(); + void (preview ? add() : check()); + } + }} + placeholder="https://example.com/termix/index.json" + className="h-8 text-xs" + aria-label={t("plugins.manager.registries.urlLabel")} + autoFocus + /> + {error && ( +

{error}

+ )} + {preview && ( +
+ {preview.name} + + {t("plugins.manager.registries.pluginCount", { + count: preview.plugins, + })} + + + {t("plugins.manager.registries.keyFingerprint", { + keyId: preview.keyId, + })} + +

+ {t("plugins.manager.registries.customWarning")} +

+
+ )} +
+
+ ); +} diff --git a/src/ui/plugins/plugin-bits.tsx b/src/ui/plugins/plugin-bits.tsx index b6c3ae5b9..124bdb859 100644 --- a/src/ui/plugins/plugin-bits.tsx +++ b/src/ui/plugins/plugin-bits.tsx @@ -88,6 +88,32 @@ export function UnverifiedBadge() { ); } +/** Marks a plugin from the community registry or a custom one. */ +export function SourceBadge({ + source, + registryName, +}: { + source: string; + registryName?: string | null; +}) { + const { t } = useTranslation(); + if (source !== "community" && source !== "custom") return null; + const label = + source === "community" + ? t("plugins.manager.registries.communityBadge") + : (registryName ?? t("plugins.manager.registries.customBadge")); + return ( + + {label} + + ); +} + export function BetaBadge() { const { t } = useTranslation(); return ( diff --git a/src/ui/plugins/plugin-model.ts b/src/ui/plugins/plugin-model.ts index c91ef59b1..dd33d230d 100644 --- a/src/ui/plugins/plugin-model.ts +++ b/src/ui/plugins/plugin-model.ts @@ -67,9 +67,13 @@ export interface PluginEntry { source: PluginSource; /** Installed from a file or folder with no registry signature behind it. */ unverified: boolean; + /** The registry it is listed in or was installed from. */ + registryId: string | null; + registryName: string | null; } -export type PluginSource = "bundled" | "official" | "unverified"; +export type PluginSource = + "bundled" | "official" | "community" | "custom" | "unverified"; export function isPrerelease(version: string | null | undefined): boolean { return Boolean( @@ -97,7 +101,10 @@ export function pluginSource(summary: PluginSummary): PluginSource { if (summary.source === "bundled" || summary.tier === "bundled") { return "bundled"; } - return summary.signedBy ? "official" : "unverified"; + if (!summary.signedBy) return "unverified"; + return summary.tier === "community" || summary.tier === "custom" + ? summary.tier + : "official"; } export function pluginStatus(summary: PluginSummary): PluginStatus { @@ -158,6 +165,8 @@ export function mergePlugins( installCountSource: entry?.installCountSource ?? null, source: pluginSource(summary), unverified: pluginSource(summary) === "unverified", + registryId: entry?.registryId ?? summary.registryId ?? null, + registryName: entry?.registryName ?? null, }); } @@ -198,8 +207,10 @@ export function mergePlugins( publishedAt: latestPublished(entry.versions), installCount: entry.installCount ?? null, installCountSource: entry.installCountSource ?? null, - source: "official", + source: entry.tier ?? "official", unverified: false, + registryId: entry.registryId ?? "official", + registryName: entry.registryName ?? null, }); } @@ -239,6 +250,8 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry { installCountSource: null, source: "unverified", unverified: true, + registryId: null, + registryName: null, }; } diff --git a/src/ui/plugins/use-plugins-manager.ts b/src/ui/plugins/use-plugins-manager.ts index e92f69692..1a0d2b572 100644 --- a/src/ui/plugins/use-plugins-manager.ts +++ b/src/ui/plugins/use-plugins-manager.ts @@ -183,6 +183,7 @@ export function usePluginsManager() { const result = await installPlugin(plugin.id, { version: version === plugin.latestVersion ? undefined : version, capabilities, + registryId: plugin.registryId ?? undefined, }); if (result.state === "failed" || result.state === "blocked") { toast.error( @@ -585,6 +586,8 @@ export function usePluginsManager() { registryError, managedByServer: registry?.managedByServer ?? false, lastCheckedAt: registry?.registry.lastCheckedAt ?? null, + registries: registry?.registries ?? [], + reload: () => load(), busy, consent, setConsent, diff --git a/src/ui/tests/plugins/PluginsTab.test.tsx b/src/ui/tests/plugins/PluginsTab.test.tsx index ca1c0a129..e06e1ff32 100644 --- a/src/ui/tests/plugins/PluginsTab.test.tsx +++ b/src/ui/tests/plugins/PluginsTab.test.tsx @@ -40,6 +40,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({ confirmConsent: vi.fn(), developerMode: false, signedOnly: false, + registries: [], }), })); @@ -60,12 +61,14 @@ beforeEach(() => localStorage.clear()); afterEach(cleanup); describe("PluginsTab", () => { - it("tells people community plugins are coming and links the guide", async () => { + it("points to the Registries tab and links the submit guide", async () => { await renderTab(); fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse)); expect( - screen.getByText(en.plugins.manager.communitySoon, { exact: false }), + screen.getByText(en.plugins.manager.registries.browseHint, { + exact: false, + }), ).toBeTruthy(); const link = screen.getByText(en.plugins.manager.communitySubmit); expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry")); diff --git a/src/ui/tests/plugins/RegistriesSection.test.tsx b/src/ui/tests/plugins/RegistriesSection.test.tsx new file mode 100644 index 000000000..24a96235f --- /dev/null +++ b/src/ui/tests/plugins/RegistriesSection.test.tsx @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import i18next from "i18next"; +import { I18nextProvider, initReactI18next } from "react-i18next"; +import en from "@/locales/en.json"; +import { RegistriesSection } from "@/plugins/RegistriesSection"; +import type { PluginsManager } from "@/plugins/use-plugins-manager"; + +const api = vi.hoisted(() => ({ + getRegistries: vi.fn(), + previewRegistry: vi.fn(), + addRegistry: vi.fn(), + setRegistryEnabled: vi.fn(), + removeRegistry: vi.fn(), +})); + +vi.mock("@/api/plugins-api", () => api); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const manager = { + registries: [], + managedByServer: false, + refresh: vi.fn(async () => {}), +} as unknown as PluginsManager; + +function state(communityEnabled = false) { + return { + managedByServer: false, + official: { id: "official", url: "https://example.com/official.json" }, + community: { + id: "community", + url: "https://example.com/community.json", + enabled: communityEnabled, + }, + custom: [], + }; +} + +async function renderSection() { + const i18n = i18next.createInstance(); + await i18n.use(initReactI18next).init({ + lng: "en", + resources: { en: { translation: en } }, + }); + return render( + + + , + ); +} + +beforeEach(() => { + for (const fn of Object.values(api)) fn.mockReset(); + api.getRegistries.mockResolvedValue(state()); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("RegistriesSection", () => { + it("asks before turning community plugins on", async () => { + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + await renderSection(); + const toggle = await screen.findByRole("switch"); + + fireEvent.click(toggle); + await waitFor(() => expect(confirm).toHaveBeenCalled()); + expect(confirm.mock.calls[0][0]).toContain( + en.plugins.manager.registries.communityConfirmBody, + ); + expect(api.setRegistryEnabled).not.toHaveBeenCalled(); + + confirm.mockReturnValue(true); + api.getRegistries.mockResolvedValue(state(true)); + fireEvent.click(toggle); + await waitFor(() => + expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", true), + ); + }); + + it("does not ask when turning community plugins off", async () => { + const confirm = vi.spyOn(window, "confirm"); + api.getRegistries.mockResolvedValue(state(true)); + await renderSection(); + fireEvent.click(await screen.findByRole("switch")); + await waitFor(() => + expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", false), + ); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("checks a registry before adding it with the key it showed", async () => { + api.previewRegistry.mockResolvedValue({ + id: "acme", + name: "Acme Plugins", + url: "https://acme.example/index.json", + keyId: "abcd1234abcd1234", + plugins: 3, + }); + api.addRegistry.mockResolvedValue({ + id: "acme", + name: "Acme Plugins", + url: "https://acme.example/index.json", + keyId: "abcd1234abcd1234", + enabled: true, + addedAt: "", + }); + await renderSection(); + fireEvent.click(await screen.findByText(en.plugins.manager.registries.add)); + fireEvent.change( + screen.getByLabelText(en.plugins.manager.registries.urlLabel), + { target: { value: "https://acme.example/index.json" } }, + ); + fireEvent.click(screen.getByText(en.plugins.manager.registries.check)); + + expect(await screen.findByText("Acme Plugins")).toBeTruthy(); + expect(screen.getByText("Key abcd1234abcd1234")).toBeTruthy(); + expect(api.addRegistry).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText(en.plugins.manager.registries.addConfirm)); + await waitFor(() => + expect(api.addRegistry).toHaveBeenCalledWith( + "https://acme.example/index.json", + "abcd1234abcd1234", + ), + ); + }); +});