feat: community and custom plugin registries

This commit is contained in:
LukeGus committed 2026-10-11 13:29:05 -05:00
1 parent 1aa1bde5f7
commit 6a7afa94a0
31 files changed
+2903 -134

No files matched your search

+2
View File
@@ -6,6 +6,8 @@
- Slovak, Croatian, Slovenian, Lithuanian, Latvian, Estonian and Malay - 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 - 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 ### Changed
+1 -1
View File
@@ -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. | | [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. | | [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).
<br /> <br />
+5
View File
@@ -361,6 +361,11 @@
"group": "plugins", "group": "plugins",
"description": "Use another plugin index instead of the official registry." "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", "name": "TERMIX_PLUGIN_STATS_URL",
"group": "plugins", "group": "plugins",
+325 -15
View File
@@ -23,6 +23,7 @@ import {
PluginManageError, PluginManageError,
} from "../../plugins/manage.js"; } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js"; import { parsePluginChannel } from "../../plugins/registry-index.js";
import { RegistryError } from "../../plugins/registries.js";
import { import {
pluginDocsUrl, pluginDocsUrl,
pluginEnvVars, pluginEnvVars,
@@ -404,6 +405,7 @@ async function auditPluginAction(
action: string, action: string,
pluginId: string, pluginId: string,
details: Record<string, unknown> = {}, details: Record<string, unknown> = {},
resourceType = "plugin",
): Promise<void> { ): Promise<void> {
const userId = (req as AuthenticatedRequest).userId as string; const userId = (req as AuthenticatedRequest).userId as string;
const { ipAddress, userAgent } = getRequestMeta(req); const { ipAddress, userAgent } = getRequestMeta(req);
@@ -412,7 +414,7 @@ async function auditPluginAction(
userId, userId,
username: await getAuditUsername(userId), username: await getAuditUsername(userId),
action, action,
resourceType: "plugin", resourceType,
resourceId: pluginId, resourceId: pluginId,
resourceName: pluginId, resourceName: pluginId,
details: JSON.stringify(details), details: JSON.stringify(details),
@@ -431,7 +433,7 @@ function sendManageError(
fallback: string, fallback: string,
operation: string, operation: string,
): void { ): void {
if (error instanceof PluginManageError) { if (error instanceof PluginManageError || error instanceof RegistryError) {
res.status(error.status).json({ res.status(error.status).json({
error: error.message, error: error.message,
code: error.code, 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 * @openapi
* /plugins/{id}/state: * /plugins/{id}/state:
@@ -1609,10 +1909,11 @@ function readVersion(body: unknown): string | undefined | null {
* @openapi * @openapi
* /plugins/registry: * /plugins/registry:
* get: * get:
* summary: List the plugins in the official registry * summary: List the plugins in every registry that is on
* description: > * description: >
* Fetches the official registry index (cached for 15 minutes unless * Fetches the index of every registry that is on (each cached for 15
* refresh is set) and merges it with what is installed here: the * 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 * installed version, the newest release this build can run, whether an
* update asks for new capabilities, and the pin and auto-update choices. * update asks for new capabilities, and the pin and auto-update choices.
* tags: * tags:
@@ -1753,10 +2054,10 @@ router.post(
* @openapi * @openapi
* /plugins/{id}/install: * /plugins/{id}/install:
* post: * post:
* summary: Install a plugin from the official registry * summary: Install a plugin from a registry
* description: > * description: >
* Downloads the release, checks its sha256 and signature against a key * Downloads the release, checks its sha256 and signature against the keys of
* this build trusts, loads it, grants the capabilities it declares and * 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 * starts it along with any installed dependency that is off. Without a
* version the newest compatible release is used; naming an older one * version the newest compatible release is used; naming an older one
* pins the plugin there. A bundled plugin at its shipped version needs * pins the plugin there. A bundled plugin at its shipped version needs
@@ -1786,6 +2087,9 @@ router.post(
* type: string * type: string
* enum: [stable, beta] * enum: [stable, beta]
* description: The channel to follow. beta installs the newest beta when it is newer than stable. * 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: * capabilities:
* type: array * type: array
* items: * items:
@@ -1798,9 +2102,9 @@ router.post(
* 403: * 403:
* description: The caller lacks admin.plugins.manage. * description: The caller lacks admin.plugins.manage.
* 404: * 404:
* description: Not in the official registry. * description: Not in that registry.
* 409: * 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: * 502:
* description: The registry could not be reached. * description: The registry could not be reached.
*/ */
@@ -1816,15 +2120,19 @@ router.post(
req.body?.channel === undefined req.body?.channel === undefined
? "stable" ? "stable"
: parsePluginChannel(req.body.channel); : parsePluginChannel(req.body.channel);
const registryId =
req.body?.registryId === undefined ? undefined : req.body.registryId;
if ( if (
!ID_PATTERN.test(pluginId) || !ID_PATTERN.test(pluginId) ||
version === null || version === null ||
capabilities === null || capabilities === null ||
channel === null channel === null ||
(registryId !== undefined &&
(typeof registryId !== "string" || !ID_PATTERN.test(registryId)))
) { ) {
res res.status(400).json({
.status(400) error: "Invalid plugin id, version, channel, registry or capabilities",
.json({ error: "Invalid plugin id, version, channel or capabilities" }); });
return; return;
} }
if (await refuseOnLinkedDesktop(res)) return; if (await refuseOnLinkedDesktop(res)) return;
@@ -1834,11 +2142,13 @@ router.post(
const result = await installPlugin(pluginId, { const result = await installPlugin(pluginId, {
version, version,
channel, channel,
registryId,
userId, userId,
capabilities, capabilities,
}); });
await auditPluginAction(req, "install_plugin", pluginId, { await auditPluginAction(req, "install_plugin", pluginId, {
version: result.version, version: result.version,
registryId: registryId ?? "official",
capabilities, capabilities,
}); });
res.json(result); res.json(result);
@@ -1859,7 +2169,7 @@ router.post(
* post: * post:
* summary: Update or change the installed version of a plugin * summary: Update or change the installed version of a plugin
* description: > * 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 * If the release declares capabilities the installed one does not, the
* request must set acceptCapabilities and list exactly those added * request must set acceptCapabilities and list exactly those added
* capabilities in capabilities, otherwise it answers 409 with code * capabilities in capabilities, otherwise it answers 409 with code
+31 -16
View File
@@ -15,7 +15,12 @@
*/ */
import { pluginLogger } from "../utils/logger.js"; 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 TELEMETRY_SOURCE = "aggregate-telemetry";
export const DOWNLOADS_SOURCE = "github-releases"; 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. */ /** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
export async function syncInstallCounts(): Promise<number> { export async function syncInstallCounts(): Promise<number> {
let changed = 0;
let sources: RegistrySource[] = [officialSource()];
try { try {
const stats = await fetchRegistryStats(); const { listSources } = await import("./registries.js");
if (!stats) return 0; sources = await listSources();
const entries = [...stats.plugins].map(([pluginId, entry]) => ({ } catch {
pluginId, // Official only when the registry choices cannot be read.
...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;
} }
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( export async function readInstallCounts(
+15 -5
View File
@@ -28,7 +28,11 @@ import {
getPluginsDir, getPluginsDir,
getUnpackedPluginsDir, getUnpackedPluginsDir,
} from "./paths.js"; } from "./paths.js";
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js"; import {
requireSignedPlugins,
verifyAnyTrusted,
type KeyTier,
} from "./trust.js";
import { moveWithRetry } from "./fs-retry.js"; import { moveWithRetry } from "./fs-retry.js";
import { import {
createPluginContext, createPluginContext,
@@ -77,6 +81,8 @@ export interface LoadedPlugin {
artifact?: string; artifact?: string;
/** Key id that signed the artifact, when a trusted key signed it. */ /** Key id that signed the artifact, when a trusted key signed it. */
signedBy?: string; signedBy?: string;
/** Which set of keys that key belongs to. */
signedTier?: KeyTier;
} }
export interface PluginLoaderOptions { export interface PluginLoaderOptions {
@@ -345,10 +351,11 @@ export class PluginLoader {
const buffer = await fs.promises.readFile(file); const buffer = await fs.promises.readFile(file);
const sigFile = `${file}.sig`; const sigFile = `${file}.sig`;
let signedBy: string | undefined; let signedBy: string | undefined;
let signedTier: KeyTier | undefined;
if (fs.existsSync(sigFile)) { if (fs.existsSync(sigFile)) {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex"); const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const result = verifyPluginArtifact( const result = verifyAnyTrusted(
buffer, buffer,
sha256, sha256,
await fs.promises.readFile(sigFile, "utf8"), await fs.promises.readFile(sigFile, "utf8"),
@@ -357,6 +364,7 @@ export class PluginLoader {
throw new Error(`signature check failed: ${result.reason}`); throw new Error(`signature check failed: ${result.reason}`);
} }
signedBy = result.keyId; signedBy = result.keyId;
signedTier = result.tier;
} else if (requireSignedPlugins()) { } else if (requireSignedPlugins()) {
throw new Error( throw new Error(
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`, `TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
@@ -402,7 +410,7 @@ export class PluginLoader {
assertBundledUpdate( assertBundledUpdate(
id, id,
raw?.version, raw?.version,
signedBy, signedTier === "official" ? signedBy : undefined,
this.bundledVersions.get(id) ?? replaced?.manifest.version, this.bundledVersions.get(id) ?? replaced?.manifest.version,
this.pinned.get(id), this.pinned.get(id),
); );
@@ -427,6 +435,7 @@ export class PluginLoader {
const plugin = await this.load(target, isUpdate ? "bundled" : "user"); const plugin = await this.load(target, isUpdate ? "bundled" : "user");
plugin.artifact = file; plugin.artifact = file;
plugin.signedBy = signedBy; plugin.signedBy = signedBy;
plugin.signedTier = signedTier;
if (replaced) { if (replaced) {
pluginLogger.info( pluginLogger.info(
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`, `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 // 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. // the image or was downloaded, so it may adopt its 2.8 tables too.
const applied = await migratePlugin(plugin.id, plugin.dir, { 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. // A new table may be where core data is waiting to move.
if (applied.length > 0) { if (applied.length > 0) {
@@ -849,7 +859,7 @@ export function assertBundledUpdate(
): void { ): void {
if (!signedBy) { if (!signedBy) {
throw new Error( 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)) { if (typeof version !== "string" || !semver.valid(version)) {
+198 -27
View File
@@ -1,8 +1,9 @@
/** /**
* Installing, updating, uninstalling and switching plugins at runtime. * Installing, updating, uninstalling and switching plugins at runtime.
* *
* The official registry is the one real source. With developer mode on, an * Plugins come from the official registry, plus the community and custom
* admin can also install a .tmxplug from a file; that skips the download, * 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 * 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 * 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 * recorded in the settings table, so the shipped copy is ignored from then
@@ -38,11 +39,21 @@ import {
getRegistryStatus, getRegistryStatus,
isApiCompatible, isApiCompatible,
latestOnChannel, latestOnChannel,
officialSource,
parsePluginChannel, parsePluginChannel,
type PluginChannel, type PluginChannel,
type RegistryIndex, type RegistryIndex,
type RegistryPlugin,
type RegistrySource,
type RegistryTier,
type RegistryVersion, type RegistryVersion,
} from "./registry-index.js"; } from "./registry-index.js";
import {
findSource,
listSources,
loadCustomRegistryKeys,
pinnedKeyOf,
} from "./registries.js";
import { invalidatePluginPermissionCache } from "./permissions.js"; import { invalidatePluginPermissionCache } from "./permissions.js";
import { moveWithRetry } from "./fs-retry.js"; import { moveWithRetry } from "./fs-retry.js";
import { readInstallCounts } from "./install-counts.js"; import { readInstallCounts } from "./install-counts.js";
@@ -127,6 +138,7 @@ async function readPins(): Promise<Map<string, string>> {
export async function applyStoredPluginChoices( export async function applyStoredPluginChoices(
loader: PluginLoader, loader: PluginLoader,
): Promise<void> { ): Promise<void> {
await loadCustomRegistryKeys();
try { try {
loader.setUninstalled(await readUninstalled()); loader.setUninstalled(await readUninstalled());
loader.setPinned(await readPins()); loader.setPinned(await readPins());
@@ -208,12 +220,28 @@ function addedCapabilities(
return next.filter((capability) => !have.has(capability)); 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 { export interface RegistryListing {
/** The official registry, kept for older clients. */
registry: ReturnType<typeof getRegistryStatus> & { id: string }; registry: ReturnType<typeof getRegistryStatus> & { 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. */ /** A linked desktop runs what its server runs, so nothing here can change. */
managedByServer: boolean; managedByServer: boolean;
plugins: Array<{ plugins: Array<{
id: string; id: string;
registryId: string;
registryName: string;
tier: RegistryTier;
name: string; name: string;
description: string; description: string;
author: 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<boolean> {
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<string> {
const ids = new Set(getPluginRuntime().loader.bundledIds());
for (const plugin of official?.plugins ?? []) ids.add(plugin.id);
return ids;
}
export async function listRegistry( export async function listRegistry(
options: { force?: boolean } = {}, options: { force?: boolean } = {},
): Promise<RegistryListing> { ): Promise<RegistryListing> {
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 { createCurrentPluginRepository } = await repos();
const records = new Map( const records = new Map(
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]), (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 { loader } = getPluginRuntime();
const shipped = loader.bundledIds(); 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 { return {
registry: { id: OFFICIAL_REGISTRY_ID, ...getRegistryStatus() }, registry: { id: OFFICIAL_REGISTRY_ID, ...getRegistryStatus() },
registries,
managedByServer: await isLinkedDesktop(), managedByServer: await isLinkedDesktop(),
plugins: index.plugins.map((plugin) => { plugins: chosen.map(({ source, plugin }) => {
const record = records.get(plugin.id); const record = records.get(plugin.id);
const live = loader.get(plugin.id); const live = loader.get(plugin.id);
const channel = channelOf(record); const channel = channelOf(record);
@@ -283,6 +401,9 @@ export async function listRegistry(
); );
return { return {
id: plugin.id, id: plugin.id,
registryId: source.id,
registryName: registries.find((entry) => entry.id === source.id)!.name,
tier: source.tier,
name: plugin.name, name: plugin.name,
description: plugin.description, description: plugin.description,
author: plugin.author, author: plugin.author,
@@ -318,8 +439,9 @@ export async function listRegistry(
pinnedVersion: record?.pinnedVersion ?? null, pinnedVersion: record?.pinnedVersion ?? null,
autoUpdate: Boolean(record?.autoUpdate), autoUpdate: Boolean(record?.autoUpdate),
bundled: shipped.has(plugin.id), bundled: shipped.has(plugin.id),
installCount: counts.get(plugin.id)?.count ?? null, installCount: counts.get(source.id)?.get(plugin.id)?.count ?? null,
installCountSource: counts.get(plugin.id)?.source ?? null, installCountSource:
counts.get(source.id)?.get(plugin.id)?.source ?? null,
}; };
}), }),
}; };
@@ -362,7 +484,8 @@ async function grantDeclared(
invalidatePluginPermissionCache(plugin.id); 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 * 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 bundledVersion = loader.bundledVersion(pluginId);
const useBundled = const useBundled =
"release" in source && "release" in source &&
source.source.tier === "official" &&
bundledVersion !== undefined && bundledVersion !== undefined &&
bundledVersion === source.release.version && bundledVersion === source.release.version &&
isBundledOnDisk(pluginId); isBundledOnDisk(pluginId);
@@ -385,7 +509,7 @@ async function swapIn(
const staged = useBundled const staged = useBundled
? null ? null
: "release" in source : "release" in source
? await downloadRelease(pluginId, source.release) ? await downloadRelease(pluginId, source.release, source.source.keys)
: { file: source.upload, signatureFile: `${source.upload}.sig` }; : { file: source.upload, signatureFile: `${source.upload}.sig` };
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`); const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
@@ -654,6 +778,8 @@ export interface InstallOptions {
version?: string; version?: string;
/** Install only: the channel to follow from the start. */ /** Install only: the channel to follow from the start. */
channel?: PluginChannel; channel?: PluginChannel;
/** Install only: the registry to install from. Official when unset. */
registryId?: string;
userId: string | null; userId: string | null;
acceptCapabilities?: boolean; 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( async function requireRelease(
pluginId: string, pluginId: string,
version: string | undefined, version: string | undefined,
channel: PluginChannel, channel: PluginChannel,
registryId: string = OFFICIAL_REGISTRY_ID,
): Promise<{ ): Promise<{
index: RegistryIndex; source: RegistrySource;
release: RegistryVersion; release: RegistryVersion;
latest: RegistryVersion | null; 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; let index: RegistryIndex;
try { try {
index = await fetchRegistryIndex(); index = await fetchRegistryIndex(source);
} catch (error) { } catch (error) {
throw unreachable(error);
}
if (await keyChanged(source, index)) {
throw new PluginManageError( throw new PluginManageError(
`Could not reach the plugin registry: ${ `${source.name}: ${KEY_CHANGED_MESSAGE}`,
error instanceof Error ? error.message : String(error) 409,
}`, "KEY_CHANGED",
502,
"REGISTRY_UNREACHABLE",
); );
} }
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); const found = findRelease(index, pluginId, version, channel);
if (!found) { if (!found) {
throw new PluginManageError( throw new PluginManageError(
version version
? `${pluginId} ${version} is not in the official registry` ? `${pluginId} ${version} is not in ${source.name}`
: `${pluginId} is not in the official registry`, : `${pluginId} is not in ${source.name}`,
404, 404,
"NOT_IN_REGISTRY", "NOT_IN_REGISTRY",
); );
@@ -723,12 +884,20 @@ async function requireRelease(
); );
} }
return { return {
index, source,
release: found.release, release: found.release,
latest: latestOnChannel(found.plugin, channel), 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. */ /** The version to hold at: an explicit pick that is not the newest. */
function pinFor( function pinFor(
release: RegistryVersion, release: RegistryVersion,
@@ -763,10 +932,11 @@ export function installPlugin(
} }
const channel = options.channel ?? "stable"; const channel = options.channel ?? "stable";
const { release, latest } = await requireRelease( const { source, release, latest } = await requireRelease(
pluginId, pluginId,
options.version, options.version,
channel, channel,
options.registryId,
); );
assertConsent(options.capabilities, release.capabilities); assertConsent(options.capabilities, release.capabilities);
const pin = pinFor(release, latest, options.version); const pin = pinFor(release, latest, options.version);
@@ -774,7 +944,7 @@ export function installPlugin(
let plugin: LoadedPlugin; let plugin: LoadedPlugin;
try { try {
plugin = await swapIn(pluginId, { release }); plugin = await swapIn(pluginId, { release, source });
} catch (error) { } catch (error) {
await applyPin(pluginId, null); await applyPin(pluginId, null);
throw new PluginManageError( throw new PluginManageError(
@@ -785,8 +955,8 @@ export function installPlugin(
} }
await repository.update(pluginId, { await repository.update(pluginId, {
registryId: OFFICIAL_REGISTRY_ID, registryId: source.id,
tier: plugin.source === "bundled" ? "bundled" : "official", tier: tierFor(plugin, source),
pinnedVersion: pin, pinnedVersion: pin,
channel, channel,
}); });
@@ -832,10 +1002,11 @@ async function updateUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
} }
const { release, latest } = await requireRelease( const { source, release, latest } = await requireRelease(
pluginId, pluginId,
options.version, options.version,
channelOf(record), channelOf(record),
record.registryId ?? OFFICIAL_REGISTRY_ID,
); );
if (release.version === live.manifest.version) { if (release.version === live.manifest.version) {
throw new PluginManageError( throw new PluginManageError(
@@ -867,7 +1038,7 @@ async function updateUnlocked(
let plugin: LoadedPlugin; let plugin: LoadedPlugin;
try { try {
plugin = await swapIn(pluginId, { release }); plugin = await swapIn(pluginId, { release, source });
} catch (error) { } catch (error) {
await applyPin(pluginId, record.pinnedVersion ?? null); await applyPin(pluginId, record.pinnedVersion ?? null);
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId); if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
@@ -879,8 +1050,8 @@ async function updateUnlocked(
} }
await repository.update(pluginId, { await repository.update(pluginId, {
registryId: OFFICIAL_REGISTRY_ID, registryId: source.id,
tier: plugin.source === "bundled" ? "bundled" : "official", tier: tierFor(plugin, source),
pinnedVersion: pin, pinnedVersion: pin,
}); });
await grantDeclared(plugin, options.userId); await grantDeclared(plugin, options.userId);
+414
View File
@@ -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<string, unknown>,
) {
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<string, unknown>;
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<string, unknown>;
const community = parsed?.community as Record<string, unknown> | 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<RegistriesConfig> {
return parseRegistriesConfig(await (await settings()).get(CONFIG_KEY));
}
async function writeRegistriesConfig(config: RegistriesConfig): Promise<void> {
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<string[]> {
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<void> {
const keys = new Map<string, TrustedPluginKey>();
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<void> {
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<void> {
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<RegistrySource[]> {
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<RegistrySource | null> {
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<string | null> {
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<string>([
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<RegistryPreview & { publicKey: string }> {
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<RegistryPreview> {
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<CustomRegistry> {
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<void> {
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<void> {
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<string[]> {
return (await readRegistriesConfig()).custom.map((entry) => entry.publicKey);
}
+119 -41
View File
@@ -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 * An index is a plain JSON file. Official and community live in
* is trusted on its own: every download is checked against the sha256 and * Termix-SSH/Termix-Registry, custom ones anywhere an admin points to.
* Ed25519 signature listed for it, and the signature must come from a key * Nothing in an index is trusted on its own: every download is checked
* pinned in trust.ts. A tampered index can at most point at a file that then * against the sha256 and Ed25519 signature listed for it, and the signature
* fails the check. * 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"; import crypto from "node:crypto";
@@ -20,13 +22,33 @@ import {
} from "@termix-ssh/plugin-sdk/manifest"; } from "@termix-ssh/plugin-sdk/manifest";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js"; import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { pluginLogger } from "../utils/logger.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 { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
import { getPluginsDir } from "./paths.js"; import { getPluginsDir } from "./paths.js";
export const OFFICIAL_REGISTRY_ID = "official"; export const OFFICIAL_REGISTRY_ID = "official";
export const OFFICIAL_REGISTRY_URL = export const OFFICIAL_REGISTRY_URL =
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json"; "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 CACHE_MS = 15 * 60_000;
const MAX_INDEX_BYTES = 5 * 1024 * 1024; const MAX_INDEX_BYTES = 5 * 1024 * 1024;
@@ -83,6 +105,8 @@ export interface RegistryIndex {
registry: string; registry: string;
name: string; name: string;
updatedAt?: string; updatedAt?: string;
/** The key the registry signs with, base64. Only read when adding one. */
publicKey?: string;
plugins: RegistryPlugin[]; plugins: RegistryPlugin[];
} }
@@ -90,6 +114,26 @@ export function getRegistryUrl(): string {
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL; 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 { function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
} }
@@ -174,6 +218,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID, registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
name: asString(index.name) ?? "", name: asString(index.name) ?? "",
updatedAt: asString(index.updatedAt), updatedAt: asString(index.updatedAt),
publicKey: isPluginPublicKey(index.publicKey) ? index.publicKey : undefined,
plugins, plugins,
}; };
} }
@@ -228,8 +273,18 @@ async function readLimited(response: Response, limit: number): Promise<Buffer> {
return Buffer.concat(chunks); return Buffer.concat(chunks);
} }
let cache: { index: RegistryIndex; at: number; hash: string } | null = null; interface CacheEntry {
let inflight: Promise<RegistryIndex> | null = null; index: RegistryIndex;
at: number;
}
const caches = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<RegistryIndex>>();
const lastErrors = new Map<string, string>();
function cacheKey(source: RegistrySource): string {
return `${source.id} ${source.url}`;
}
export interface RegistryStatus { export interface RegistryStatus {
url: string; url: string;
@@ -237,51 +292,67 @@ export interface RegistryStatus {
error: string | null; error: string | null;
} }
let lastError: string | null = null; export function getRegistryStatus(
source: RegistrySource = officialSource(),
export function getRegistryStatus(): RegistryStatus { ): RegistryStatus {
const cache = caches.get(cacheKey(source));
return { return {
url: getRegistryUrl(), url: source.url,
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null, 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<RegistryIndex> {
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( export async function fetchRegistryIndex(
source: RegistrySource = officialSource(),
options: { force?: boolean } = {}, options: { force?: boolean } = {},
): Promise<RegistryIndex> { ): Promise<RegistryIndex> {
const key = cacheKey(source);
const cache = caches.get(key);
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) { if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
return cache.index; return cache.index;
} }
if (inflight) return inflight; const running = inflight.get(key);
if (running) return running;
inflight = (async () => { const request = (async () => {
try { try {
const response = await fetchFollowing(getRegistryUrl()); const index = await fetchIndexAt(source.url);
const body = await readLimited(response, MAX_INDEX_BYTES); caches.set(key, { index, at: Date.now() });
const index = parseRegistryIndex(JSON.parse(body.toString("utf8"))); lastErrors.delete(key);
cache = {
index,
at: Date.now(),
hash: crypto.createHash("sha256").update(body).digest("hex"),
};
lastError = null;
return index; return index;
} catch (error) { } 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. // A stale index beats none when the registry is briefly unreachable.
if (cache) { const stale = caches.get(key);
pluginLogger.warn(`Plugin registry check failed: ${lastError}`, { if (stale) {
operation: "plugin_registry", pluginLogger.warn(
}); `Plugin registry ${source.id} check failed: ${message}`,
return cache.index; { operation: "plugin_registry" },
);
return stale.index;
} }
throw error; throw error;
} finally { } finally {
inflight = null; inflight.delete(key);
} }
})(); })();
return inflight; inflight.set(key, request);
return request;
} }
export interface RegistryStats { export interface RegistryStats {
@@ -289,11 +360,14 @@ export interface RegistryStats {
} }
/** stats.json sits next to index.json and is rebuilt daily by the registry. */ /** stats.json sits next to index.json and is rebuilt daily by the registry. */
export function getStatsUrl(): string | null { export function getStatsUrl(
if (process.env.TERMIX_PLUGIN_STATS_URL) { 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; return process.env.TERMIX_PLUGIN_STATS_URL;
} }
const url = getRegistryUrl(); const url = source.url;
return /\/index\.json$/.test(url) return /\/index\.json$/.test(url)
? url.replace(/\/index\.json$/, "/stats.json") ? url.replace(/\/index\.json$/, "/stats.json")
: null; : null;
@@ -325,8 +399,10 @@ export function parseRegistryStats(raw: unknown): RegistryStats {
return { plugins }; return { plugins };
} }
export async function fetchRegistryStats(): Promise<RegistryStats | null> { export async function fetchRegistryStats(
const url = getStatsUrl(); source: RegistrySource = officialSource(),
): Promise<RegistryStats | null> {
const url = getStatsUrl(source);
if (!url) return null; if (!url) return null;
const response = await fetchFollowing(url); const response = await fetchFollowing(url);
const body = await readLimited(response, MAX_INDEX_BYTES); const body = await readLimited(response, MAX_INDEX_BYTES);
@@ -335,9 +411,9 @@ export async function fetchRegistryStats(): Promise<RegistryStats | null> {
/** Test seam. */ /** Test seam. */
export function resetRegistryCache(): void { export function resetRegistryCache(): void {
cache = null; caches.clear();
inflight = null; inflight.clear();
lastError = null; lastErrors.clear();
} }
/** Every release a channel may install, newest first. */ /** Every release a channel may install, newest first. */
@@ -414,6 +490,7 @@ export function assertArtifactMatches(
export async function downloadRelease( export async function downloadRelease(
pluginId: string, pluginId: string,
release: RegistryVersion, release: RegistryVersion,
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
): Promise<{ file: string; signatureFile: string }> { ): Promise<{ file: string; signatureFile: string }> {
if (!isApiCompatible(release.api)) { if (!isApiCompatible(release.api)) {
throw new Error( throw new Error(
@@ -433,6 +510,7 @@ export async function downloadRelease(
buffer, buffer,
release.sha256, release.sha256,
release.signature, release.signature,
keys,
); );
if (verified.ok === false) { if (verified.ok === false) {
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`); throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
+68
View File
@@ -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 = export type ArtifactVerification =
{ ok: true; keyId: string } | { ok: false; reason: string }; { 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" }; 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. */ /** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
export function requireSignedPlugins(): boolean { export function requireSignedPlugins(): boolean {
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true"; return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
+7 -4
View File
@@ -21,6 +21,7 @@ import {
unloadPlugin, unloadPlugin,
} from "../../plugins/index.js"; } from "../../plugins/index.js";
import { getPluginsDir } from "../../plugins/paths.js"; import { getPluginsDir } from "../../plugins/paths.js";
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
import type { LoadedPlugin } from "../../plugins/loader.js"; import type { LoadedPlugin } from "../../plugins/loader.js";
import { import {
createCurrentPluginPermissionGrantRepository, createCurrentPluginPermissionGrantRepository,
@@ -283,10 +284,12 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
} }
export async function mirrorPlugins(link: SyncLink): Promise<void> { export async function mirrorPlugins(link: SyncLink): Promise<void> {
const { plugins } = await remoteJson<{ plugins: RemotePlugin[] }>( const { plugins, registryKeys } = await remoteJson<{
link, plugins: RemotePlugin[];
"/sync/v2/plugins", 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; lastRemote = plugins;
const remoteIds = new Set(plugins.map((plugin) => plugin.id)); const remoteIds = new Set(plugins.map((plugin) => plugin.id));
+4 -2
View File
@@ -472,7 +472,7 @@ async function listPluginsForDesktop() {
* /sync/v2/plugins: * /sync/v2/plugins:
* get: * get:
* summary: The features this server runs, for a linked desktop to mirror * 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: * tags:
* - Sync * - Sync
* responses: * 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) { } catch (error) {
syncLogger.error("Failed to list plugins for sync", error, { syncLogger.error("Failed to list plugins for sync", error, {
operation: "sync_plugins", operation: "sync_plugins",
@@ -7,6 +7,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginManageError } from "../../../plugins/manage.js"; import { PluginManageError } from "../../../plugins/manage.js";
import { RegistryError } from "../../../plugins/registries.js";
import express from "express"; import express from "express";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import type { Server } from "node:http"; import type { Server } from "node:http";
@@ -194,6 +195,19 @@ vi.mock("../../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), 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<typeof import("../../../plugins/registries.js")>()),
...registriesMock,
}));
const onboardingMock = vi.hoisted(() => ({ const onboardingMock = vi.hoisted(() => ({
describeOnboardingPlugins: vi.fn(), describeOnboardingPlugins: vi.fn(),
applyOnboardingChoices: 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", () => { describe("install, update, uninstall", () => {
const post = (path: string, body: unknown = {}, user = "admin-1") => const post = (path: string, body: unknown = {}, user = "admin-1") =>
fetch(`${baseUrl}${path}`, { fetch(`${baseUrl}${path}`, {
@@ -5,18 +5,36 @@ const state = vi.hoisted(() => ({
plugins: Map<string, { downloads: number; activeInstalls: number | null }>; plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
}, },
fail: false, 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[] }>, upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
rows: [] as Array<{ pluginId: string; count: number; source: string }>, rows: [] as Array<{ pluginId: string; count: number; source: string }>,
})); }));
vi.mock("../../plugins/registry-index.js", () => ({ vi.mock("../../plugins/registry-index.js", () => ({
OFFICIAL_REGISTRY_ID: "official", OFFICIAL_REGISTRY_ID: "official",
fetchRegistryStats: async () => { officialSource: () => ({ id: "official", tier: "official" }),
fetchRegistryStats: async (source: { id: string }) => {
if (state.fail) throw new Error("offline"); 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", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginInstallCountRepository: () => ({ createCurrentPluginInstallCountRepository: () => ({
upsertMany: async (registryId: string, entries: unknown[]) => { upsertMany: async (registryId: string, entries: unknown[]) => {
@@ -38,10 +56,36 @@ const {
beforeEach(() => { beforeEach(() => {
state.stats = null; state.stats = null;
state.fail = false; state.fail = false;
state.sources = [{ id: "official", tier: "official" }];
state.statsBySource = {};
state.upserted = []; state.upserted = [];
state.rows = []; 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", () => { describe("preferredCount", () => {
it("prefers active installs and falls back to downloads", () => { it("prefers active installs and falls back to downloads", () => {
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({ expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
+229 -1
View File
@@ -4,6 +4,7 @@
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -33,6 +34,8 @@ const db = vi.hoisted(() => ({
const registry = vi.hoisted(() => ({ const registry = vi.hoisted(() => ({
index: { registry: "official", name: "", plugins: [] as unknown[] }, index: { registry: "official", name: "", plugins: [] as unknown[] },
/** Indexes of the other registries, by id. Missing means unreachable. */
others: {} as Record<string, Record<string, unknown>>,
download: vi.fn(), download: vi.fn(),
})); }));
@@ -97,7 +100,14 @@ vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
await importOriginal<typeof import("../../plugins/registry-index.js")>(); await importOriginal<typeof import("../../plugins/registry-index.js")>();
return { return {
...actual, ...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, downloadRelease: registry.download,
}; };
}); });
@@ -216,6 +226,7 @@ beforeEach(() => {
db.settings.clear(); db.settings.clear();
db.grants.length = 0; db.grants.length = 0;
registry.index.plugins = []; registry.index.plugins = [];
registry.others = {};
registry.download.mockReset(); registry.download.mockReset();
}); });
@@ -856,3 +867,220 @@ describe("release notes", () => {
expect(manage.readReleaseNotes(undefined)).toBeUndefined(); 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" });
});
});
@@ -0,0 +1,259 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
const state = vi.hoisted(() => ({
settings: new Map<string, string>(),
plugins: [] as Array<{ id: string; registryId: string | null }>,
indexes: new Map<string, unknown>(),
}));
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<typeof import("../../plugins/registry-index.js")>();
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<string, unknown>): 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<string> {
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([]);
});
});
@@ -17,14 +17,27 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
await importOriginal<typeof import("../../plugins/trust.js")>(); await importOriginal<typeof import("../../plugins/trust.js")>();
return { return {
...actual, ...actual,
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) => // The built in official keys are swapped for the test key. Keys a
actual.verifyPluginArtifact(buffer, sha256, signature, [ // caller passes for another registry are used as they are.
testKey.value as never, 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 { import {
communitySource,
downloadRelease, downloadRelease,
fetchRegistryIndex, fetchRegistryIndex,
fetchRegistryStats, fetchRegistryStats,
@@ -35,6 +48,7 @@ import {
parseRegistryIndex, parseRegistryIndex,
parseRegistryStats, parseRegistryStats,
resetRegistryCache, resetRegistryCache,
type RegistrySource,
type RegistryVersion, type RegistryVersion,
} from "../../plugins/registry-index.js"; } from "../../plugins/registry-index.js";
@@ -91,6 +105,25 @@ afterEach(() => {
fs.rmSync(dataDir, { recursive: true, force: true }); 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", () => { describe("parseRegistryIndex", () => {
it("keeps valid entries and sorts versions newest first", () => { it("keeps valid entries and sorts versions newest first", () => {
const index = parseRegistryIndex({ const index = parseRegistryIndex({
@@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => {
fetchMock.mockResolvedValueOnce(response(body)); fetchMock.mockResolvedValueOnce(response(body));
const first = await fetchRegistryIndex(); const first = await fetchRegistryIndex();
fetchMock.mockResolvedValueOnce(response("down", 503)); 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 () => { it("throws when there is nothing cached", async () => {
@@ -384,6 +439,38 @@ describe("downloadRelease", () => {
).rejects.toThrow(/sha256/); ).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 () => { it("refuses a release built for another plugin API", async () => {
await expect( await expect(
downloadRelease("docker", version({ api: "2" })), 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", () => { it("keeps valid counts and drops the rest", () => {
const stats = parseRegistryStats({ const stats = parseRegistryStats({
plugins: { plugins: {
@@ -15,6 +15,8 @@ import { PluginLoader } from "../../plugins/loader.js";
const testKey = vi.hoisted(() => ({ const testKey = vi.hoisted(() => ({
privateKey: null as crypto.KeyObject | null, privateKey: null as crypto.KeyObject | null,
key: { id: "test", publicKey: "", addedIn: "2.9.0" }, 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) => { vi.mock("../../plugins/trust.js", async (importActual) => {
@@ -23,6 +25,17 @@ vi.mock("../../plugins/trust.js", async (importActual) => {
...actual, ...actual,
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) => verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]), 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" }) .export({ format: "der", type: "spki" })
.subarray(12) .subarray(12)
.toString("base64"); .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> = []; const cleanups: Array<() => void> = [];
@@ -68,7 +87,7 @@ function setup() {
async function writeArtifact( async function writeArtifact(
dir: string, dir: string,
id: string, id: string,
sig: "valid" | "wrong-key" | "none", sig: "valid" | "community" | "wrong-key" | "none",
version = "1.0.0", version = "1.0.0",
): Promise<string> { ): Promise<string> {
const build = tempRoot("termix-build-"); const build = tempRoot("termix-build-");
@@ -82,7 +101,9 @@ async function writeArtifact(
const signer = const signer =
sig === "valid" sig === "valid"
? testKey.privateKey ? testKey.privateKey
: crypto.generateKeyPairSync("ed25519").privateKey; : sig === "community"
? testKey.communityPrivateKey
: crypto.generateKeyPairSync("ed25519").privateKey;
const digest = crypto const digest = crypto
.createHash("sha256") .createHash("sha256")
.update(fs.readFileSync(file)) .update(fs.readFileSync(file))
@@ -171,6 +192,27 @@ describe("updating a bundled plugin", () => {
expect(plugin?.source).toBe("bundled"); expect(plugin?.source).toBe("bundled");
expect(plugin?.artifact).toBe(file); expect(plugin?.artifact).toBe(file);
expect(plugin?.signedBy).toBe("test"); 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 () => { it("keeps the bundled copy for an unsigned newer archive", async () => {
+61
View File
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { import {
COMMUNITY_PLUGIN_KEYS,
TRUSTED_PLUGIN_KEYS,
isPluginPublicKey,
pluginKeyId,
requireSignedPlugins, requireSignedPlugins,
setCustomPluginKeys,
verifyAnyTrusted,
verifyPluginArtifact, verifyPluginArtifact,
type TrustedPluginKey, type TrustedPluginKey,
} from "../../plugins/trust.js"; } 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", () => { describe("requireSignedPlugins", () => {
it("is on only for the exact value true", () => { it("is on only for the exact value true", () => {
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS; const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
@@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({
install: vi.fn(), install: vi.fn(),
fetch: vi.fn(), fetch: vi.fn(),
loadBundled: vi.fn(), loadBundled: vi.fn(),
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
})); }));
vi.mock("../../../utils/logger.js", () => ({ vi.mock("../../../utils/logger.js", () => ({
@@ -29,6 +30,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}), }),
createCurrentPluginPermissionGrantRepository: () => ({}), createCurrentPluginPermissionGrantRepository: () => ({}),
})); }));
vi.mock("../../../plugins/registries.js", () => ({
setSyncedRegistryKeys: state.syncedKeys,
}));
vi.mock("../../../sync/client/http.js", () => ({ vi.mock("../../../sync/client/http.js", () => ({
remoteFetch: state.fetch, remoteFetch: state.fetch,
remoteJson: vi.fn(), remoteJson: vi.fn(),
@@ -100,6 +104,19 @@ afterEach(() => {
}); });
describe("mirrorPlugins", () => { 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 () => { it("keeps the installed copy when the download fails", async () => {
serverHas("1.1.0"); serverHas("1.1.0");
state.fetch.mockResolvedValue({ ok: false, status: 502 }); state.fetch.mockResolvedValue({ ok: false, status: 502 });
+73 -1
View File
@@ -291,8 +291,14 @@ export interface RegistryPluginVersion {
size: number; size: number;
} }
export type RegistryTier = "official" | "community" | "custom";
export interface RegistryPluginEntry { export interface RegistryPluginEntry {
id: string; id: string;
/** Which registry this entry comes from. */
registryId?: string;
registryName?: string;
tier?: RegistryTier;
name: string; name: string;
description: string; description: string;
author: string; author: string;
@@ -320,6 +326,16 @@ export interface RegistryPluginEntry {
installCountSource: string | null; 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 { export interface RegistryListing {
registry: { registry: {
id: string; id: string;
@@ -327,6 +343,7 @@ export interface RegistryListing {
lastCheckedAt: string | null; lastCheckedAt: string | null;
error: string | null; error: string | null;
}; };
registries?: RegistryListingSource[];
managedByServer: boolean; managedByServer: boolean;
plugins: RegistryPluginEntry[]; plugins: RegistryPluginEntry[];
} }
@@ -375,7 +392,7 @@ export async function getPluginRegistry(
/** `capabilities` is exactly what the consent prompt showed. */ /** `capabilities` is exactly what the consent prompt showed. */
export async function installPlugin( export async function installPlugin(
pluginId: string, pluginId: string,
options: { version?: string; capabilities: string[] }, options: { version?: string; capabilities: string[]; registryId?: string },
): Promise<{ id: string; version: string; state: string }> { ): Promise<{ id: string; version: string; state: string }> {
try { try {
const response = await rbacApi.post( 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<RegistriesState> {
const response = await rbacApi.get("/plugins/registries");
return response.data;
}
export async function previewRegistry(url: string): Promise<RegistryPreview> {
const response = await rbacApi.post("/plugins/registries/preview", { url });
return response.data;
}
export async function addRegistry(
url: string,
keyId: string,
): Promise<CustomRegistryInfo> {
const response = await rbacApi.post("/plugins/registries", { url, keyId });
return response.data;
}
export async function setRegistryEnabled(
id: string,
enabled: boolean,
): Promise<void> {
await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, {
enabled,
});
}
export async function removeRegistry(id: string): Promise<void> {
await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`);
}
export async function updateAllPlugins(): Promise<UpdateAllResult> { export async function updateAllPlugins(): Promise<UpdateAllResult> {
try { try {
const response = await rbacApi.post("/plugins/update-all"); const response = await rbacApi.post("/plugins/update-all");
+3 -1
View File
@@ -38,5 +38,7 @@
"pluginCatalog": "plugins", "pluginCatalog": "plugins",
"cli": "cli", "cli": "cli",
"develop": "develop", "develop": "develop",
"communityRegistry": "develop/community-registry" "communityRegistry": "develop/community-registry",
"pluginRegistries": "guide/plugins#registries",
"hostRegistry": "develop/registries"
} }
+39 -2
View File
@@ -1899,7 +1899,8 @@
"tabs": { "tabs": {
"installed": "Installed", "installed": "Installed",
"browse": "Browse", "browse": "Browse",
"updates": "Updates" "updates": "Updates",
"registries": "Registries"
}, },
"status": { "status": {
"summary": "{{installed}} installed, {{running}} running", "summary": "{{installed}} installed, {{running}} running",
@@ -2084,8 +2085,44 @@
"noneInstalled": "No plugins installed yet. Browse to add some.", "noneInstalled": "No plugins installed yet. Browse to add some.",
"nothingHere": "Nothing here.", "nothingHere": "Nothing here.",
"nothingHereHint": "Try another category or search.", "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", "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.", "upToDate": "Everything is up to date.",
"lastChecked": "Last checked {{time}}", "lastChecked": "Last checked {{time}}",
"updatesReady_one": "{{count}} update ready.", "updatesReady_one": "{{count}} update ready.",
+5
View File
@@ -33,6 +33,7 @@ import {
CapabilityRow, CapabilityRow,
InstallCountFact, InstallCountFact,
PluginIconBox, PluginIconBox,
SourceBadge,
UnverifiedBadge, UnverifiedBadge,
} from "./plugin-bits"; } from "./plugin-bits";
import { import {
@@ -108,6 +109,10 @@ export function PluginDetail({
</span> </span>
<Facts className="text-[11px] text-muted-foreground/70"> <Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />} {plugin.unverified && <UnverifiedBadge />}
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.isBeta && <BetaBadge />} {plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
{plugin.version ? ( {plugin.version ? (
+58 -4
View File
@@ -6,6 +6,7 @@ import {
Columns3, Columns3,
Download, Download,
Globe, Globe,
Library,
Puzzle, Puzzle,
RefreshCw, RefreshCw,
RotateCcw, RotateCcw,
@@ -42,8 +43,10 @@ import {
BetaBadge, BetaBadge,
InstallCountFact, InstallCountFact,
PluginIconBox, PluginIconBox,
SourceBadge,
UnverifiedBadge, UnverifiedBadge,
} from "./plugin-bits"; } from "./plugin-bits";
import { RegistriesSection } from "./RegistriesSection";
import { PluginConsentPrompt } from "./PluginConsentPrompt"; import { PluginConsentPrompt } from "./PluginConsentPrompt";
import { PluginDetail } from "./PluginDetail"; import { PluginDetail } from "./PluginDetail";
import { import {
@@ -60,7 +63,7 @@ import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
import { docsUrl } from "@/lib/docs"; import { docsUrl } from "@/lib/docs";
import { DocsLink } from "@/components/docs-link"; 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; const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
type GridSize = (typeof GRID_SIZES)[number]; type GridSize = (typeof GRID_SIZES)[number];
@@ -146,6 +149,11 @@ function PluginsTabBody() {
icon: <Download className="size-3" />, icon: <Download className="size-3" />,
count: updates.filter((p) => !p.pinnedVersion).length || undefined, count: updates.filter((p) => !p.pinnedVersion).length || undefined,
}, },
{
id: "registries",
label: t("plugins.manager.tabs.registries"),
icon: <Library className="size-3" />,
},
]; ];
return ( return (
@@ -217,12 +225,14 @@ function PluginsTabBody() {
layout={layout} layout={layout}
layoutControls={layoutControls} layoutControls={layoutControls}
/> />
) : ( ) : section === "updates" ? (
<UpdatesSection <UpdatesSection
plugins={updates} plugins={updates}
manager={manager} manager={manager}
onOpen={setDetailId} onOpen={setDetailId}
/> />
) : (
<RegistriesSection manager={manager} />
)} )}
</div> </div>
</PanelShell> </PanelShell>
@@ -442,6 +452,7 @@ function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) {
{plugin.name} {plugin.name}
</span> </span>
{plugin.unverified && <UnverifiedBadge />} {plugin.unverified && <UnverifiedBadge />}
<SourceBadge source={plugin.source} registryName={plugin.registryName} />
{plugin.isBeta && <BetaBadge />} {plugin.isBeta && <BetaBadge />}
</div> </div>
); );
@@ -704,6 +715,10 @@ function InstalledCard({
facts={ facts={
<> <>
{plugin.unverified && <UnverifiedBadge />} {plugin.unverified && <UnverifiedBadge />}
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.isBeta && <BetaBadge />} {plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span> <span>v{plugin.version}</span>
@@ -935,14 +950,20 @@ function BrowseSection({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [category, setCategory] = useState<string | null>(null); const [category, setCategory] = useState<string | null>(null);
const [registry, setRegistry] = useState<string | null>(null);
const [sort, setSort] = useState<SortKey>("popular"); const [sort, setSort] = useState<SortKey>("popular");
const categories = useMemo(() => categoriesOf(plugins), [plugins]); const categories = useMemo(() => categoriesOf(plugins), [plugins]);
const registries = manager.registries;
const registryFilter = registries.some((entry) => entry.id === registry)
? registry
: null;
const rows = sortPlugins( const rows = sortPlugins(
plugins.filter( plugins.filter(
(p) => (p) =>
matchesQuery(p, query) && matchesQuery(p, query) &&
(category === null || p.category === category), (category === null || p.category === category) &&
(registryFilter === null || p.registryId === registryFilter),
), ),
sort, sort,
); );
@@ -1011,6 +1032,35 @@ function BrowseSection({
placeholder={t("plugins.manager.searchPlugins")} placeholder={t("plugins.manager.searchPlugins")}
> >
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{registries.length > 1 && (
<Select
value={registryFilter ?? "__all"}
onValueChange={(v) => setRegistry(v === "__all" ? null : v)}
>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
aria-label={t("plugins.manager.registries.filter")}
>
<Library className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all" className="text-xs">
{t("plugins.manager.registries.allRegistries")}
</SelectItem>
{registries.map((entry) => (
<SelectItem
key={entry.id}
value={entry.id}
className="text-xs"
>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<SortSelect value={sort} onChange={setSort} /> <SortSelect value={sort} onChange={setSort} />
{layoutControls} {layoutControls}
</div> </div>
@@ -1063,7 +1113,7 @@ function BrowseSection({
)} )}
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{t("plugins.manager.communitySoon")}{" "} {t("plugins.manager.registries.browseHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]"> <DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")} {t("plugins.manager.communitySubmit")}
</DocsLink> </DocsLink>
@@ -1091,6 +1141,10 @@ function BrowseCard({
onOpen={onOpen} onOpen={onOpen}
facts={ facts={
<> <>
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>} {plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
{plugin.category && <span>{plugin.category}</span>} {plugin.category && <span>{plugin.category}</span>}
+390
View File
@@ -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<string, unknown>;
} {
const data = (error as { response?: { data?: Record<string, unknown> } })
?.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<RegistriesState | null>(null);
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState<string | null>(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 ? (
<span className="text-[11px] leading-snug text-destructive">
{t("plugins.manager.registries.unreachable", { error })}
</span>
) : null;
};
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.intro")}{" "}
<DocsLink core="hostRegistry" className="text-[11px]">
{t("plugins.manager.registries.hostYourOwn")}
</DocsLink>
</p>
{!readOnly && (
<AddButton
label={t("plugins.manager.registries.add")}
onClick={() => setAdding(true)}
/>
)}
</div>
{state && (
<div className="flex flex-col border-t border-border/40">
<ListRow
stripe={0}
icon={<Library />}
title={t("plugins.manager.registries.officialName")}
badges={
<ListBadge tone="brand">
{t("plugins.manager.registries.alwaysOn")}
</ListBadge>
}
meta={meta("official", state.official.url)}
>
{errorLine("official")}
</ListRow>
<ListRow
stripe={1}
icon={<Library />}
tone={state.community.enabled ? "brand" : "muted"}
dimmed={!state.community.enabled}
title={t("plugins.manager.registries.communityName")}
meta={meta("community", state.community.url)}
trailing={
<FakeSwitch
checked={state.community.enabled}
disabled={readOnly || busy === "community"}
onChange={(next) => void toggle("community", next)}
/>
}
>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.communityHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")}
</DocsLink>
</span>
{state.community.enabled && errorLine("community")}
</ListRow>
{state.custom.map((entry, index) => (
<ListRow
key={entry.id}
stripe={index}
icon={<Library />}
tone={entry.enabled ? "warning" : "muted"}
dimmed={!entry.enabled}
title={entry.name}
badges={
<ListBadge tone="warning">
{t("plugins.manager.registries.customBadge")}
</ListBadge>
}
meta={meta(
entry.id,
entry.url,
t("plugins.manager.registries.keyFingerprint", {
keyId: entry.keyId,
}),
)}
trailing={
<FakeSwitch
checked={entry.enabled}
disabled={readOnly || busy === entry.id}
onChange={(next) => void toggle(entry.id, next)}
/>
}
actions={
readOnly ? undefined : (
<ListRowAction
label={t("plugins.manager.registries.remove")}
tone="destructive"
disabled={busy === entry.id}
onClick={() => void remove(entry.id, entry.name)}
>
<Trash2 />
</ListRowAction>
)
}
>
{entry.enabled && errorLine(entry.id)}
</ListRow>
))}
</div>
)}
<AddRegistryView
open={adding}
onOpenChange={setAdding}
onAdded={async () => {
setAdding(false);
await load();
await manager.refresh();
}}
/>
</div>
);
}
function AddRegistryView({
open,
onOpenChange,
onAdded,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdded: () => Promise<void>;
}) {
const { t } = useTranslation();
const [url, setUrl] = useState("");
const [preview, setPreview] = useState<RegistryPreview | null>(null);
const [error, setError] = useState<string | null>(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 (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("plugins.manager.registries.addTitle")}
icon={<Library className="size-4" />}
footer={
<FormFooter
onCancel={() => onOpenChange(false)}
onSave={() => void (preview ? add() : check())}
saveLabel={
preview
? t("plugins.manager.registries.addConfirm")
: t("plugins.manager.registries.check")
}
saving={working}
disabled={!url.trim()}
/>
}
>
<div className="flex flex-col gap-3 p-3">
<p className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.addHint")}
</p>
<Input
value={url}
onChange={(e) => {
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 && (
<p className="text-[11px] leading-snug text-destructive">{error}</p>
)}
{preview && (
<div className="flex flex-col gap-1.5 border border-warning/40 bg-warning/5 p-3">
<span className="text-sm font-semibold">{preview.name}</span>
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.registries.pluginCount", {
count: preview.plugins,
})}
</span>
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.registries.keyFingerprint", {
keyId: preview.keyId,
})}
</span>
<p className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.customWarning")}
</p>
</div>
)}
</div>
</InlineView>
);
}
+26
View File
@@ -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 (
<span
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
title={t(`plugins.manager.registries.${source}BadgeHint`, {
name: label,
})}
>
{label}
</span>
);
}
export function BetaBadge() { export function BetaBadge() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
+16 -3
View File
@@ -67,9 +67,13 @@ export interface PluginEntry {
source: PluginSource; source: PluginSource;
/** Installed from a file or folder with no registry signature behind it. */ /** Installed from a file or folder with no registry signature behind it. */
unverified: boolean; 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 { export function isPrerelease(version: string | null | undefined): boolean {
return Boolean( return Boolean(
@@ -97,7 +101,10 @@ export function pluginSource(summary: PluginSummary): PluginSource {
if (summary.source === "bundled" || summary.tier === "bundled") { if (summary.source === "bundled" || summary.tier === "bundled") {
return "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 { export function pluginStatus(summary: PluginSummary): PluginStatus {
@@ -158,6 +165,8 @@ export function mergePlugins(
installCountSource: entry?.installCountSource ?? null, installCountSource: entry?.installCountSource ?? null,
source: pluginSource(summary), source: pluginSource(summary),
unverified: pluginSource(summary) === "unverified", 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), publishedAt: latestPublished(entry.versions),
installCount: entry.installCount ?? null, installCount: entry.installCount ?? null,
installCountSource: entry.installCountSource ?? null, installCountSource: entry.installCountSource ?? null,
source: "official", source: entry.tier ?? "official",
unverified: false, unverified: false,
registryId: entry.registryId ?? "official",
registryName: entry.registryName ?? null,
}); });
} }
@@ -239,6 +250,8 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
installCountSource: null, installCountSource: null,
source: "unverified", source: "unverified",
unverified: true, unverified: true,
registryId: null,
registryName: null,
}; };
} }
+3
View File
@@ -183,6 +183,7 @@ export function usePluginsManager() {
const result = await installPlugin(plugin.id, { const result = await installPlugin(plugin.id, {
version: version === plugin.latestVersion ? undefined : version, version: version === plugin.latestVersion ? undefined : version,
capabilities, capabilities,
registryId: plugin.registryId ?? undefined,
}); });
if (result.state === "failed" || result.state === "blocked") { if (result.state === "failed" || result.state === "blocked") {
toast.error( toast.error(
@@ -585,6 +586,8 @@ export function usePluginsManager() {
registryError, registryError,
managedByServer: registry?.managedByServer ?? false, managedByServer: registry?.managedByServer ?? false,
lastCheckedAt: registry?.registry.lastCheckedAt ?? null, lastCheckedAt: registry?.registry.lastCheckedAt ?? null,
registries: registry?.registries ?? [],
reload: () => load(),
busy, busy,
consent, consent,
setConsent, setConsent,
+5 -2
View File
@@ -40,6 +40,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
confirmConsent: vi.fn(), confirmConsent: vi.fn(),
developerMode: false, developerMode: false,
signedOnly: false, signedOnly: false,
registries: [],
}), }),
})); }));
@@ -60,12 +61,14 @@ beforeEach(() => localStorage.clear());
afterEach(cleanup); afterEach(cleanup);
describe("PluginsTab", () => { 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(); await renderTab();
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse)); fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
expect( expect(
screen.getByText(en.plugins.manager.communitySoon, { exact: false }), screen.getByText(en.plugins.manager.registries.browseHint, {
exact: false,
}),
).toBeTruthy(); ).toBeTruthy();
const link = screen.getByText(en.plugins.manager.communitySubmit); const link = screen.getByText(en.plugins.manager.communitySubmit);
expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry")); expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry"));
@@ -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(
<I18nextProvider i18n={i18n}>
<RegistriesSection manager={manager} />
</I18nextProvider>,
);
}
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",
),
);
});
});