mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback * fix: proper plural forms for count strings and full translations * fix: plural forms for selected, hidden and active counts * fix: synced plugin updates failing on desktop and server-only plugins shown as stopped * fix: restore rounded corners on app icons and favicon * feat: desktop app auto updates with automatic, ask first or never modes * feat: community and custom plugin registries * feat: plugin guide for AI tools in the SDK with a lint check * fix: keep the terminal WebSocket open past nginx's 60s default (#1588) * chore: update changelog * feat: show plugin download counts only and stop CI builds inflating them * feat: hide installed plugins in the browse tab by default * chore: pin bundled plugins to latest releases * chore: update translations * fix: focusing a saved password field and saving no longer erases it * fix: tell desktop users when their 2.8 data needs 2.9 first * fix: point windows update info at the x64 installer * fix: keep desktop client tunnel sockets alive behind proxies * chore: pin bundled plugins to latest releases * fix: cover client tunnel keepalive in the c2s runtime tests * fix: typing after opening a host no longer goes to the host search * fix: accept tab and = separators in SSH config import (#1598) * chore: update changelog * chore: drop plugin versions from core changelog * chore: update translations * chore: lint, format, and bump version to 26.10.1 --------- Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com> Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
174 files changed
+94334
-39995
No files matched your search
@@ -542,7 +542,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
plugin_id TEXT NOT NULL,
|
||||
registry_id TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'aggregate-telemetry',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (plugin_id, registry_id)
|
||||
);
|
||||
|
||||
@@ -80,6 +80,8 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"donation_modal_dismissed",
|
||||
],
|
||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||
// 26.10.1 shows downloads only.
|
||||
plugin_install_counts: ["source"],
|
||||
user_preferences: [
|
||||
"command_autocomplete",
|
||||
"terminal_defaults",
|
||||
|
||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = mysqlTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = mysqlTable(
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: int("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = pgTable("plugin_registries", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = pgTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = pgTable(
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -1051,10 +1051,9 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = sqliteTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1063,7 +1062,6 @@ export const pluginInstallCounts = sqliteTable(
|
||||
pluginId: text("plugin_id").notNull(),
|
||||
registryId: text("registry_id").notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -7,10 +7,9 @@ export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
||||
export interface PluginInstallCountInput {
|
||||
pluginId: string;
|
||||
count: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Popularity numbers for the store, one row per plugin and registry. */
|
||||
/** Download counts for the store, one row per plugin and registry. */
|
||||
export class PluginInstallCountRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
@@ -38,13 +37,11 @@ export class PluginInstallCountRepository {
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
const row = existing.get(entry.pluginId);
|
||||
if (row && row.count === entry.count && row.source === entry.source) {
|
||||
continue;
|
||||
}
|
||||
if (row && row.count === entry.count) continue;
|
||||
if (row) {
|
||||
await this.context.drizzle
|
||||
.update(pluginInstallCounts)
|
||||
.set({ count: entry.count, source: entry.source, updatedAt: now })
|
||||
.set({ count: entry.count, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
||||
@@ -56,7 +53,6 @@ export class PluginInstallCountRepository {
|
||||
pluginId: entry.pluginId,
|
||||
registryId,
|
||||
count: entry.count,
|
||||
source: entry.source,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,11 +91,11 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
if (spaceIdx === -1) continue;
|
||||
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const key = line.slice(0, spaceIdx).toLowerCase();
|
||||
const value = line.slice(spaceIdx + 1).trim();
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2];
|
||||
|
||||
if (key === "host") {
|
||||
if (current && current.hostname) results.push(current);
|
||||
|
||||
@@ -17,8 +17,13 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
|
||||
import {
|
||||
isLinkedDesktop,
|
||||
MAX_UPLOAD_BYTES,
|
||||
PluginManageError,
|
||||
} from "../../plugins/manage.js";
|
||||
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||
import { RegistryError } from "../../plugins/registries.js";
|
||||
import {
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
@@ -236,6 +241,9 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does.
|
||||
*
|
||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||
* only run on that server and stay off here on purpose.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -253,6 +261,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId,
|
||||
"admin.plugins.manage",
|
||||
);
|
||||
const linked = await isLinkedDesktop();
|
||||
|
||||
// One query for every plugin rather than one per plugin.
|
||||
const grantsByPlugin = new Map<string, string[]>();
|
||||
@@ -285,6 +294,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
let desktopMode: unknown = loaded?.manifest.desktop;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
@@ -299,7 +309,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
env?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
desktop?: unknown;
|
||||
};
|
||||
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
@@ -332,6 +344,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
@@ -392,6 +405,7 @@ async function auditPluginAction(
|
||||
action: string,
|
||||
pluginId: string,
|
||||
details: Record<string, unknown> = {},
|
||||
resourceType = "plugin",
|
||||
): Promise<void> {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
@@ -400,7 +414,7 @@ async function auditPluginAction(
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action,
|
||||
resourceType: "plugin",
|
||||
resourceType,
|
||||
resourceId: pluginId,
|
||||
resourceName: pluginId,
|
||||
details: JSON.stringify(details),
|
||||
@@ -419,7 +433,7 @@ function sendManageError(
|
||||
fallback: string,
|
||||
operation: string,
|
||||
): void {
|
||||
if (error instanceof PluginManageError) {
|
||||
if (error instanceof PluginManageError || error instanceof RegistryError) {
|
||||
res.status(error.status).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
@@ -570,6 +584,304 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries:
|
||||
* get:
|
||||
* summary: List the plugin registries
|
||||
* description: The official registry, which is always on, the community registry and whether it is on, and every custom registry an admin added with its pinned key fingerprint.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registries.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
*/
|
||||
router.get(
|
||||
"/registries",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { readRegistriesConfig } =
|
||||
await import("../../plugins/registries.js");
|
||||
const { officialSource, communitySource } =
|
||||
await import("../../plugins/registry-index.js");
|
||||
const config = await readRegistriesConfig();
|
||||
res.json({
|
||||
managedByServer: await isLinkedDesktop(),
|
||||
official: { id: "official", url: officialSource().url },
|
||||
community: {
|
||||
id: "community",
|
||||
url: communitySource().url,
|
||||
enabled: config.community.enabled,
|
||||
},
|
||||
custom: config.custom.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
keyId: entry.keyId,
|
||||
enabled: entry.enabled,
|
||||
addedAt: entry.addedAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read plugin registries",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/preview:
|
||||
* post:
|
||||
* summary: Read a registry index before adding it
|
||||
* description: Fetches the index at url and returns its name, the id it would get, how many plugins it lists and the fingerprint of the key it signs with. Nothing is saved. The index must list a publicKey.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - url
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What the registry would be added as.
|
||||
* 400:
|
||||
* description: Not an https URL, or the index has no publicKey.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: The registry is already added.
|
||||
* 502:
|
||||
* description: The index could not be read.
|
||||
*/
|
||||
router.post(
|
||||
"/registries/preview",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { previewRegistry } = await import("../../plugins/registries.js");
|
||||
res.json(await previewRegistry(req.body?.url));
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries:
|
||||
* post:
|
||||
* summary: Add a custom plugin registry
|
||||
* description: Reads the index again and pins the key it names. keyId must be the fingerprint the preview showed, otherwise the answer is 409 KEY_MISMATCH and nothing is saved. Every plugin installed from it must be signed by that key.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - url
|
||||
* - keyId
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* keyId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The added registry.
|
||||
* 400:
|
||||
* description: Not an https URL, no publicKey, or too many registries.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: Already added, or the key changed since the preview.
|
||||
* 502:
|
||||
* description: The index could not be read.
|
||||
*/
|
||||
router.post(
|
||||
"/registries",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { addRegistry } = await import("../../plugins/registries.js");
|
||||
const entry = await addRegistry(req.body?.url, req.body?.keyId);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
"add_plugin_registry",
|
||||
entry.id,
|
||||
{ url: entry.url, keyId: entry.keyId },
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
keyId: entry.keyId,
|
||||
enabled: entry.enabled,
|
||||
addedAt: entry.addedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to add the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/{id}:
|
||||
* patch:
|
||||
* summary: Turn a plugin registry on or off
|
||||
* description: Works for community and custom registries. Plugins already installed from a registry that is off keep running but get no updates.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - enabled
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registry was changed.
|
||||
* 400:
|
||||
* description: Invalid body, or the official registry.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such registry.
|
||||
*/
|
||||
router.patch(
|
||||
"/registries/:id",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = String(req.params.id);
|
||||
const enabled = req.body?.enabled;
|
||||
if (typeof enabled !== "boolean") {
|
||||
res.status(400).json({ error: "enabled must be a boolean" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { setRegistryEnabled } =
|
||||
await import("../../plugins/registries.js");
|
||||
await setRegistryEnabled(id, enabled);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
enabled ? "enable_plugin_registry" : "disable_plugin_registry",
|
||||
id,
|
||||
{},
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({ id, enabled });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to change the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/{id}:
|
||||
* delete:
|
||||
* summary: Remove a custom plugin registry
|
||||
* description: Refused with 409 REGISTRY_IN_USE while plugins installed from it remain, since its key has to stay trusted for them to load.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registry was removed.
|
||||
* 400:
|
||||
* description: Official and community can only be turned off.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such registry.
|
||||
* 409:
|
||||
* description: Plugins from it are still installed.
|
||||
*/
|
||||
router.delete(
|
||||
"/registries/:id",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = String(req.params.id);
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { removeRegistry } = await import("../../plugins/registries.js");
|
||||
await removeRegistry(id);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
"remove_plugin_registry",
|
||||
id,
|
||||
{},
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({ id });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to remove the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/state:
|
||||
@@ -1597,10 +1909,11 @@ function readVersion(body: unknown): string | undefined | null {
|
||||
* @openapi
|
||||
* /plugins/registry:
|
||||
* get:
|
||||
* summary: List the plugins in the official registry
|
||||
* summary: List the plugins in every registry that is on
|
||||
* description: >
|
||||
* Fetches the official registry index (cached for 15 minutes unless
|
||||
* refresh is set) and merges it with what is installed here: the
|
||||
* Fetches the index of every registry that is on (each cached for 15
|
||||
* minutes unless refresh is set), with each one's status under
|
||||
* registries, and merges them with what is installed here: the
|
||||
* installed version, the newest release this build can run, whether an
|
||||
* update asks for new capabilities, and the pin and auto-update choices.
|
||||
* tags:
|
||||
@@ -1741,10 +2054,10 @@ router.post(
|
||||
* @openapi
|
||||
* /plugins/{id}/install:
|
||||
* post:
|
||||
* summary: Install a plugin from the official registry
|
||||
* summary: Install a plugin from a registry
|
||||
* description: >
|
||||
* Downloads the release, checks its sha256 and signature against a key
|
||||
* this build trusts, loads it, grants the capabilities it declares and
|
||||
* Downloads the release, checks its sha256 and signature against the keys of
|
||||
* the registry it comes from, loads it, grants the capabilities it declares and
|
||||
* starts it along with any installed dependency that is off. Without a
|
||||
* version the newest compatible release is used; naming an older one
|
||||
* pins the plugin there. A bundled plugin at its shipped version needs
|
||||
@@ -1774,6 +2087,9 @@ router.post(
|
||||
* type: string
|
||||
* enum: [stable, beta]
|
||||
* description: The channel to follow. beta installs the newest beta when it is newer than stable.
|
||||
* registryId:
|
||||
* type: string
|
||||
* description: The registry to install from, official when unset. It must be turned on, and only the official registry can offer an official plugin id.
|
||||
* capabilities:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -1786,9 +2102,9 @@ router.post(
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: Not in the official registry.
|
||||
* description: Not in that registry.
|
||||
* 409:
|
||||
* description: Already installed, incompatible, the consent does not match, or managed by a linked server.
|
||||
* description: Already installed, incompatible, the consent does not match, the registry is off, the id belongs to an official plugin, or managed by a linked server.
|
||||
* 502:
|
||||
* description: The registry could not be reached.
|
||||
*/
|
||||
@@ -1804,15 +2120,19 @@ router.post(
|
||||
req.body?.channel === undefined
|
||||
? "stable"
|
||||
: parsePluginChannel(req.body.channel);
|
||||
const registryId =
|
||||
req.body?.registryId === undefined ? undefined : req.body.registryId;
|
||||
if (
|
||||
!ID_PATTERN.test(pluginId) ||
|
||||
version === null ||
|
||||
capabilities === null ||
|
||||
channel === null
|
||||
channel === null ||
|
||||
(registryId !== undefined &&
|
||||
(typeof registryId !== "string" || !ID_PATTERN.test(registryId)))
|
||||
) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid plugin id, version, channel or capabilities" });
|
||||
res.status(400).json({
|
||||
error: "Invalid plugin id, version, channel, registry or capabilities",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
@@ -1822,11 +2142,13 @@ router.post(
|
||||
const result = await installPlugin(pluginId, {
|
||||
version,
|
||||
channel,
|
||||
registryId,
|
||||
userId,
|
||||
capabilities,
|
||||
});
|
||||
await auditPluginAction(req, "install_plugin", pluginId, {
|
||||
version: result.version,
|
||||
registryId: registryId ?? "official",
|
||||
capabilities,
|
||||
});
|
||||
res.json(result);
|
||||
@@ -1847,7 +2169,7 @@ router.post(
|
||||
* post:
|
||||
* summary: Update or change the installed version of a plugin
|
||||
* description: >
|
||||
* Swaps in another release from the official registry, newer or older.
|
||||
* Swaps in another release from the registry the plugin came from, newer or older.
|
||||
* If the release declares capabilities the installed one does not, the
|
||||
* request must set acceptCapabilities and list exactly those added
|
||||
* capabilities in capabilities, otherwise it answers 409 with code
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The registry timer: refreshes the store's install counts and applies
|
||||
* The registry timer: refreshes the store's download counts and applies
|
||||
* plugin updates for plugins with auto-update on. An update that asks for a
|
||||
* new capability is never applied here; admins get an alert and review it in
|
||||
* the Plugins tab. Nothing is ever installed from here.
|
||||
@@ -13,8 +13,8 @@ const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
export async function runPluginAutoUpdate(): Promise<void> {
|
||||
const { syncInstallCounts } = await import("./install-counts.js");
|
||||
await syncInstallCounts();
|
||||
const { syncDownloadCounts } = await import("./download-counts.js");
|
||||
await syncDownloadCounts();
|
||||
|
||||
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
||||
if (await isLinkedDesktop()) return;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Download counts for the plugin store, copied from the registry's
|
||||
* stats.json into plugin_install_counts.
|
||||
*
|
||||
* A count is how often the plugin's .tmxplug release files were downloaded.
|
||||
* An instance that updates three times counts three times, and copies bundled
|
||||
* into Docker images or the desktop app never count. Treat it as a ranking,
|
||||
* never as a user count.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
fetchRegistryStats,
|
||||
officialSource,
|
||||
type RegistrySource,
|
||||
} from "./registry-index.js";
|
||||
|
||||
async function repository() {
|
||||
const { createCurrentPluginInstallCountRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginInstallCountRepository();
|
||||
}
|
||||
|
||||
const STALE_MS = 6 * 60 * 60_000;
|
||||
const FORCED_MS = 60_000;
|
||||
const WAIT_MS = 3_000;
|
||||
|
||||
let lastSyncAt = 0;
|
||||
let running: Promise<number> | null = null;
|
||||
|
||||
/**
|
||||
* Syncs when the counts are older than the registry timer's interval, or
|
||||
* older than a minute with force (the Refresh button). Waits a few seconds
|
||||
* at most so a slow registry never holds up the store; a sync that runs
|
||||
* longer still finishes in the background.
|
||||
*/
|
||||
export async function refreshDownloadCounts(force = false): Promise<void> {
|
||||
const age = Date.now() - lastSyncAt;
|
||||
if (!running && age >= (force ? FORCED_MS : STALE_MS)) {
|
||||
running = syncDownloadCounts().finally(() => {
|
||||
running = null;
|
||||
});
|
||||
}
|
||||
if (!running) return;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
running,
|
||||
new Promise((resolve) => {
|
||||
timeout = setTimeout(resolve, WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetDownloadCountRefresh(): void {
|
||||
lastSyncAt = 0;
|
||||
running = null;
|
||||
}
|
||||
|
||||
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
||||
export async function syncDownloadCounts(): Promise<number> {
|
||||
lastSyncAt = Date.now();
|
||||
let changed = 0;
|
||||
let sources: RegistrySource[] = [officialSource()];
|
||||
try {
|
||||
const { listSources } = await import("./registries.js");
|
||||
sources = await listSources();
|
||||
} catch {
|
||||
// Official only when the registry choices cannot be read.
|
||||
}
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const stats = await fetchRegistryStats(source);
|
||||
if (!stats) continue;
|
||||
const entries = [...stats.plugins].map(([pluginId, downloads]) => ({
|
||||
pluginId,
|
||||
count: downloads,
|
||||
}));
|
||||
changed += await (await repository()).upsertMany(source.id, entries);
|
||||
} catch (error) {
|
||||
pluginLogger.warn(
|
||||
`Could not sync plugin download counts from ${source.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function readDownloadCounts(
|
||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||
): Promise<Map<string, number>> {
|
||||
try {
|
||||
const rows = await (await repository()).listByRegistry(registryId);
|
||||
return new Map(rows.map((row) => [row.pluginId, row.count]));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import module from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getPluginsDir } from "./paths.js";
|
||||
|
||||
/**
|
||||
* A plugin build leaves host packages (drizzle-orm, express, ssh2, ...) for
|
||||
* core to supply, and Node finds them by walking up from the importing file.
|
||||
* That works for bundled plugins inside the app, but a .tmxplug unpacked into
|
||||
* a data dir outside it (the desktop app keeps it in AppData) finds nothing.
|
||||
* Those imports fall back to core's own copy, the one bundled plugins get.
|
||||
*/
|
||||
|
||||
let installed = false;
|
||||
|
||||
export function isBareSpecifier(specifier: string): boolean {
|
||||
if (specifier.startsWith(".") || specifier.startsWith("/")) return false;
|
||||
if (specifier.startsWith("#")) return false;
|
||||
// file:, node:, data:, and a Windows drive letter
|
||||
return !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier);
|
||||
}
|
||||
|
||||
/** Whether an import from parentURL may fall back to core's packages. */
|
||||
export function isUserPluginFile(
|
||||
parentURL: string | undefined,
|
||||
pluginsDir: string,
|
||||
): boolean {
|
||||
if (!parentURL?.startsWith("file:")) return false;
|
||||
let file: string;
|
||||
try {
|
||||
file = fileURLToPath(new URL(parentURL));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const relative = path.relative(path.resolve(pluginsDir), file);
|
||||
return (
|
||||
relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
export function installHostModuleFallback(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
// Node 22.15+. Older runtimes keep plain resolution.
|
||||
if (typeof module.registerHooks !== "function") return;
|
||||
module.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
|
||||
!isBareSpecifier(specifier) ||
|
||||
!isUserPluginFile(context.parentURL, getPluginsDir())
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return nextResolve(specifier, {
|
||||
...context,
|
||||
parentURL: import.meta.url,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Popularity numbers for the plugin store, copied from the registry's
|
||||
* stats.json into plugin_install_counts.
|
||||
*
|
||||
* Two sources, and the store prefers the first:
|
||||
* - "aggregate-telemetry": instances that reported the plugin enabled in the
|
||||
* last 7 days through the Usage Statistics plugin. It undercounts by every
|
||||
* instance that turned the report off or never installed that plugin.
|
||||
* - "github-releases": downloads of the plugin's .tmxplug release assets.
|
||||
* This counts downloads, not active installs: an instance that updates
|
||||
* three times counts three times, and Docker images that bundle the plugin
|
||||
* count once per build, not once per install.
|
||||
*
|
||||
* Treat both as a rough ranking, never as a user count.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js";
|
||||
|
||||
export const TELEMETRY_SOURCE = "aggregate-telemetry";
|
||||
export const DOWNLOADS_SOURCE = "github-releases";
|
||||
|
||||
export interface InstallCount {
|
||||
count: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
async function repository() {
|
||||
const { createCurrentPluginInstallCountRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginInstallCountRepository();
|
||||
}
|
||||
|
||||
/** Picks the number to show: active installs when known, downloads otherwise. */
|
||||
export function preferredCount(entry: {
|
||||
downloads: number;
|
||||
activeInstalls: number | null;
|
||||
}): InstallCount {
|
||||
return entry.activeInstalls !== null && entry.activeInstalls > 0
|
||||
? { count: entry.activeInstalls, source: TELEMETRY_SOURCE }
|
||||
: { count: entry.downloads, source: DOWNLOADS_SOURCE };
|
||||
}
|
||||
|
||||
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
||||
export async function syncInstallCounts(): Promise<number> {
|
||||
try {
|
||||
const stats = await fetchRegistryStats();
|
||||
if (!stats) return 0;
|
||||
const entries = [...stats.plugins].map(([pluginId, entry]) => ({
|
||||
pluginId,
|
||||
...preferredCount(entry),
|
||||
}));
|
||||
return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries);
|
||||
} catch (error) {
|
||||
pluginLogger.warn(
|
||||
`Could not sync plugin install counts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readInstallCounts(
|
||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||
): Promise<Map<string, InstallCount>> {
|
||||
try {
|
||||
const rows = await (await repository()).listByRegistry(registryId);
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.pluginId,
|
||||
{ count: row.count, source: row.source },
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
getPluginsDir,
|
||||
getUnpackedPluginsDir,
|
||||
} from "./paths.js";
|
||||
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
|
||||
import {
|
||||
requireSignedPlugins,
|
||||
verifyAnyTrusted,
|
||||
type KeyTier,
|
||||
} from "./trust.js";
|
||||
import { moveWithRetry } from "./fs-retry.js";
|
||||
import {
|
||||
createPluginContext,
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
import { resolveRequirements } from "./service-registry.js";
|
||||
import { resolveSecretRequirements } from "./secret-registry.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { installHostModuleFallback } from "./host-modules.js";
|
||||
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
|
||||
|
||||
export type PluginState =
|
||||
@@ -76,6 +81,8 @@ export interface LoadedPlugin {
|
||||
artifact?: string;
|
||||
/** Key id that signed the artifact, when a trusted key signed it. */
|
||||
signedBy?: string;
|
||||
/** Which set of keys that key belongs to. */
|
||||
signedTier?: KeyTier;
|
||||
}
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
@@ -344,10 +351,11 @@ export class PluginLoader {
|
||||
const buffer = await fs.promises.readFile(file);
|
||||
const sigFile = `${file}.sig`;
|
||||
let signedBy: string | undefined;
|
||||
let signedTier: KeyTier | undefined;
|
||||
|
||||
if (fs.existsSync(sigFile)) {
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const result = verifyPluginArtifact(
|
||||
const result = verifyAnyTrusted(
|
||||
buffer,
|
||||
sha256,
|
||||
await fs.promises.readFile(sigFile, "utf8"),
|
||||
@@ -356,6 +364,7 @@ export class PluginLoader {
|
||||
throw new Error(`signature check failed: ${result.reason}`);
|
||||
}
|
||||
signedBy = result.keyId;
|
||||
signedTier = result.tier;
|
||||
} else if (requireSignedPlugins()) {
|
||||
throw new Error(
|
||||
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
|
||||
@@ -401,7 +410,7 @@ export class PluginLoader {
|
||||
assertBundledUpdate(
|
||||
id,
|
||||
raw?.version,
|
||||
signedBy,
|
||||
signedTier === "official" ? signedBy : undefined,
|
||||
this.bundledVersions.get(id) ?? replaced?.manifest.version,
|
||||
this.pinned.get(id),
|
||||
);
|
||||
@@ -426,6 +435,7 @@ export class PluginLoader {
|
||||
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
|
||||
plugin.artifact = file;
|
||||
plugin.signedBy = signedBy;
|
||||
plugin.signedTier = signedTier;
|
||||
if (replaced) {
|
||||
pluginLogger.info(
|
||||
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
|
||||
@@ -567,6 +577,7 @@ export class PluginLoader {
|
||||
)}`;
|
||||
|
||||
try {
|
||||
installHostModuleFallback();
|
||||
const imported = (await import(entry)) as {
|
||||
activate?: PluginModule["activate"];
|
||||
deactivate?: PluginModule["deactivate"];
|
||||
@@ -615,7 +626,8 @@ export class PluginLoader {
|
||||
// A release signed by a trusted key is official whether it shipped in
|
||||
// the image or was downloaded, so it may adopt its 2.8 tables too.
|
||||
const applied = await migratePlugin(plugin.id, plugin.dir, {
|
||||
bundled: plugin.source === "bundled" || Boolean(plugin.signedBy),
|
||||
bundled:
|
||||
plugin.source === "bundled" || plugin.signedTier === "official",
|
||||
});
|
||||
// A new table may be where core data is waiting to move.
|
||||
if (applied.length > 0) {
|
||||
@@ -847,7 +859,7 @@ export function assertBundledUpdate(
|
||||
): void {
|
||||
if (!signedBy) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${id}"; only a signed update can replace it`,
|
||||
`a bundled plugin already uses the id "${id}"; only an official signed update can replace it`,
|
||||
);
|
||||
}
|
||||
if (typeof version !== "string" || !semver.valid(version)) {
|
||||
|
||||
+203
-31
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Installing, updating, uninstalling and switching plugins at runtime.
|
||||
*
|
||||
* The official registry is the one real source. With developer mode on, an
|
||||
* admin can also install a .tmxplug from a file; that skips the download,
|
||||
* Plugins come from the official registry, plus the community and custom
|
||||
* registries an admin turned on (see registries.ts). With developer mode on,
|
||||
* an admin can also install a .tmxplug from a file; that skips the download,
|
||||
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
||||
* image and can still be uninstalled: their files are deleted and the id is
|
||||
* recorded in the settings table, so the shipped copy is ignored from then
|
||||
@@ -38,14 +39,27 @@ import {
|
||||
getRegistryStatus,
|
||||
isApiCompatible,
|
||||
latestOnChannel,
|
||||
officialSource,
|
||||
parsePluginChannel,
|
||||
type PluginChannel,
|
||||
type RegistryIndex,
|
||||
type RegistryPlugin,
|
||||
type RegistrySource,
|
||||
type RegistryTier,
|
||||
type RegistryVersion,
|
||||
} from "./registry-index.js";
|
||||
import {
|
||||
findSource,
|
||||
listSources,
|
||||
loadCustomRegistryKeys,
|
||||
pinnedKeyOf,
|
||||
} from "./registries.js";
|
||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||
import { moveWithRetry } from "./fs-retry.js";
|
||||
import { readInstallCounts } from "./install-counts.js";
|
||||
import {
|
||||
readDownloadCounts,
|
||||
refreshDownloadCounts,
|
||||
} from "./download-counts.js";
|
||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||
import {
|
||||
parseManifest,
|
||||
@@ -127,6 +141,7 @@ async function readPins(): Promise<Map<string, string>> {
|
||||
export async function applyStoredPluginChoices(
|
||||
loader: PluginLoader,
|
||||
): Promise<void> {
|
||||
await loadCustomRegistryKeys();
|
||||
try {
|
||||
loader.setUninstalled(await readUninstalled());
|
||||
loader.setPinned(await readPins());
|
||||
@@ -208,12 +223,28 @@ function addedCapabilities(
|
||||
return next.filter((capability) => !have.has(capability));
|
||||
}
|
||||
|
||||
export interface RegistryListingSource {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: RegistryTier;
|
||||
url: string;
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
plugins: number;
|
||||
}
|
||||
|
||||
export interface RegistryListing {
|
||||
/** The official registry, kept for older clients. */
|
||||
registry: ReturnType<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. */
|
||||
managedByServer: boolean;
|
||||
plugins: Array<{
|
||||
id: string;
|
||||
registryId: string;
|
||||
registryName: string;
|
||||
tier: RegistryTier;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
@@ -245,28 +276,118 @@ export interface RegistryListing {
|
||||
pinnedVersion: string | null;
|
||||
autoUpdate: boolean;
|
||||
bundled: boolean;
|
||||
/** See install-counts.ts for what this number does and does not mean. */
|
||||
installCount: number | null;
|
||||
installCountSource: string | null;
|
||||
/** See download-counts.ts for what this number does and does not mean. */
|
||||
downloads: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
options: { force?: boolean } = {},
|
||||
): 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 records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
||||
);
|
||||
const counts = await readInstallCounts();
|
||||
await refreshDownloadCounts(options.force);
|
||||
const counts = new Map(
|
||||
await Promise.all(
|
||||
sources
|
||||
.filter((source) => source.tier !== "custom")
|
||||
.map(
|
||||
async (source) =>
|
||||
[source.id, await readDownloadCounts(source.id)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
const { loader } = getPluginRuntime();
|
||||
const shipped = loader.bundledIds();
|
||||
const reserved = reservedIds(official.value);
|
||||
|
||||
const registries: RegistryListingSource[] = [];
|
||||
// Per id, every registry that lists it, in source order.
|
||||
const offers = new Map<
|
||||
string,
|
||||
Array<{ source: RegistrySource; plugin: RegistryPlugin }>
|
||||
>();
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const source = sources[i];
|
||||
const result = results[i];
|
||||
const status = getRegistryStatus(source);
|
||||
let error = status.error;
|
||||
let listed = 0;
|
||||
if (result.status === "fulfilled") {
|
||||
if (await keyChanged(source, result.value)) {
|
||||
error = KEY_CHANGED_MESSAGE;
|
||||
} else {
|
||||
for (const plugin of result.value.plugins) {
|
||||
if (source.tier !== "official" && reserved.has(plugin.id)) continue;
|
||||
const list = offers.get(plugin.id) ?? [];
|
||||
list.push({ source, plugin });
|
||||
offers.set(plugin.id, list);
|
||||
listed++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error =
|
||||
result.reason instanceof Error
|
||||
? result.reason.message
|
||||
: String(result.reason);
|
||||
}
|
||||
registries.push({
|
||||
id: source.id,
|
||||
name:
|
||||
result.status === "fulfilled" && result.value.name
|
||||
? result.value.name
|
||||
: source.name,
|
||||
tier: source.tier,
|
||||
url: source.url,
|
||||
lastCheckedAt: status.lastCheckedAt,
|
||||
error,
|
||||
plugins: listed,
|
||||
});
|
||||
}
|
||||
|
||||
const chosen: Array<{ source: RegistrySource; plugin: RegistryPlugin }> = [];
|
||||
for (const [id, list] of offers) {
|
||||
// An installed plugin is always shown from the registry it came from.
|
||||
const from = records.get(id)?.registryId;
|
||||
chosen.push(list.find((offer) => offer.source.id === from) ?? list[0]);
|
||||
}
|
||||
|
||||
return {
|
||||
registry: { id: OFFICIAL_REGISTRY_ID, ...getRegistryStatus() },
|
||||
registries,
|
||||
managedByServer: await isLinkedDesktop(),
|
||||
plugins: index.plugins.map((plugin) => {
|
||||
plugins: chosen.map(({ source, plugin }) => {
|
||||
const record = records.get(plugin.id);
|
||||
const live = loader.get(plugin.id);
|
||||
const channel = channelOf(record);
|
||||
@@ -283,6 +404,9 @@ export async function listRegistry(
|
||||
);
|
||||
return {
|
||||
id: plugin.id,
|
||||
registryId: source.id,
|
||||
registryName: registries.find((entry) => entry.id === source.id)!.name,
|
||||
tier: source.tier,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
author: plugin.author,
|
||||
@@ -318,8 +442,7 @@ export async function listRegistry(
|
||||
pinnedVersion: record?.pinnedVersion ?? null,
|
||||
autoUpdate: Boolean(record?.autoUpdate),
|
||||
bundled: shipped.has(plugin.id),
|
||||
installCount: counts.get(plugin.id)?.count ?? null,
|
||||
installCountSource: counts.get(plugin.id)?.source ?? null,
|
||||
downloads: counts.get(source.id)?.get(plugin.id) ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -362,7 +485,8 @@ async function grantDeclared(
|
||||
invalidatePluginPermissionCache(plugin.id);
|
||||
}
|
||||
|
||||
type SwapSource = { release: RegistryVersion } | { upload: string };
|
||||
type SwapSource =
|
||||
{ release: RegistryVersion; source: RegistrySource } | { upload: string };
|
||||
|
||||
/**
|
||||
* Puts a registry release, or an uploaded file, in place of whatever copy of
|
||||
@@ -377,6 +501,7 @@ async function swapIn(
|
||||
const bundledVersion = loader.bundledVersion(pluginId);
|
||||
const useBundled =
|
||||
"release" in source &&
|
||||
source.source.tier === "official" &&
|
||||
bundledVersion !== undefined &&
|
||||
bundledVersion === source.release.version &&
|
||||
isBundledOnDisk(pluginId);
|
||||
@@ -385,7 +510,7 @@ async function swapIn(
|
||||
const staged = useBundled
|
||||
? null
|
||||
: "release" in source
|
||||
? await downloadRelease(pluginId, source.release)
|
||||
? await downloadRelease(pluginId, source.release, source.source.keys)
|
||||
: { file: source.upload, signatureFile: `${source.upload}.sig` };
|
||||
|
||||
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
||||
@@ -654,6 +779,8 @@ export interface InstallOptions {
|
||||
version?: string;
|
||||
/** Install only: the channel to follow from the start. */
|
||||
channel?: PluginChannel;
|
||||
/** Install only: the registry to install from. Official when unset. */
|
||||
registryId?: string;
|
||||
userId: string | null;
|
||||
acceptCapabilities?: boolean;
|
||||
/**
|
||||
@@ -684,33 +811,68 @@ function assertConsent(
|
||||
}
|
||||
}
|
||||
|
||||
function unreachable(error: unknown): PluginManageError {
|
||||
return new PluginManageError(
|
||||
`Could not reach the plugin registry: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
502,
|
||||
"REGISTRY_UNREACHABLE",
|
||||
);
|
||||
}
|
||||
|
||||
async function requireRelease(
|
||||
pluginId: string,
|
||||
version: string | undefined,
|
||||
channel: PluginChannel,
|
||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||
): Promise<{
|
||||
index: RegistryIndex;
|
||||
source: RegistrySource;
|
||||
release: RegistryVersion;
|
||||
latest: RegistryVersion | null;
|
||||
}> {
|
||||
const source = await findSource(registryId);
|
||||
if (!source) {
|
||||
throw new PluginManageError(
|
||||
`The registry "${registryId}" is turned off or was removed`,
|
||||
409,
|
||||
"REGISTRY_OFF",
|
||||
);
|
||||
}
|
||||
let index: RegistryIndex;
|
||||
try {
|
||||
index = await fetchRegistryIndex();
|
||||
index = await fetchRegistryIndex(source);
|
||||
} catch (error) {
|
||||
throw unreachable(error);
|
||||
}
|
||||
if (await keyChanged(source, index)) {
|
||||
throw new PluginManageError(
|
||||
`Could not reach the plugin registry: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
502,
|
||||
"REGISTRY_UNREACHABLE",
|
||||
`${source.name}: ${KEY_CHANGED_MESSAGE}`,
|
||||
409,
|
||||
"KEY_CHANGED",
|
||||
);
|
||||
}
|
||||
if (source.tier !== "official") {
|
||||
let official: RegistryIndex;
|
||||
try {
|
||||
official = await fetchRegistryIndex(officialSource());
|
||||
} catch (error) {
|
||||
throw unreachable(error);
|
||||
}
|
||||
if (reservedIds(official).has(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
`${pluginId} is an official plugin and can only come from the official registry`,
|
||||
409,
|
||||
"RESERVED_ID",
|
||||
);
|
||||
}
|
||||
}
|
||||
const found = findRelease(index, pluginId, version, channel);
|
||||
if (!found) {
|
||||
throw new PluginManageError(
|
||||
version
|
||||
? `${pluginId} ${version} is not in the official registry`
|
||||
: `${pluginId} is not in the official registry`,
|
||||
? `${pluginId} ${version} is not in ${source.name}`
|
||||
: `${pluginId} is not in ${source.name}`,
|
||||
404,
|
||||
"NOT_IN_REGISTRY",
|
||||
);
|
||||
@@ -723,12 +885,20 @@ async function requireRelease(
|
||||
);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
source,
|
||||
release: found.release,
|
||||
latest: latestOnChannel(found.plugin, channel),
|
||||
};
|
||||
}
|
||||
|
||||
/** What a registry install is recorded as. */
|
||||
function tierFor(plugin: LoadedPlugin, source: RegistrySource): string {
|
||||
if (source.tier === "official" && plugin.source === "bundled") {
|
||||
return "bundled";
|
||||
}
|
||||
return source.tier;
|
||||
}
|
||||
|
||||
/** The version to hold at: an explicit pick that is not the newest. */
|
||||
function pinFor(
|
||||
release: RegistryVersion,
|
||||
@@ -763,10 +933,11 @@ export function installPlugin(
|
||||
}
|
||||
|
||||
const channel = options.channel ?? "stable";
|
||||
const { release, latest } = await requireRelease(
|
||||
const { source, release, latest } = await requireRelease(
|
||||
pluginId,
|
||||
options.version,
|
||||
channel,
|
||||
options.registryId,
|
||||
);
|
||||
assertConsent(options.capabilities, release.capabilities);
|
||||
const pin = pinFor(release, latest, options.version);
|
||||
@@ -774,7 +945,7 @@ export function installPlugin(
|
||||
|
||||
let plugin: LoadedPlugin;
|
||||
try {
|
||||
plugin = await swapIn(pluginId, { release });
|
||||
plugin = await swapIn(pluginId, { release, source });
|
||||
} catch (error) {
|
||||
await applyPin(pluginId, null);
|
||||
throw new PluginManageError(
|
||||
@@ -785,8 +956,8 @@ export function installPlugin(
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
registryId: OFFICIAL_REGISTRY_ID,
|
||||
tier: plugin.source === "bundled" ? "bundled" : "official",
|
||||
registryId: source.id,
|
||||
tier: tierFor(plugin, source),
|
||||
pinnedVersion: pin,
|
||||
channel,
|
||||
});
|
||||
@@ -832,10 +1003,11 @@ async function updateUnlocked(
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
|
||||
const { release, latest } = await requireRelease(
|
||||
const { source, release, latest } = await requireRelease(
|
||||
pluginId,
|
||||
options.version,
|
||||
channelOf(record),
|
||||
record.registryId ?? OFFICIAL_REGISTRY_ID,
|
||||
);
|
||||
if (release.version === live.manifest.version) {
|
||||
throw new PluginManageError(
|
||||
@@ -867,7 +1039,7 @@ async function updateUnlocked(
|
||||
|
||||
let plugin: LoadedPlugin;
|
||||
try {
|
||||
plugin = await swapIn(pluginId, { release });
|
||||
plugin = await swapIn(pluginId, { release, source });
|
||||
} catch (error) {
|
||||
await applyPin(pluginId, record.pinnedVersion ?? null);
|
||||
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
|
||||
@@ -879,8 +1051,8 @@ async function updateUnlocked(
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
registryId: OFFICIAL_REGISTRY_ID,
|
||||
tier: plugin.source === "bundled" ? "bundled" : "official",
|
||||
registryId: source.id,
|
||||
tier: tierFor(plugin, source),
|
||||
pinnedVersion: pin,
|
||||
});
|
||||
await grantDeclared(plugin, options.userId);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* The official plugin registry: fetching its index and downloading a release.
|
||||
* Plugin registries: fetching an index and downloading a release.
|
||||
*
|
||||
* The index is a plain JSON file in Termix-SSH/Termix-Registry. Nothing in it
|
||||
* is trusted on its own: every download is checked against the sha256 and
|
||||
* Ed25519 signature listed for it, and the signature must come from a key
|
||||
* pinned in trust.ts. A tampered index can at most point at a file that then
|
||||
* fails the check.
|
||||
* An index is a plain JSON file. Official and community live in
|
||||
* Termix-SSH/Termix-Registry, custom ones anywhere an admin points to.
|
||||
* Nothing in an index is trusted on its own: every download is checked
|
||||
* against the sha256 and Ed25519 signature listed for it, and the signature
|
||||
* must come from that registry's keys (built in for official and community,
|
||||
* pinned when an admin added a custom one). A tampered index can at most
|
||||
* point at a file that then fails the check.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
@@ -20,13 +22,33 @@ import {
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { verifyPluginArtifact } from "./trust.js";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
TRUSTED_PLUGIN_KEYS,
|
||||
isPluginPublicKey,
|
||||
verifyPluginArtifact,
|
||||
type TrustedPluginKey,
|
||||
} from "./trust.js";
|
||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||
import { getPluginsDir } from "./paths.js";
|
||||
|
||||
export const OFFICIAL_REGISTRY_ID = "official";
|
||||
export const OFFICIAL_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
|
||||
export const COMMUNITY_REGISTRY_ID = "community";
|
||||
export const COMMUNITY_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json";
|
||||
|
||||
export type RegistryTier = "official" | "community" | "custom";
|
||||
|
||||
/** Where an index lives and which keys may sign what it lists. */
|
||||
export interface RegistrySource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
tier: RegistryTier;
|
||||
keys: readonly TrustedPluginKey[];
|
||||
}
|
||||
|
||||
const CACHE_MS = 15 * 60_000;
|
||||
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
||||
@@ -83,6 +105,8 @@ export interface RegistryIndex {
|
||||
registry: string;
|
||||
name: string;
|
||||
updatedAt?: string;
|
||||
/** The key the registry signs with, base64. Only read when adding one. */
|
||||
publicKey?: string;
|
||||
plugins: RegistryPlugin[];
|
||||
}
|
||||
|
||||
@@ -90,6 +114,26 @@ export function getRegistryUrl(): string {
|
||||
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL;
|
||||
}
|
||||
|
||||
export function officialSource(): RegistrySource {
|
||||
return {
|
||||
id: OFFICIAL_REGISTRY_ID,
|
||||
name: "Termix Official Plugins",
|
||||
url: getRegistryUrl(),
|
||||
tier: "official",
|
||||
keys: TRUSTED_PLUGIN_KEYS,
|
||||
};
|
||||
}
|
||||
|
||||
export function communitySource(): RegistrySource {
|
||||
return {
|
||||
id: COMMUNITY_REGISTRY_ID,
|
||||
name: "Termix Community Plugins",
|
||||
url: process.env.TERMIX_COMMUNITY_REGISTRY_URL || COMMUNITY_REGISTRY_URL,
|
||||
tier: "community",
|
||||
keys: COMMUNITY_PLUGIN_KEYS,
|
||||
};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
@@ -174,6 +218,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
||||
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
|
||||
name: asString(index.name) ?? "",
|
||||
updatedAt: asString(index.updatedAt),
|
||||
publicKey: isPluginPublicKey(index.publicKey) ? index.publicKey : undefined,
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
@@ -228,8 +273,18 @@ async function readLimited(response: Response, limit: number): Promise<Buffer> {
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
let cache: { index: RegistryIndex; at: number; hash: string } | null = null;
|
||||
let inflight: Promise<RegistryIndex> | null = null;
|
||||
interface CacheEntry {
|
||||
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 {
|
||||
url: string;
|
||||
@@ -237,63 +292,83 @@ export interface RegistryStatus {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let lastError: string | null = null;
|
||||
|
||||
export function getRegistryStatus(): RegistryStatus {
|
||||
export function getRegistryStatus(
|
||||
source: RegistrySource = officialSource(),
|
||||
): RegistryStatus {
|
||||
const cache = caches.get(cacheKey(source));
|
||||
return {
|
||||
url: getRegistryUrl(),
|
||||
url: source.url,
|
||||
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null,
|
||||
error: lastError,
|
||||
error: lastErrors.get(cacheKey(source)) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetches and parses an index with no caching, for previewing a new one. */
|
||||
export async function fetchIndexAt(url: string): Promise<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(
|
||||
source: RegistrySource = officialSource(),
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<RegistryIndex> {
|
||||
const key = cacheKey(source);
|
||||
const cache = caches.get(key);
|
||||
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
|
||||
return cache.index;
|
||||
}
|
||||
if (inflight) return inflight;
|
||||
const running = inflight.get(key);
|
||||
if (running) return running;
|
||||
|
||||
inflight = (async () => {
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await fetchFollowing(getRegistryUrl());
|
||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||
const index = parseRegistryIndex(JSON.parse(body.toString("utf8")));
|
||||
cache = {
|
||||
index,
|
||||
at: Date.now(),
|
||||
hash: crypto.createHash("sha256").update(body).digest("hex"),
|
||||
};
|
||||
lastError = null;
|
||||
const index = await fetchIndexAt(source.url);
|
||||
caches.set(key, { index, at: Date.now() });
|
||||
lastErrors.delete(key);
|
||||
return index;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
lastErrors.set(key, message);
|
||||
// A stale index beats none when the registry is briefly unreachable.
|
||||
if (cache) {
|
||||
pluginLogger.warn(`Plugin registry check failed: ${lastError}`, {
|
||||
operation: "plugin_registry",
|
||||
});
|
||||
return cache.index;
|
||||
const stale = caches.get(key);
|
||||
if (stale) {
|
||||
pluginLogger.warn(
|
||||
`Plugin registry ${source.id} check failed: ${message}`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
return stale.index;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
inflight = null;
|
||||
inflight.delete(key);
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
inflight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export interface RegistryStats {
|
||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||
/** Downloads per plugin id. */
|
||||
plugins: Map<string, number>;
|
||||
}
|
||||
|
||||
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
|
||||
export function getStatsUrl(): string | null {
|
||||
if (process.env.TERMIX_PLUGIN_STATS_URL) {
|
||||
export function getStatsUrl(
|
||||
source: RegistrySource = officialSource(),
|
||||
): string | null {
|
||||
if (source.tier === "custom") return null;
|
||||
if (source.tier === "official" && process.env.TERMIX_PLUGIN_STATS_URL) {
|
||||
return process.env.TERMIX_PLUGIN_STATS_URL;
|
||||
}
|
||||
const url = getRegistryUrl();
|
||||
const url = source.url;
|
||||
return /\/index\.json$/.test(url)
|
||||
? url.replace(/\/index\.json$/, "/stats.json")
|
||||
: null;
|
||||
@@ -306,27 +381,22 @@ function asCount(value: unknown): number | null {
|
||||
}
|
||||
|
||||
export function parseRegistryStats(raw: unknown): RegistryStats {
|
||||
const plugins = new Map<
|
||||
string,
|
||||
{ downloads: number; activeInstalls: number | null }
|
||||
>();
|
||||
const plugins = new Map<string, number>();
|
||||
const entries = (raw as { plugins?: unknown } | null)?.plugins;
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||
return { plugins };
|
||||
}
|
||||
for (const [id, value] of Object.entries(entries)) {
|
||||
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
|
||||
const entry = value as Record<string, unknown>;
|
||||
plugins.set(id, {
|
||||
downloads: asCount(entry.downloads) ?? 0,
|
||||
activeInstalls: asCount(entry.activeInstalls),
|
||||
});
|
||||
plugins.set(id, asCount((value as Record<string, unknown>).downloads) ?? 0);
|
||||
}
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
export async function fetchRegistryStats(): Promise<RegistryStats | null> {
|
||||
const url = getStatsUrl();
|
||||
export async function fetchRegistryStats(
|
||||
source: RegistrySource = officialSource(),
|
||||
): Promise<RegistryStats | null> {
|
||||
const url = getStatsUrl(source);
|
||||
if (!url) return null;
|
||||
const response = await fetchFollowing(url);
|
||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||
@@ -335,9 +405,9 @@ export async function fetchRegistryStats(): Promise<RegistryStats | null> {
|
||||
|
||||
/** Test seam. */
|
||||
export function resetRegistryCache(): void {
|
||||
cache = null;
|
||||
inflight = null;
|
||||
lastError = null;
|
||||
caches.clear();
|
||||
inflight.clear();
|
||||
lastErrors.clear();
|
||||
}
|
||||
|
||||
/** Every release a channel may install, newest first. */
|
||||
@@ -414,6 +484,7 @@ export function assertArtifactMatches(
|
||||
export async function downloadRelease(
|
||||
pluginId: string,
|
||||
release: RegistryVersion,
|
||||
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
|
||||
): Promise<{ file: string; signatureFile: string }> {
|
||||
if (!isApiCompatible(release.api)) {
|
||||
throw new Error(
|
||||
@@ -433,6 +504,7 @@ export async function downloadRelease(
|
||||
buffer,
|
||||
release.sha256,
|
||||
release.signature,
|
||||
keys,
|
||||
);
|
||||
if (verified.ok === false) {
|
||||
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
|
||||
|
||||
@@ -28,6 +28,49 @@ export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Signs community plugins after a person reviewed them. Its own tier on
|
||||
* purpose: it can never replace a bundled plugin or count as official.
|
||||
*/
|
||||
export const COMMUNITY_PLUGIN_KEYS: readonly TrustedPluginKey[] = [
|
||||
{
|
||||
id: "be5b24ea07b36624",
|
||||
publicKey: "LYdHHGJeinCZ7UA8R/o5i1P5W3+dzktTDn5bxp4nPpI=",
|
||||
addedIn: "26.10.1",
|
||||
},
|
||||
];
|
||||
|
||||
export type KeyTier = "official" | "community" | "custom";
|
||||
|
||||
/** Keys of registries an admin added, pinned when they were added. */
|
||||
let customKeys: readonly TrustedPluginKey[] = [];
|
||||
|
||||
export function setCustomPluginKeys(keys: readonly TrustedPluginKey[]): void {
|
||||
customKeys = keys.filter((key) => toPublicKey(key.publicKey) !== null);
|
||||
}
|
||||
|
||||
export function getCustomPluginKeys(): readonly TrustedPluginKey[] {
|
||||
return customKeys;
|
||||
}
|
||||
|
||||
/** First 16 hex chars of the sha256 of a raw base64 public key. */
|
||||
export function pluginKeyId(publicKey: string): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(Buffer.from(publicKey, "base64"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/** Whether a string is a raw base64 Ed25519 public key. */
|
||||
export function isPluginPublicKey(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
/^[A-Za-z0-9+/]{43}=$/.test(value) &&
|
||||
toPublicKey(value) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export type ArtifactVerification =
|
||||
{ ok: true; keyId: string } | { ok: false; reason: string };
|
||||
|
||||
@@ -86,6 +129,31 @@ export function verifyPluginArtifact(
|
||||
return { ok: false, reason: "signature does not match a trusted key" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a file against every key this build trusts, official first, and
|
||||
* says which tier signed it.
|
||||
*/
|
||||
export function verifyAnyTrusted(
|
||||
buffer: Buffer,
|
||||
sha256: string,
|
||||
signature: string,
|
||||
): { ok: true; keyId: string; tier: KeyTier } | { ok: false; reason: string } {
|
||||
const tiers: Array<[KeyTier, readonly TrustedPluginKey[]]> = [
|
||||
["official", TRUSTED_PLUGIN_KEYS],
|
||||
["community", COMMUNITY_PLUGIN_KEYS],
|
||||
["custom", customKeys],
|
||||
];
|
||||
let reason = "signature does not match a trusted key";
|
||||
for (const [tier, keys] of tiers) {
|
||||
if (keys.length === 0) continue;
|
||||
const result = verifyPluginArtifact(buffer, sha256, signature, keys);
|
||||
if (result.ok === true) return { ok: true, keyId: result.keyId, tier };
|
||||
reason = result.reason;
|
||||
if (reason !== "signature does not match a trusted key") break;
|
||||
}
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
/** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
|
||||
export function requireSignedPlugins(): boolean {
|
||||
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
unloadPlugin,
|
||||
} from "../../plugins/index.js";
|
||||
import { getPluginsDir } from "../../plugins/paths.js";
|
||||
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
import {
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
@@ -283,10 +284,12 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
}
|
||||
|
||||
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||
const { plugins } = await remoteJson<{ plugins: RemotePlugin[] }>(
|
||||
link,
|
||||
"/sync/v2/plugins",
|
||||
);
|
||||
const { plugins, registryKeys } = await remoteJson<{
|
||||
plugins: RemotePlugin[];
|
||||
registryKeys?: unknown[];
|
||||
}>(link, "/sync/v2/plugins");
|
||||
// Before any download: a plugin from a custom registry needs its key.
|
||||
await setSyncedRegistryKeys(Array.isArray(registryKeys) ? registryKeys : []);
|
||||
lastRemote = plugins;
|
||||
const remoteIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ async function listPluginsForDesktop() {
|
||||
* /sync/v2/plugins:
|
||||
* get:
|
||||
* summary: The features this server runs, for a linked desktop to mirror
|
||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns.
|
||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
@@ -509,7 +509,9 @@ router.get(
|
||||
),
|
||||
});
|
||||
}
|
||||
res.json({ plugins });
|
||||
const { customRegistryKeys } =
|
||||
await import("../../plugins/registries.js");
|
||||
res.json({ plugins, registryKeys: await customRegistryKeys() });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to list plugins for sync", error, {
|
||||
operation: "sync_plugins",
|
||||
|
||||
@@ -92,6 +92,13 @@ Host alias-only
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts tabs and = between keywords and values", () => {
|
||||
const config = "Host\tweb\n\tHostName=10.0.0.5\n\tUser = deploy\n";
|
||||
expect(parseSSHConfig(config)).toEqual([
|
||||
{ name: "web", hostname: "10.0.0.5", user: "deploy" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults port to undefined when not specified", () => {
|
||||
const config = `
|
||||
Host server
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginManageError } from "../../../plugins/manage.js";
|
||||
import { RegistryError } from "../../../plugins/registries.js";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
@@ -194,6 +195,19 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const registriesMock = vi.hoisted(() => ({
|
||||
readRegistriesConfig: vi.fn(),
|
||||
previewRegistry: vi.fn(),
|
||||
addRegistry: vi.fn(),
|
||||
setRegistryEnabled: vi.fn(),
|
||||
removeRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/registries.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/registries.js")>()),
|
||||
...registriesMock,
|
||||
}));
|
||||
|
||||
const onboardingMock = vi.hoisted(() => ({
|
||||
describeOnboardingPlugins: vi.fn(),
|
||||
applyOnboardingChoices: vi.fn(),
|
||||
@@ -443,6 +457,28 @@ describe("plugins route", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks server-only plugins on a linked desktop", async () => {
|
||||
state.plugins.set(
|
||||
"sample-plugin",
|
||||
makePlugin({ manifestJson: JSON.stringify({ desktop: "server" }) }),
|
||||
);
|
||||
|
||||
const read = async () =>
|
||||
(
|
||||
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
|
||||
runsOnServer: boolean;
|
||||
}>
|
||||
)[0].runsOnServer;
|
||||
|
||||
expect(await read()).toBe(false);
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
try {
|
||||
expect(await read()).toBe(true);
|
||||
} finally {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants a declared capability", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
@@ -577,6 +613,7 @@ describe("plugins route", () => {
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
runsOnServer: false,
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
@@ -744,6 +781,190 @@ describe("plugins route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin registries", () => {
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
user = "admin-1",
|
||||
) =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", "x-test-user-id": user },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(registriesMock)) fn.mockReset();
|
||||
manageMock.isLinkedDesktop.mockReset();
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
auditMock.logAudit.mockClear();
|
||||
});
|
||||
|
||||
it("is admin only", async () => {
|
||||
for (const [method, path, body] of [
|
||||
["GET", "/plugins/registries", undefined],
|
||||
[
|
||||
"POST",
|
||||
"/plugins/registries/preview",
|
||||
{ url: "https://x.com/i.json" },
|
||||
],
|
||||
["POST", "/plugins/registries", { url: "https://x.com/i.json" }],
|
||||
["PATCH", "/plugins/registries/community", { enabled: true }],
|
||||
["DELETE", "/plugins/registries/acme", undefined],
|
||||
] as const) {
|
||||
const res = await call(method, path, body, "user-2");
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||
expect(registriesMock.removeRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the registries without the full public keys", async () => {
|
||||
registriesMock.readRegistriesConfig.mockResolvedValue({
|
||||
community: { enabled: true },
|
||||
custom: [
|
||||
{
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
publicKey: "secret-looking",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await call("GET", "/plugins/registries");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.community.enabled).toBe(true);
|
||||
expect(body.custom[0]).toEqual({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("previews and adds a registry and audits the add", async () => {
|
||||
registriesMock.previewRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
plugins: 2,
|
||||
});
|
||||
const preview = await call("POST", "/plugins/registries/preview", {
|
||||
url: "https://acme.example/index.json",
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
expect((await preview.json()).keyId).toBe("abcd");
|
||||
|
||||
registriesMock.addRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
publicKey: "k",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
});
|
||||
const added = await call("POST", "/plugins/registries", {
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
});
|
||||
expect(added.status).toBe(200);
|
||||
expect(registriesMock.addRegistry).toHaveBeenCalledWith(
|
||||
"https://acme.example/index.json",
|
||||
"abcd",
|
||||
);
|
||||
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "add_plugin_registry",
|
||||
resourceType: "plugin_registry",
|
||||
resourceId: "acme",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes registry errors through with their code", async () => {
|
||||
registriesMock.addRegistry.mockRejectedValue(
|
||||
new RegistryError("changed", 409, "KEY_MISMATCH", { keyId: "new" }),
|
||||
);
|
||||
const res = await call("POST", "/plugins/registries", {
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "old",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toEqual({
|
||||
error: "changed",
|
||||
code: "KEY_MISMATCH",
|
||||
keyId: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a registry on and off and checks the body", async () => {
|
||||
expect(
|
||||
(await call("PATCH", "/plugins/registries/community", { enabled: "x" }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
const res = await call("PATCH", "/plugins/registries/community", {
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(registriesMock.setRegistryEnabled).toHaveBeenCalledWith(
|
||||
"community",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to remove a registry still in use", async () => {
|
||||
registriesMock.removeRegistry.mockRejectedValue(
|
||||
new RegistryError("in use", 409, "REGISTRY_IN_USE", {
|
||||
plugins: ["tool"],
|
||||
}),
|
||||
);
|
||||
const res = await call("DELETE", "/plugins/registries/acme");
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).plugins).toEqual(["tool"]);
|
||||
});
|
||||
|
||||
it("leaves registries to the server on a linked desktop", async () => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
const res = await call("PATCH", "/plugins/registries/community", {
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs from the registry the caller names", async () => {
|
||||
manageMock.installPlugin.mockReset();
|
||||
manageMock.installPlugin.mockResolvedValue({
|
||||
id: "neat",
|
||||
version: "1.0.0",
|
||||
state: "active",
|
||||
});
|
||||
const res = await call("POST", "/plugins/neat/install", {
|
||||
registryId: "community",
|
||||
capabilities: [],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(manageMock.installPlugin).toHaveBeenCalledWith(
|
||||
"neat",
|
||||
expect.objectContaining({ registryId: "community" }),
|
||||
);
|
||||
const bad = await call("POST", "/plugins/neat/install", {
|
||||
registryId: "../x",
|
||||
capabilities: [],
|
||||
});
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("install, update, uninstall", () => {
|
||||
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
|
||||
@@ -61,6 +61,7 @@ function setup() {
|
||||
getC2SRelayHeaders: headers,
|
||||
getWebSocketOptions: (_url: string, options: unknown) => options,
|
||||
attachC2SAuth: vi.fn(),
|
||||
keepC2SSocketAlive: vi.fn(),
|
||||
logToFile: vi.fn(),
|
||||
emitC2STunnelStatuses: vi.fn(),
|
||||
checkLocalPortAvailable: async () => ({ available: true }),
|
||||
@@ -177,3 +178,32 @@ describe("client tunnel runtime authentication", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("client tunnel socket keepalive", () => {
|
||||
it("pings an open socket until it closes", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const context = vm.createContext({
|
||||
setInterval,
|
||||
clearInterval,
|
||||
WebSocket: { OPEN: 1 },
|
||||
});
|
||||
vm.runInContext(
|
||||
section("function keepC2SSocketAlive(", "// C2S (client-to-server)"),
|
||||
context,
|
||||
);
|
||||
const ws = Object.assign(new EventEmitter(), {
|
||||
readyState: 1,
|
||||
ping: vi.fn(),
|
||||
});
|
||||
context.keepC2SSocketAlive(ws);
|
||||
vi.advanceTimersByTime(50_000);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||
ws.emit("close");
|
||||
vi.advanceTimersByTime(50_000);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | { plugins: Map<string, number> },
|
||||
fail: false,
|
||||
sources: [{ id: "official", tier: "official" }] as Array<{
|
||||
id: string;
|
||||
tier: string;
|
||||
}>,
|
||||
statsBySource: {} as Record<string, { plugins: Map<string, number> } | null>,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
officialSource: () => ({ id: "official", tier: "official" }),
|
||||
fetchRegistryStats: async (source: { id: string }) => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.statsBySource[source.id] ?? state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registries.js", () => ({
|
||||
listSources: async () => state.sources,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
readDownloadCounts,
|
||||
refreshDownloadCounts,
|
||||
resetDownloadCountRefresh,
|
||||
syncDownloadCounts,
|
||||
} = await import("../../plugins/download-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.sources = [{ id: "official", tier: "official" }];
|
||||
state.statsBySource = {};
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
resetDownloadCountRefresh();
|
||||
});
|
||||
|
||||
describe("refreshDownloadCounts", () => {
|
||||
const docker = () => ({ plugins: new Map([["docker", 5]]) });
|
||||
|
||||
it("syncs on the first look and not again while fresh", async () => {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
await refreshDownloadCounts();
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not sync again right away even when forced", async () => {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
await refreshDownloadCounts(true);
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("syncs again when forced after a minute", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
vi.setSystemTime(Date.now() + 61_000);
|
||||
await refreshDownloadCounts();
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
await refreshDownloadCounts(true);
|
||||
expect(state.upserted).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncDownloadCounts", () => {
|
||||
it("writes one row per plugin", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", 50],
|
||||
["tunnels", 8],
|
||||
]),
|
||||
};
|
||||
expect(await syncDownloadCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 50 },
|
||||
{ pluginId: "tunnels", count: 8 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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", 5]]) },
|
||||
community: { plugins: new Map([["neat", 3]]) },
|
||||
mine: null,
|
||||
};
|
||||
expect(await syncDownloadCounts()).toBe(2);
|
||||
expect(state.upserted.map((entry) => entry.registryId)).toEqual([
|
||||
"official",
|
||||
"community",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncDownloadCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncDownloadCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readDownloadCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20 }];
|
||||
const counts = await readDownloadCounts();
|
||||
expect(counts.get("docker")).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isBareSpecifier,
|
||||
isUserPluginFile,
|
||||
} from "../../plugins/host-modules.js";
|
||||
|
||||
describe("isBareSpecifier", () => {
|
||||
it("accepts package names", () => {
|
||||
expect(isBareSpecifier("drizzle-orm")).toBe(true);
|
||||
expect(isBareSpecifier("drizzle-orm/sqlite-core")).toBe(true);
|
||||
expect(isBareSpecifier("@termix-ssh/plugin-sdk/backend")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses paths, urls and builtins", () => {
|
||||
expect(isBareSpecifier("./local.js")).toBe(false);
|
||||
expect(isBareSpecifier("../up.js")).toBe(false);
|
||||
expect(isBareSpecifier("/abs/file.js")).toBe(false);
|
||||
expect(isBareSpecifier("file:///x.js")).toBe(false);
|
||||
expect(isBareSpecifier("node:fs")).toBe(false);
|
||||
expect(isBareSpecifier("C:\\x\\y.js")).toBe(false);
|
||||
expect(isBareSpecifier("#internal")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUserPluginFile", () => {
|
||||
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
|
||||
|
||||
it("matches files under the plugins dir, query included", () => {
|
||||
const file = path.join(root, ".unpacked", "demo", "dist", "backend.js");
|
||||
expect(isUserPluginFile(`${pathToFileURL(file).href}?v=1`, root)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores everything else", () => {
|
||||
const outside = path.resolve(os.tmpdir(), "termix-data", "other.js");
|
||||
expect(isUserPluginFile(pathToFileURL(outside).href, root)).toBe(false);
|
||||
expect(isUserPluginFile(pathToFileURL(root).href, root)).toBe(false);
|
||||
expect(isUserPluginFile(undefined, root)).toBe(false);
|
||||
expect(isUserPluginFile("data:text/javascript,1", root)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installHostModuleFallback", () => {
|
||||
let dataDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
dataDir = null;
|
||||
});
|
||||
|
||||
// A real Node process: vitest resolves imports on its own and would hide
|
||||
// what plain Node does.
|
||||
function importFromPlugin(withFallback: boolean) {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-host-modules-"));
|
||||
const dir = path.join(dataDir, "plugins", ".unpacked", "demo");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const entry = path.join(dir, "backend.mjs");
|
||||
fs.writeFileSync(
|
||||
entry,
|
||||
'import { sql } from "drizzle-orm";\nconsole.log(typeof sql);\n',
|
||||
);
|
||||
const hook = pathToFileURL(
|
||||
path.resolve(__dirname, "../../plugins/host-modules.ts"),
|
||||
).href;
|
||||
const script = [
|
||||
`const { installHostModuleFallback } = await import(${JSON.stringify(hook)});`,
|
||||
withFallback ? "installHostModuleFallback();" : "",
|
||||
`await import(${JSON.stringify(`${pathToFileURL(entry).href}?v=1`)});`,
|
||||
].join("\n");
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "-e", script],
|
||||
{
|
||||
cwd: path.resolve(__dirname, "../../../.."),
|
||||
env: { ...process.env, DATA_DIR: dataDir },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("lets a plugin outside the app import core's packages", () => {
|
||||
const result = importFromPlugin(true);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("function");
|
||||
});
|
||||
|
||||
it("fails the same import without it", () => {
|
||||
const result = importFromPlugin(false);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("Cannot find package 'drizzle-orm'");
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | {
|
||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||
},
|
||||
fail: false,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
fetchRegistryStats: async () => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
DOWNLOADS_SOURCE,
|
||||
TELEMETRY_SOURCE,
|
||||
preferredCount,
|
||||
readInstallCounts,
|
||||
syncInstallCounts,
|
||||
} = await import("../../plugins/install-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
});
|
||||
|
||||
describe("preferredCount", () => {
|
||||
it("prefers active installs and falls back to downloads", () => {
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
||||
count: 12,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncInstallCounts", () => {
|
||||
it("writes one row per plugin with its preferred source", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", { downloads: 50, activeInstalls: 20 }],
|
||||
["tunnels", { downloads: 8, activeInstalls: null }],
|
||||
]),
|
||||
};
|
||||
expect(await syncInstallCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
||||
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readInstallCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
||||
const counts = await readInstallCounts();
|
||||
expect(counts.get("docker")).toEqual({
|
||||
count: 20,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -33,6 +34,8 @@ const db = vi.hoisted(() => ({
|
||||
|
||||
const registry = vi.hoisted(() => ({
|
||||
index: { registry: "official", name: "", plugins: [] as unknown[] },
|
||||
/** Indexes of the other registries, by id. Missing means unreachable. */
|
||||
others: {} as Record<string, Record<string, unknown>>,
|
||||
download: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -97,7 +100,14 @@ vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
|
||||
fetchRegistryIndex: async (source?: { id: string }) => {
|
||||
if (!source || source.id === "official") {
|
||||
return actual.parseRegistryIndex(registry.index);
|
||||
}
|
||||
const other = registry.others[source.id];
|
||||
if (!other) throw new Error(`${source.id} is offline`);
|
||||
return actual.parseRegistryIndex(other);
|
||||
},
|
||||
downloadRelease: registry.download,
|
||||
};
|
||||
});
|
||||
@@ -216,6 +226,7 @@ beforeEach(() => {
|
||||
db.settings.clear();
|
||||
db.grants.length = 0;
|
||||
registry.index.plugins = [];
|
||||
registry.others = {};
|
||||
registry.download.mockReset();
|
||||
});
|
||||
|
||||
@@ -856,3 +867,220 @@ describe("release notes", () => {
|
||||
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("more registries", () => {
|
||||
const customKey = (() => {
|
||||
const { publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||
return publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
})();
|
||||
|
||||
function turnOnCommunity(): void {
|
||||
db.settings.set(
|
||||
"plugin_registries",
|
||||
JSON.stringify({ community: { enabled: true }, custom: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
it("lists community plugins only when it is on, never official ids", async () => {
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
name: "Community",
|
||||
plugins: [
|
||||
registryEntry("docker", [{ version: "9.0.0" }]),
|
||||
registryEntry("neat", [{ version: "1.0.0" }]),
|
||||
],
|
||||
};
|
||||
await boot();
|
||||
|
||||
let listing = await manage.listRegistry();
|
||||
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
||||
expect(listing.registries.map((r) => r.id)).toEqual(["official"]);
|
||||
|
||||
turnOnCommunity();
|
||||
listing = await manage.listRegistry();
|
||||
expect(
|
||||
listing.plugins.map((p) => [p.id, p.registryId, p.tier, p.latestVersion]),
|
||||
).toEqual([
|
||||
["docker", "official", "official", "1.0.0"],
|
||||
["neat", "community", "community", "1.0.0"],
|
||||
]);
|
||||
expect(listing.registries.find((r) => r.id === "community")).toMatchObject({
|
||||
tier: "community",
|
||||
plugins: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps listing official plugins when another registry is down", async () => {
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
const listing = await manage.listRegistry();
|
||||
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
||||
expect(listing.registries.find((r) => r.id === "community")?.error).toMatch(
|
||||
/offline/,
|
||||
);
|
||||
});
|
||||
|
||||
it("installs from the community registry with its keys and records it", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
registry.download.mockImplementation(() => stageArtifact("neat"));
|
||||
|
||||
const result = await manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
const keys = registry.download.mock.calls[0][2] as Array<{ id: string }>;
|
||||
expect(keys.map((key) => key.id)).toEqual(["be5b24ea07b36624"]);
|
||||
expect(db.rows.get("neat")).toMatchObject({
|
||||
registryId: "community",
|
||||
tier: "community",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a plugin from the registry it came from", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [
|
||||
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
registry.download.mockImplementation(() =>
|
||||
stageArtifact("neat", { version: "1.0.0" }),
|
||||
);
|
||||
await manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
version: "1.0.0",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
await manage.setPluginOptions("neat", { pinned: false });
|
||||
|
||||
registry.download.mockReset();
|
||||
registry.download.mockImplementation(() =>
|
||||
stageArtifact("neat", { version: "1.1.0" }),
|
||||
);
|
||||
const result = await manage.updatePlugin("neat", { userId: "admin" });
|
||||
expect(result.version).toBe("1.1.0");
|
||||
const [, release, keys] = registry.download.mock.calls[0];
|
||||
expect(release.url).toContain("neat-1.1.0");
|
||||
expect(keys.map((key: { id: string }) => key.id)).toEqual([
|
||||
"be5b24ea07b36624",
|
||||
]);
|
||||
expect(db.rows.get("neat")).toMatchObject({ registryId: "community" });
|
||||
});
|
||||
|
||||
it("stops updating a community plugin once the official registry lists its id", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [
|
||||
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
registry.download.mockImplementation(() => stageArtifact("neat"));
|
||||
await manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
version: "1.0.0",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
registry.index.plugins = [registryEntry("neat", [{ version: "5.0.0" }])];
|
||||
await expect(
|
||||
manage.updatePlugin("neat", { userId: "admin", version: "1.1.0" }),
|
||||
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
||||
});
|
||||
|
||||
it("refuses an official id from another registry", async () => {
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [registryEntry("docker", [{ version: "9.0.0" }])],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("docker", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a registry that is off", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
||||
};
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "REGISTRY_OFF" });
|
||||
});
|
||||
|
||||
it("stops trusting a custom registry whose index names a new key", async () => {
|
||||
const other = crypto
|
||||
.generateKeyPairSync("ed25519")
|
||||
.publicKey.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
db.settings.set(
|
||||
"plugin_registries",
|
||||
JSON.stringify({
|
||||
community: { enabled: false },
|
||||
custom: [
|
||||
{
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/index.json",
|
||||
publicKey: customKey,
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
registry.others.mine = {
|
||||
registry: "mine",
|
||||
publicKey: customKey,
|
||||
plugins: [registryEntry("tool", [{ version: "1.0.0" }])],
|
||||
};
|
||||
await boot();
|
||||
let listing = await manage.listRegistry();
|
||||
expect(listing.plugins.find((p) => p.id === "tool")?.tier).toBe("custom");
|
||||
|
||||
registry.others.mine.publicKey = other;
|
||||
listing = await manage.listRegistry();
|
||||
expect(listing.plugins.find((p) => p.id === "tool")).toBeUndefined();
|
||||
expect(listing.registries.find((r) => r.id === "mine")?.error).toMatch(
|
||||
/different signing key/,
|
||||
);
|
||||
await expect(
|
||||
manage.installPlugin("tool", {
|
||||
userId: "admin",
|
||||
registryId: "mine",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "KEY_CHANGED" });
|
||||
});
|
||||
});
|
||||
@@ -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")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.value as never,
|
||||
]),
|
||||
// The built in official keys are swapped for the test key. Keys a
|
||||
// caller passes for another registry are used as they are.
|
||||
verifyPluginArtifact: (
|
||||
buffer: Buffer,
|
||||
sha256: string,
|
||||
signature: string,
|
||||
keys?: readonly unknown[],
|
||||
) =>
|
||||
actual.verifyPluginArtifact(
|
||||
buffer,
|
||||
sha256,
|
||||
signature,
|
||||
keys && keys !== actual.TRUSTED_PLUGIN_KEYS
|
||||
? (keys as never)
|
||||
: [testKey.value as never],
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
communitySource,
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
fetchRegistryStats,
|
||||
@@ -35,6 +48,7 @@ import {
|
||||
parseRegistryIndex,
|
||||
parseRegistryStats,
|
||||
resetRegistryCache,
|
||||
type RegistrySource,
|
||||
type RegistryVersion,
|
||||
} from "../../plugins/registry-index.js";
|
||||
|
||||
@@ -91,6 +105,25 @@ afterEach(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex publicKey", () => {
|
||||
const key = publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
|
||||
it("keeps a valid key and drops anything else", () => {
|
||||
expect(parseRegistryIndex({ publicKey: key, plugins: [] }).publicKey).toBe(
|
||||
key,
|
||||
);
|
||||
expect(
|
||||
parseRegistryIndex({ publicKey: "not a key", plugins: [] }).publicKey,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseRegistryIndex({ publicKey: 42, plugins: [] }).publicKey,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex", () => {
|
||||
it("keeps valid entries and sorts versions newest first", () => {
|
||||
const index = parseRegistryIndex({
|
||||
@@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => {
|
||||
fetchMock.mockResolvedValueOnce(response(body));
|
||||
const first = await fetchRegistryIndex();
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
expect(await fetchRegistryIndex({ force: true })).toBe(first);
|
||||
expect(await fetchRegistryIndex(undefined, { force: true })).toBe(first);
|
||||
});
|
||||
|
||||
it("caches each registry on its own", async () => {
|
||||
const other: RegistrySource = {
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/mine/index.json",
|
||||
tier: "custom",
|
||||
keys: [],
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(body))
|
||||
.mockResolvedValueOnce(
|
||||
response(JSON.stringify({ registry: "mine", plugins: [] })),
|
||||
);
|
||||
const official = await fetchRegistryIndex();
|
||||
const mine = await fetchRegistryIndex(other);
|
||||
expect(official.plugins).toHaveLength(1);
|
||||
expect(mine.plugins).toHaveLength(0);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(other.url);
|
||||
expect(await fetchRegistryIndex(other)).toBe(mine);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws when there is nothing cached", async () => {
|
||||
@@ -384,6 +439,38 @@ describe("downloadRelease", () => {
|
||||
).rejects.toThrow(/sha256/);
|
||||
});
|
||||
|
||||
it("checks against the keys of the registry it came from", async () => {
|
||||
const signed = sign(artifact);
|
||||
const own = crypto.generateKeyPairSync("ed25519");
|
||||
const ownKey = {
|
||||
id: "own",
|
||||
publicKey: own.publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "admin",
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: artifact.length }), [
|
||||
ownKey,
|
||||
]),
|
||||
).rejects.toThrow(/trusted key/);
|
||||
|
||||
const digest = crypto.createHash("sha256").update(artifact).digest();
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
const result = await downloadRelease(
|
||||
"docker",
|
||||
version({
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, own.privateKey).toString("base64"),
|
||||
size: artifact.length,
|
||||
}),
|
||||
[ownKey],
|
||||
);
|
||||
expect(fs.readFileSync(result.file)).toEqual(artifact);
|
||||
});
|
||||
|
||||
it("refuses a release built for another plugin API", async () => {
|
||||
await expect(
|
||||
downloadRelease("docker", version({ api: "2" })),
|
||||
@@ -407,19 +494,34 @@ describe("registry stats", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("has a community stats file and none for custom registries", () => {
|
||||
expect(getStatsUrl(communitySource())).toBe(
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/stats.json",
|
||||
);
|
||||
expect(
|
||||
getStatsUrl({
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/index.json",
|
||||
tier: "custom",
|
||||
keys: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps valid counts and drops the rest", () => {
|
||||
const stats = parseRegistryStats({
|
||||
plugins: {
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
docker: { downloads: 120 },
|
||||
tunnels: { downloads: 7 },
|
||||
"../bad": { downloads: 1 },
|
||||
broken: { downloads: -3, activeInstalls: "many" },
|
||||
broken: { downloads: -3 },
|
||||
},
|
||||
});
|
||||
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
tunnels: { downloads: 7, activeInstalls: null },
|
||||
broken: { downloads: 0, activeInstalls: null },
|
||||
docker: 120,
|
||||
tunnels: 7,
|
||||
broken: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,9 +530,6 @@ describe("registry stats", () => {
|
||||
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||
);
|
||||
const stats = await fetchRegistryStats();
|
||||
expect(stats?.plugins.get("docker")).toEqual({
|
||||
downloads: 3,
|
||||
activeInstalls: null,
|
||||
});
|
||||
expect(stats?.plugins.get("docker")).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { PluginLoader } from "../../plugins/loader.js";
|
||||
const testKey = vi.hoisted(() => ({
|
||||
privateKey: null as crypto.KeyObject | null,
|
||||
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
|
||||
communityPrivateKey: null as crypto.KeyObject | null,
|
||||
community: { id: "community", publicKey: "", addedIn: "26.10.1" },
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
@@ -23,6 +25,17 @@ vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
|
||||
verifyAnyTrusted: (buffer: Buffer, sha256: string, signature: string) => {
|
||||
const official = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.key,
|
||||
]);
|
||||
if (official.ok === true) return { ...official, tier: "official" };
|
||||
const community = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.community,
|
||||
]);
|
||||
if (community.ok === true) return { ...community, tier: "community" };
|
||||
return community;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,6 +55,12 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
const community = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.communityPrivateKey = community.privateKey;
|
||||
testKey.community.publicKey = community.publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -68,7 +87,7 @@ function setup() {
|
||||
async function writeArtifact(
|
||||
dir: string,
|
||||
id: string,
|
||||
sig: "valid" | "wrong-key" | "none",
|
||||
sig: "valid" | "community" | "wrong-key" | "none",
|
||||
version = "1.0.0",
|
||||
): Promise<string> {
|
||||
const build = tempRoot("termix-build-");
|
||||
@@ -82,7 +101,9 @@ async function writeArtifact(
|
||||
const signer =
|
||||
sig === "valid"
|
||||
? testKey.privateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
: sig === "community"
|
||||
? testKey.communityPrivateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
const digest = crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(file))
|
||||
@@ -171,6 +192,27 @@ describe("updating a bundled plugin", () => {
|
||||
expect(plugin?.source).toBe("bundled");
|
||||
expect(plugin?.artifact).toBe(file);
|
||||
expect(plugin?.signedBy).toBe("test");
|
||||
expect(plugin?.signedTier).toBe("official");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for a community signed newer archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "community", "2.0.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("loads a community signed plugin and records its tier", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await writeArtifact(userDir, "community-one", "community");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
const plugin = loader.get("community-one");
|
||||
expect(plugin?.source).toBe("user");
|
||||
expect(plugin?.signedBy).toBe("community");
|
||||
expect(plugin?.signedTier).toBe("community");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
TRUSTED_PLUGIN_KEYS,
|
||||
isPluginPublicKey,
|
||||
pluginKeyId,
|
||||
requireSignedPlugins,
|
||||
setCustomPluginKeys,
|
||||
verifyAnyTrusted,
|
||||
verifyPluginArtifact,
|
||||
type TrustedPluginKey,
|
||||
} from "../../plugins/trust.js";
|
||||
@@ -97,6 +103,61 @@ describe("verifyPluginArtifact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("key tiers", () => {
|
||||
const artifact = Buffer.from("a plugin archive");
|
||||
|
||||
it("derives key ids the same way the built in keys were made", () => {
|
||||
for (const key of [...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS]) {
|
||||
expect(pluginKeyId(key.publicKey)).toBe(key.id);
|
||||
expect(isPluginPublicKey(key.publicKey)).toBe(true);
|
||||
}
|
||||
expect(isPluginPublicKey("bm9wZQ==")).toBe(false);
|
||||
expect(isPluginPublicKey(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the community key apart from the official ones", () => {
|
||||
const official = new Set(TRUSTED_PLUGIN_KEYS.map((key) => key.id));
|
||||
for (const key of COMMUNITY_PLUGIN_KEYS) {
|
||||
expect(official.has(key.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies a custom key only while it is set, as the custom tier", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
|
||||
setCustomPluginKeys([key]);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature)).toEqual({
|
||||
ok: true,
|
||||
keyId: "test",
|
||||
tier: "custom",
|
||||
});
|
||||
|
||||
setCustomPluginKeys([]);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a custom key that is not a valid public key", () => {
|
||||
setCustomPluginKeys([{ id: "bad", publicKey: "bm9wZQ==", addedIn: "x" }]);
|
||||
const { privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
setCustomPluginKeys([]);
|
||||
});
|
||||
|
||||
it("stops at a sha256 mismatch", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
setCustomPluginKeys([key]);
|
||||
const { signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, "0".repeat(64), signature)).toEqual({
|
||||
ok: false,
|
||||
reason: "sha256 does not match the file",
|
||||
});
|
||||
setCustomPluginKeys([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSignedPlugins", () => {
|
||||
it("is on only for the exact value true", () => {
|
||||
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
|
||||
@@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({
|
||||
install: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
loadBundled: vi.fn(),
|
||||
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
@@ -29,6 +30,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({}),
|
||||
}));
|
||||
vi.mock("../../../plugins/registries.js", () => ({
|
||||
setSyncedRegistryKeys: state.syncedKeys,
|
||||
}));
|
||||
vi.mock("../../../sync/client/http.js", () => ({
|
||||
remoteFetch: state.fetch,
|
||||
remoteJson: vi.fn(),
|
||||
@@ -100,6 +104,19 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("mirrorPlugins", () => {
|
||||
it("trusts the server's custom registry keys before downloading", async () => {
|
||||
vi.mocked(remoteJson).mockResolvedValue({
|
||||
plugins: [],
|
||||
registryKeys: ["a-key"],
|
||||
});
|
||||
await mirrorPlugins({} as never);
|
||||
expect(state.syncedKeys).toHaveBeenCalledWith(["a-key"]);
|
||||
|
||||
vi.mocked(remoteJson).mockResolvedValue({ plugins: [] });
|
||||
await mirrorPlugins({} as never);
|
||||
expect(state.syncedKeys).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it("keeps the installed copy when the download fails", async () => {
|
||||
serverHas("1.1.0");
|
||||
state.fetch.mockResolvedValue({ ok: false, status: 502 });
|
||||
|
||||
Reference in new issue
Block a user