feat: consent check, developer mode file installs, install counts and issue links for plugins

This commit is contained in:
LukeGus committed 2026-10-06 14:30:56 -05:00
1 parent 26a474bb3d
commit 08db1fcdde
28 files changed
+2248 -72

No files matched your search

@@ -18,6 +18,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { HostDefaultsRepository } from "./host-defaults-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
@@ -240,6 +241,13 @@ export function createCurrentPluginSettingsRepository(): PluginSettingsRepositor
);
}
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
return new PluginInstallCountRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_install_count_repository_write"),
);
}
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
return new PluginMigrationRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,68 @@
import { and, eq } from "drizzle-orm";
import { pluginInstallCounts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
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. */
export class PluginInstallCountRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByRegistry(
registryId: string,
): Promise<PluginInstallCountRecord[]> {
return this.context.drizzle
.select()
.from(pluginInstallCounts)
.where(eq(pluginInstallCounts.registryId, registryId));
}
/** Writes only the rows that changed, so an unchanged sync costs no save. */
async upsertMany(
registryId: string,
entries: PluginInstallCountInput[],
now = new Date().toISOString(),
): Promise<number> {
const existing = new Map(
(await this.listByRegistry(registryId)).map((row) => [row.pluginId, row]),
);
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) {
await this.context.drizzle
.update(pluginInstallCounts)
.set({ count: entry.count, source: entry.source, updatedAt: now })
.where(
and(
eq(pluginInstallCounts.pluginId, entry.pluginId),
eq(pluginInstallCounts.registryId, registryId),
),
);
} else {
await this.context.drizzle.insert(pluginInstallCounts).values({
pluginId: entry.pluginId,
registryId,
count: entry.count,
source: entry.source,
updatedAt: now,
});
}
written += 1;
}
if (written > 0) await this.onWrite?.();
return written;
}
}
+285 -10
View File
@@ -1399,6 +1399,20 @@ router.put(
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/** The consented capability list, or null when the body is malformed. */
function readCapabilities(body: unknown): string[] | undefined | null {
const value = (body as { capabilities?: unknown } | undefined)?.capabilities;
if (value === undefined) return undefined;
if (
!Array.isArray(value) ||
value.length > 200 ||
!value.every((c) => typeof c === "string" && c.length <= 128)
) {
return null;
}
return value as string[];
}
function readVersion(body: unknown): string | undefined | null {
const version = (body as { version?: unknown } | undefined)?.version;
if (version === undefined || version === null || version === "") {
@@ -1503,7 +1517,9 @@ router.post(
* 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
* no download.
* no download. capabilities must list exactly what the consent prompt
* showed; when it differs from what the release asks for, nothing is
* downloaded and the answer is 409 CONSENT_MISMATCH with the real list.
* tags:
* - Plugins
* parameters:
@@ -1513,24 +1529,31 @@ router.post(
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - capabilities
* properties:
* version:
* type: string
* capabilities:
* type: array
* items:
* type: string
* responses:
* 200:
* description: The installed version and the plugin's state.
* 400:
* description: Invalid id or version.
* description: Invalid id, version or capability list, or no capability list.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: Not in the official registry.
* 409:
* description: Already installed, incompatible, or managed by a linked server.
* description: Already installed, incompatible, the consent does not match, or managed by a linked server.
* 502:
* description: The registry could not be reached.
*/
@@ -1541,17 +1564,29 @@ router.post(
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const version = readVersion(req.body);
if (!ID_PATTERN.test(pluginId) || version === null) {
res.status(400).json({ error: "Invalid plugin id or version" });
const capabilities = readCapabilities(req.body);
if (
!ID_PATTERN.test(pluginId) ||
version === null ||
capabilities === null
) {
res
.status(400)
.json({ error: "Invalid plugin id, version or capabilities" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { installPlugin } = await import("../../plugins/manage.js");
const userId = (req as AuthenticatedRequest).userId as string;
const result = await installPlugin(pluginId, { version, userId });
const result = await installPlugin(pluginId, {
version,
userId,
capabilities,
});
await auditPluginAction(req, "install_plugin", pluginId, {
version: result.version,
capabilities,
});
res.json(result);
} catch (error) {
@@ -1573,8 +1608,9 @@ router.post(
* description: >
* Swaps in another release from the official registry, newer or older.
* If the release declares capabilities the installed one does not, the
* request must set acceptCapabilities, otherwise it answers 409 with
* code CAPABILITIES_ADDED and the list. A running plugin is started
* request must set acceptCapabilities and list exactly those added
* capabilities in capabilities, otherwise it answers 409 with code
* CAPABILITIES_ADDED (or CONSENT_MISMATCH) and the list. A running plugin is started
* again on the new version. Naming a version that is not the newest
* pins the plugin to it.
* tags:
@@ -1595,6 +1631,10 @@ router.post(
* type: string
* acceptCapabilities:
* type: boolean
* capabilities:
* type: array
* items:
* type: string
* responses:
* 200:
* description: The new version and the plugin's state.
@@ -1614,8 +1654,15 @@ router.post(
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const version = readVersion(req.body);
if (!ID_PATTERN.test(pluginId) || version === null) {
res.status(400).json({ error: "Invalid plugin id or version" });
const capabilities = readCapabilities(req.body);
if (
!ID_PATTERN.test(pluginId) ||
version === null ||
capabilities === null
) {
res
.status(400)
.json({ error: "Invalid plugin id, version or capabilities" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
@@ -1626,6 +1673,7 @@ router.post(
version,
userId,
acceptCapabilities: req.body?.acceptCapabilities === true,
capabilities,
});
await auditPluginAction(req, "update_plugin", pluginId, {
version: result.version,
@@ -1807,4 +1855,231 @@ router.get(
},
);
/**
* @openapi
* /plugins/developer-mode:
* get:
* summary: Read whether plugin developer mode is on
* description: Developer mode lets admins install a .tmxplug from a file, without the registry's signature check. The UI shows a banner while it is on.
* tags:
* - Plugins
* responses:
* 200:
* description: Whether developer mode is on, and whether TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs anyway.
* 403:
* description: The caller lacks admin.plugins.manage.
*/
router.get(
"/developer-mode",
authenticateJWT,
requireManagePlugins,
async (_req: Request, res: Response) => {
try {
const { getDeveloperMode } = await import("../../plugins/manage.js");
const { requireSignedPlugins } = await import("../../plugins/trust.js");
res.json({
enabled: await getDeveloperMode(),
signedOnly: requireSignedPlugins(),
});
} catch (error) {
sendManageError(
res,
error,
"Failed to read developer mode",
"plugin_developer_mode",
);
}
},
);
/**
* @openapi
* /plugins/developer-mode:
* put:
* summary: Turn plugin developer mode on or off
* description: Turning it off drops any file that was uploaded but not installed yet. Plugins already installed from a file keep running and stay marked unverified.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - enabled
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: The saved value.
* 400:
* description: enabled is not a boolean.
* 403:
* description: The caller lacks admin.plugins.manage.
*/
router.put(
"/developer-mode",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
res.status(400).json({ error: "enabled must be a boolean" });
return;
}
try {
const { setDeveloperMode } = await import("../../plugins/manage.js");
await setDeveloperMode(enabled);
await auditPluginAction(req, "plugin_developer_mode", "*", { enabled });
res.json({ enabled });
} catch (error) {
sendManageError(
res,
error,
"Failed to save developer mode",
"plugin_developer_mode",
);
}
},
);
/**
* @openapi
* /plugins/upload:
* post:
* summary: Upload a .tmxplug to install from a file
* description: >
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
* The file is checked (archive, manifest, plugin API) and held for 30
* minutes, but nothing is loaded. The answer lists what it asks for so
* the admin can agree, then POST /plugins/upload/{token}/install
* installs it. A file can replace an earlier upload with the same id,
* never a bundled or registry plugin.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/octet-stream:
* schema:
* type: string
* format: binary
* responses:
* 200:
* description: A token plus the plugin's id, name, version and capabilities.
* 400:
* description: Not a valid .tmxplug or manifest.
* 403:
* description: Developer mode is off, signed plugins are required, or the caller lacks admin.plugins.manage.
* 409:
* description: The id belongs to a bundled or registry plugin, the plugin API does not match, or managed by a linked server.
* 413:
* description: The file is larger than 50 MB.
*/
router.post(
"/upload",
authenticateJWT,
requireManagePlugins,
express.raw({ type: "application/octet-stream", limit: "50mb" }),
async (req: Request, res: Response) => {
if (await refuseOnLinkedDesktop(res)) return;
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
res.status(400).json({
error: "Send the .tmxplug as application/octet-stream",
code: "INVALID_ARTIFACT",
});
return;
}
try {
const { stageUpload } = await import("../../plugins/manage.js");
res.json(await stageUpload(req.body));
} catch (error) {
sendManageError(
res,
error,
"Failed to read the uploaded plugin",
"plugin_upload",
);
}
},
);
/**
* @openapi
* /plugins/upload/{token}/install:
* post:
* summary: Install a plugin uploaded from a file
* description: >
* Developer mode only. capabilities must list exactly what the upload
* asks for, as the consent prompt showed it. The plugin is installed,
* granted those capabilities and started, and is marked unverified for
* as long as it stays installed.
* tags:
* - Plugins
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - capabilities
* properties:
* capabilities:
* type: array
* items:
* type: string
* responses:
* 200:
* description: The installed version and the plugin's state.
* 400:
* description: Invalid token or capability list.
* 403:
* description: Developer mode is off, signed plugins are required, or the caller lacks admin.plugins.manage.
* 404:
* description: The upload expired or never existed.
* 409:
* description: The consent does not match, the id is taken, or managed by a linked server.
*/
router.post(
"/upload/:token/install",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const token = String(req.params.token);
const capabilities = readCapabilities(req.body);
if (!/^[a-f0-9]{32}$/.test(token) || capabilities === null) {
res.status(400).json({ error: "Invalid upload token or capabilities" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { installUpload } = await import("../../plugins/manage.js");
const userId = (req as AuthenticatedRequest).userId as string;
const result = await installUpload(token, { userId, capabilities });
await auditPluginAction(req, "install_plugin_from_file", result.id, {
version: result.version,
capabilities,
verified: false,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to install the plugin",
"plugin_install",
);
}
},
);
export default router;
+67
View File
@@ -0,0 +1,67 @@
import zlib from "node:zlib";
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
/**
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
* disk. Returns null when the archive has no such file.
*/
export function readArtifactFile(
buffer: Buffer,
wanted: string,
): Buffer | null {
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
let offset = 0;
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const field = (start: number, length: number) =>
header
.subarray(start, start + length)
.toString("utf8")
.replace(/\0.*$/s, "");
const prefix = field(345, 155);
const name = (
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
).replace(/^\.\//, "");
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
if (!Number.isFinite(size) || size < 0) break;
const start = offset + 512;
if (name === wanted) return tar.subarray(start, start + size);
offset = start + Math.ceil(size / 512) * 512;
}
return null;
}
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
let raw: Buffer | null;
try {
raw = readArtifactFile(buffer, "manifest.json");
} catch {
throw new Error("the file is not a valid .tmxplug archive");
}
if (!raw) throw new Error("the archive has no manifest.json");
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString("utf8"));
} catch {
throw new Error("manifest.json is not valid JSON");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("manifest.json is not an object");
}
return parsed as Record<string, unknown>;
}
/** Whether two capability lists name the same set, order ignored. */
export function sameCapabilities(
a: readonly string[],
b: readonly string[],
): boolean {
const left = new Set(a);
const right = new Set(b);
if (left.size !== right.size) return false;
for (const capability of left) if (!right.has(capability)) return false;
return true;
}
+7 -3
View File
@@ -1,7 +1,8 @@
/**
* Applies plugin updates on a timer 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.
* The registry timer: refreshes the store's install 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.
*/
import { pluginLogger } from "../utils/logger.js";
@@ -12,6 +13,9 @@ 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 { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
if (await isLinkedDesktop()) return;
+79
View File
@@ -0,0 +1,79 @@
/**
* 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();
}
}
+306 -15
View File
@@ -1,12 +1,15 @@
/**
* Installing, updating, uninstalling and switching plugins at runtime.
*
* Only the official registry is a source for now. Bundled plugins ship in the
* 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,
* 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
* on and a reinstall downloads the plugin like any other.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import semver from "semver";
@@ -38,8 +41,15 @@ import {
} from "./registry-index.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
import { moveWithRetry } from "./fs-retry.js";
import { readInstallCounts } from "./install-counts.js";
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
import { parseManifest, type PluginManifest } from "./manifest.js";
import { requireSignedPlugins } from "./trust.js";
const UNINSTALLED_KEY = "plugins_uninstalled";
const DEVELOPER_MODE_KEY = "plugins_developer_mode";
/** Installed from a file with no registry signature behind it. Never cleared. */
export const UNVERIFIED_TIER = "unverified";
export class PluginManageError extends Error {
constructor(
@@ -212,6 +222,9 @@ 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;
}>;
}
@@ -223,6 +236,7 @@ export async function listRegistry(
const records = new Map(
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
);
const counts = await readInstallCounts();
const { loader } = getPluginRuntime();
const shipped = loader.bundledIds();
@@ -271,6 +285,8 @@ 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,
};
}),
};
@@ -313,24 +329,31 @@ async function grantDeclared(
invalidatePluginPermissionCache(plugin.id);
}
type SwapSource = { release: RegistryVersion } | { upload: string };
/**
* Puts `release` in place of whatever copy of the plugin is loaded. On a
* failed load the previous copy comes back.
* Puts a registry release, or an uploaded file, in place of whatever copy of
* the plugin is loaded. On a failed load the previous copy comes back.
*/
async function swapIn(
pluginId: string,
release: RegistryVersion,
source: SwapSource,
): Promise<LoadedPlugin> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
const bundledVersion = loader.bundledVersion(pluginId);
const useBundled =
"release" in source &&
bundledVersion !== undefined &&
bundledVersion === release.version &&
bundledVersion === source.release.version &&
isBundledOnDisk(pluginId);
// Fetched before anything stops, so a bad download changes nothing.
const staged = useBundled ? null : await downloadRelease(pluginId, release);
const staged = useBundled
? null
: "release" in source
? await downloadRelease(pluginId, source.release)
: { file: source.upload, signatureFile: `${source.upload}.sig` };
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
const backupDir = path.join(getDownloadsDir(), `${pluginId}.previous`);
@@ -561,6 +584,32 @@ export interface InstallOptions {
version?: string;
userId: string | null;
acceptCapabilities?: boolean;
/**
* The capabilities the consent prompt showed: every one for an install,
* the added ones for an update. Must match what the release asks for.
*/
capabilities?: string[];
}
function assertConsent(
consented: string[] | undefined,
asked: readonly string[],
): void {
if (consented === undefined) {
throw new PluginManageError(
"The capabilities agreed to were not sent",
400,
"CONSENT_REQUIRED",
);
}
if (!sameCapabilities(consented, asked)) {
throw new PluginManageError(
"The capabilities agreed to do not match what this release asks for",
409,
"CONSENT_MISMATCH",
{ capabilities: [...asked] },
);
}
}
async function requireRelease(
@@ -641,12 +690,13 @@ export function installPlugin(
}
const { release, latest } = await requireRelease(pluginId, options.version);
assertConsent(options.capabilities, release.capabilities);
const pin = pinFor(release, latest, options.version);
await applyPin(pluginId, pin);
let plugin: LoadedPlugin;
try {
plugin = await swapIn(pluginId, release);
plugin = await swapIn(pluginId, { release });
} catch (error) {
await applyPin(pluginId, null);
throw new PluginManageError(
@@ -716,13 +766,16 @@ async function updateUnlocked(
live.manifest.capabilities,
release.capabilities,
);
if (added.length > 0 && !options.acceptCapabilities) {
throw new PluginManageError(
"This version asks for new capabilities",
409,
"CAPABILITIES_ADDED",
{ capabilities: added },
);
if (added.length > 0) {
if (!options.acceptCapabilities) {
throw new PluginManageError(
"This version asks for new capabilities",
409,
"CAPABILITIES_ADDED",
{ capabilities: added },
);
}
assertConsent(options.capabilities, added);
}
const wasEnabled = record.state !== "disabled";
@@ -731,7 +784,7 @@ async function updateUnlocked(
let plugin: LoadedPlugin;
try {
plugin = await swapIn(pluginId, release);
plugin = await swapIn(pluginId, { release });
} catch (error) {
await applyPin(pluginId, record.pinnedVersion ?? null);
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
@@ -1003,3 +1056,241 @@ export function deletePluginData(
return removed;
});
}
export async function getDeveloperMode(): Promise<boolean> {
const { createCurrentSettingsRepository } = await repos();
return (
(await createCurrentSettingsRepository().get(DEVELOPER_MODE_KEY)) === "true"
);
}
export async function setDeveloperMode(enabled: boolean): Promise<boolean> {
const { createCurrentSettingsRepository } = await repos();
await createCurrentSettingsRepository().set(
DEVELOPER_MODE_KEY,
enabled ? "true" : "false",
);
if (!enabled) await clearUploads();
return enabled;
}
/** 50 MB, the most nginx lets through to /plugins. */
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
const UPLOAD_TTL_MS = 30 * 60_000;
interface StagedUpload {
file: string;
manifest: PluginManifest;
expiresAt: number;
}
const uploads = new Map<string, StagedUpload>();
async function clearUploads(now = Infinity): Promise<void> {
for (const [token, upload] of uploads) {
if (upload.expiresAt > now) continue;
uploads.delete(token);
await fs.promises.rm(upload.file, { force: true });
}
}
export interface UploadPreview {
token: string;
id: string;
name: string;
version: string;
description: string;
author: string;
capabilities: string[];
/** The version of an earlier upload this one replaces. */
replaces: string | null;
}
async function assertUploadsAllowed(): Promise<void> {
if (!(await getDeveloperMode())) {
throw new PluginManageError(
"Installing from a file needs developer mode",
403,
"DEVELOPER_MODE_OFF",
);
}
if (requireSignedPlugins()) {
throw new PluginManageError(
"TERMIX_REQUIRE_SIGNED_PLUGINS is on, so unsigned files cannot be installed",
403,
"SIGNED_ONLY",
);
}
}
function validateUpload(buffer: Buffer): PluginManifest {
let raw: Record<string, unknown>;
try {
raw = readArtifactManifest(buffer);
} catch (error) {
throw new PluginManageError(
error instanceof Error ? error.message : String(error),
400,
"INVALID_ARTIFACT",
);
}
const { manifest, errors } = parseManifest(raw);
if (!manifest) {
throw new PluginManageError(
`The manifest is not valid: ${errors.join("; ")}`,
400,
"INVALID_MANIFEST",
);
}
if (!isApiCompatible(String(manifest.engine.api))) {
throw new PluginManageError(
`${manifest.id} ${manifest.version} does not run on this version of Termix`,
409,
"INCOMPATIBLE",
);
}
return manifest;
}
/** Only another upload of the same id can be replaced from a file. */
async function assertReplaceable(pluginId: string): Promise<string | null> {
const { loader } = getPluginRuntime();
if (loader.bundledIds().has(pluginId)) {
throw new PluginManageError(
`${pluginId} ships with Termix and can only be replaced by a signed release`,
409,
"BUNDLED_ID",
);
}
const { createCurrentPluginRepository } = await repos();
const record = await createCurrentPluginRepository().findById(pluginId);
if (!record) return null;
if (record.tier !== UNVERIFIED_TIER) {
throw new PluginManageError(
"A plugin with this id is already installed from another source",
409,
"ALREADY_INSTALLED",
);
}
return record.version;
}
/**
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
* it asks for. Nothing is loaded yet.
*/
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
await assertUploadsAllowed();
await clearUploads(Date.now());
const manifest = validateUpload(buffer);
const replaces = await assertReplaceable(manifest.id);
const token = crypto.randomBytes(16).toString("hex");
const dir = getDownloadsDir();
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `upload-${token}.tmxplug`);
await fs.promises.writeFile(file, buffer);
uploads.set(token, {
file,
manifest,
expiresAt: Date.now() + UPLOAD_TTL_MS,
});
return {
token,
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description ?? "",
author: manifest.author?.name ?? "",
capabilities: [...manifest.capabilities],
replaces,
};
}
/** Installs a staged upload. It stays marked unverified for good. */
export function installUpload(
token: string,
options: { userId: string | null; capabilities?: string[] },
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
await assertUploadsAllowed();
const upload = uploads.get(token);
if (!upload || upload.expiresAt < Date.now()) {
throw new PluginManageError(
"The uploaded file has expired, upload it again",
404,
"UPLOAD_EXPIRED",
);
}
const pluginId = upload.manifest.id;
// Read again: the staged file is what gets loaded, not the first look.
const manifest = validateUpload(await fs.promises.readFile(upload.file));
if (manifest.id !== pluginId) {
throw new PluginManageError(
"The staged file changed",
409,
"UPLOAD_CHANGED",
);
}
assertConsent(options.capabilities, manifest.capabilities);
await assertReplaceable(pluginId);
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const before = await repository.findById(pluginId);
const wasEnabled = !before || before.state !== "disabled";
uploads.delete(token);
let plugin: LoadedPlugin;
try {
plugin = await swapIn(pluginId, { upload: upload.file });
} catch (error) {
await fs.promises.rm(upload.file, { force: true });
const { loader } = getPluginRuntime();
if (before && wasEnabled && loader.get(pluginId)) {
await startAndRecord(pluginId);
}
throw new PluginManageError(
error instanceof Error ? error.message : String(error),
500,
"INSTALL_FAILED",
);
}
await repository.update(pluginId, {
tier: UNVERIFIED_TIER,
registryId: null,
pinnedVersion: null,
autoUpdate: false,
});
await grantDeclared(plugin, options.userId);
if (!wasEnabled) {
return { id: pluginId, version: manifest.version, state: "stopped" };
}
const { missing } = dependencyChain(pluginId);
if (missing.length > 0) {
await recordState(
pluginId,
"blocked",
`requires ${missing.map((id) => `"${id}"`).join(", ")}, which is not installed`,
);
return { id: pluginId, version: manifest.version, state: "blocked" };
}
for (const id of (await planStateChange(pluginId, true)).enable) {
await startAndRecord(id);
}
const state = await startAndRecord(pluginId);
pluginLogger.warn(
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
{ operation: "plugin_install" },
);
return { id: pluginId, version: manifest.version, state };
});
}
/** Test seam. */
export function resetUploads(): void {
uploads.clear();
}
+80
View File
@@ -16,6 +16,7 @@ import { SUPPORTED_PLUGIN_API_VERSION } 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 { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
import { getPluginsDir } from "./paths.js";
export const OFFICIAL_REGISTRY_ID = "official";
@@ -245,6 +246,55 @@ export async function fetchRegistryIndex(
return inflight;
}
export interface RegistryStats {
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
}
/** 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) {
return process.env.TERMIX_PLUGIN_STATS_URL;
}
const url = getRegistryUrl();
return /\/index\.json$/.test(url)
? url.replace(/\/index\.json$/, "/stats.json")
: null;
}
function asCount(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? Math.floor(value)
: null;
}
export function parseRegistryStats(raw: unknown): RegistryStats {
const plugins = new Map<
string,
{ downloads: number; activeInstalls: number | null }
>();
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),
});
}
return { plugins };
}
export async function fetchRegistryStats(): Promise<RegistryStats | null> {
const url = getStatsUrl();
if (!url) return null;
const response = await fetchFollowing(url);
const body = await readLimited(response, MAX_INDEX_BYTES);
return parseRegistryStats(JSON.parse(body.toString("utf8")));
}
/** Test seam. */
export function resetRegistryCache(): void {
cache = null;
@@ -266,6 +316,35 @@ export function findRelease(
return { plugin, release };
}
/**
* The signed file is the truth and the index only describes it, so the
* consent the admin gave (built from the index) must match what the packed
* manifest asks for.
*/
export function assertArtifactMatches(
pluginId: string,
release: RegistryVersion,
manifest: Record<string, unknown>,
): void {
const where = `${pluginId} ${release.version}`;
if (manifest.id !== pluginId) {
throw new Error(`${where}: the archive is for "${String(manifest.id)}"`);
}
if (manifest.version !== release.version) {
throw new Error(
`${where}: the archive is version ${String(manifest.version)}`,
);
}
const declared = Array.isArray(manifest.capabilities)
? manifest.capabilities.filter((c): c is string => typeof c === "string")
: [];
if (!sameCapabilities(declared, release.capabilities)) {
throw new Error(
`${where}: the archive asks for different capabilities than the registry lists`,
);
}
}
/**
* Downloads a release, verifies it, and leaves it in a staging folder the
* loader never scans. The caller moves it into place.
@@ -296,6 +375,7 @@ export async function downloadRelease(
if (verified.ok === false) {
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
}
assertArtifactMatches(pluginId, release, readArtifactManifest(buffer));
const staging = getDownloadsDir();
await fs.promises.mkdir(staging, { recursive: true });
@@ -173,6 +173,10 @@ const manageMock = vi.hoisted(() => ({
deletePluginData: vi.fn(),
planStateChange: vi.fn(),
setPluginState: vi.fn(),
getDeveloperMode: vi.fn(),
setDeveloperMode: vi.fn(),
stageUpload: vi.fn(),
installUpload: vi.fn(),
isLinkedDesktop: vi.fn(async () => false),
}));
const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) }));
@@ -630,11 +634,15 @@ describe("plugins route", () => {
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/install", { version: "1.0.0" });
const res = await post("/plugins/docker/install", {
version: "1.0.0",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
version: "1.0.0",
userId: "admin-1",
capabilities: ["kv:own"],
});
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
@@ -649,6 +657,13 @@ describe("plugins route", () => {
expect(
(await post("/plugins/docker/install", { version: 7 })).status,
).toBe(400);
expect(
(await post("/plugins/docker/install", { capabilities: "kv:own" }))
.status,
).toBe(400);
expect(
(await post("/plugins/docker/install", { capabilities: [1] })).status,
).toBe(400);
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
@@ -668,6 +683,25 @@ describe("plugins route", () => {
version: undefined,
userId: "admin-1",
acceptCapabilities: false,
capabilities: undefined,
});
});
it("passes the consented list on an accepted update", async () => {
manageMock.updatePlugin.mockResolvedValue({
id: "docker",
version: "1.1.0",
state: "active",
});
await post("/plugins/docker/update", {
acceptCapabilities: true,
capabilities: ["notify:send"],
});
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
version: undefined,
userId: "admin-1",
acceptCapabilities: true,
capabilities: ["notify:send"],
});
});
@@ -734,6 +768,21 @@ describe("plugins route", () => {
body: JSON.stringify({ autoUpdate: true }),
}),
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
fetch(`${baseUrl}/plugins/developer-mode`, { headers }),
fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers,
body: JSON.stringify({ enabled: true }),
}),
fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"x-test-user-id": user,
},
body: Buffer.from("x"),
}),
post(`/plugins/upload/${"a".repeat(32)}/install`, {}, user),
];
for (const res of await Promise.all(calls)) {
expect(res.status).toBe(403);
@@ -745,4 +794,106 @@ describe("plugins route", () => {
}
});
});
describe("developer mode and file installs", () => {
beforeEach(() => {
for (const fn of Object.values(manageMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("reads and saves developer mode, auditing the change", async () => {
manageMock.getDeveloperMode.mockResolvedValue(true);
const read = await fetch(`${baseUrl}/plugins/developer-mode`);
expect(await read.json()).toEqual({ enabled: true, signedOnly: false });
const bad = await fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: "yes" }),
});
expect(bad.status).toBe(400);
const saved = await fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: false }),
});
expect(saved.status).toBe(200);
expect(manageMock.setDeveloperMode).toHaveBeenCalledWith(false);
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: "plugin_developer_mode" }),
);
});
it("hands the raw upload to the stager", async () => {
manageMock.stageUpload.mockResolvedValue({ token: "t", id: "notes" });
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: Buffer.from("archive"),
});
expect(res.status).toBe(200);
const [buffer] = manageMock.stageUpload.mock.calls[0];
expect(Buffer.from(buffer).toString()).toBe("archive");
});
it("refuses an upload that is not octet-stream", async () => {
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
expect(manageMock.stageUpload).not.toHaveBeenCalled();
});
it("passes the developer mode refusal through", async () => {
manageMock.stageUpload.mockRejectedValue(
new PluginManageError("off", 403, "DEVELOPER_MODE_OFF"),
);
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: Buffer.from("archive"),
});
expect(res.status).toBe(403);
expect((await res.json()).code).toBe("DEVELOPER_MODE_OFF");
});
it("installs a staged upload and audits it as unverified", async () => {
manageMock.installUpload.mockResolvedValue({
id: "notes",
version: "0.1.0",
state: "active",
});
const token = "b".repeat(32);
const res = await fetch(`${baseUrl}/plugins/upload/${token}/install`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: ["kv:own"] }),
});
expect(res.status).toBe(200);
expect(manageMock.installUpload).toHaveBeenCalledWith(token, {
userId: "admin-1",
capabilities: ["kv:own"],
});
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "install_plugin_from_file",
resourceId: "notes",
}),
);
});
it("rejects a malformed upload token", async () => {
const res = await fetch(`${baseUrl}/plugins/upload/nope/install`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: [] }),
});
expect(res.status).toBe(400);
expect(manageMock.installUpload).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import {
readArtifactFile,
readArtifactManifest,
sameCapabilities,
} from "../../plugins/artifact-manifest.js";
function pack(files: Record<string, string>, prefix = ""): Buffer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-artifact-"));
try {
for (const [name, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
fs.writeFileSync(path.join(dir, name), content);
}
const file = path.join(dir, "out.tmxplug");
tar.c(
{ gzip: true, sync: true, file, cwd: dir },
Object.keys(files).map((name) => `${prefix}${name}`),
);
return fs.readFileSync(file);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe("readArtifactFile", () => {
it("finds a file by name, with or without a ./ prefix", () => {
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x");
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe(
'{"id":"a"}',
);
});
it("returns null for a missing file", () => {
expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe(
null,
);
});
});
describe("readArtifactManifest", () => {
it("parses the manifest", () => {
expect(
readArtifactManifest(pack({ "manifest.json": '{"id":"notes"}' })),
).toEqual({ id: "notes" });
});
it("refuses a file that is not an archive", () => {
expect(() => readArtifactManifest(Buffer.from("nope"))).toThrow(
/not a valid .tmxplug/,
);
});
it("refuses an archive with no manifest or a broken one", () => {
expect(() => readArtifactManifest(pack({ "a.txt": "a" }))).toThrow(
/no manifest.json/,
);
expect(() =>
readArtifactManifest(pack({ "manifest.json": "{oops" })),
).toThrow(/not valid JSON/);
expect(() =>
readArtifactManifest(pack({ "manifest.json": "[1]" })),
).toThrow(/not an object/);
});
});
describe("sameCapabilities", () => {
it("compares as sets", () => {
expect(sameCapabilities(["a", "b"], ["b", "a"])).toBe(true);
expect(sameCapabilities(["a"], ["a", "b"])).toBe(false);
expect(sameCapabilities(["a", "c"], ["a", "b"])).toBe(false);
expect(sameCapabilities([], [])).toBe(true);
});
});
@@ -0,0 +1,103 @@
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,
});
});
});
+215 -4
View File
@@ -155,6 +155,8 @@ function tempRoot(prefix: string): string {
return root;
}
const DEFAULT_CAPS = ["hosts:read", "kv:own"];
function registryEntry(
id: string,
versions: Array<{ version: string; capabilities?: string[] }>,
@@ -248,7 +250,10 @@ describe("uninstall and reinstall of a bundled plugin", () => {
expect(loader.get("docker")).toBeUndefined();
registry.download.mockImplementation(() => stageArtifact("docker"));
const result = await manage.installPlugin("docker", { userId: "admin" });
const result = await manage.installPlugin("docker", {
userId: "admin",
capabilities: DEFAULT_CAPS,
});
expect(result.state).toBe("active");
expect(registry.download).toHaveBeenCalledOnce();
expect(loader.get("docker")?.source).toBe("user");
@@ -299,7 +304,10 @@ describe("installing from the registry", () => {
);
const loader = await boot();
const result = await manage.installPlugin("notes", { userId: "admin" });
const result = await manage.installPlugin("notes", {
userId: "admin",
capabilities: DEFAULT_CAPS,
});
expect(result).toMatchObject({ id: "notes", state: "active" });
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
@@ -326,7 +334,11 @@ describe("installing from the registry", () => {
registry.download.mockImplementation(() => stageArtifact("notes"));
await boot();
await manage.installPlugin("notes", { userId: "admin", version: "1.0.0" });
await manage.installPlugin("notes", {
userId: "admin",
version: "1.0.0",
capabilities: DEFAULT_CAPS,
});
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
});
@@ -334,7 +346,10 @@ describe("installing from the registry", () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
registry.download.mockImplementation(() => stageArtifact("notes"));
await boot();
await manage.installPlugin("notes", { userId: "admin" });
await manage.installPlugin("notes", {
userId: "admin",
capabilities: DEFAULT_CAPS,
});
await manage.uninstallPlugin("notes");
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
@@ -429,3 +444,199 @@ describe("dependencies", () => {
});
});
});
describe("consent", () => {
it("refuses an install without the consented list, before any download", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
await boot();
await expect(
manage.installPlugin("notes", { userId: "admin" }),
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
expect(registry.download).not.toHaveBeenCalled();
});
it("refuses when the consent does not match what the release asks for", async () => {
registry.index.plugins = [
registryEntry("notes", [
{ version: "1.0.0", capabilities: ["kv:own", "credentials:read"] },
]),
];
await boot();
await expect(
manage.installPlugin("notes", {
userId: "admin",
capabilities: ["kv:own"],
}),
).rejects.toMatchObject({
code: "CONSENT_MISMATCH",
details: { capabilities: ["kv:own", "credentials:read"] },
});
expect(registry.download).not.toHaveBeenCalled();
expect(db.rows.has("notes")).toBe(false);
});
it("accepts the same list in another order", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
registry.download.mockImplementation(() => stageArtifact("notes"));
await boot();
const result = await manage.installPlugin("notes", {
userId: "admin",
capabilities: ["kv:own", "hosts:read"],
});
expect(result.state).toBe("active");
});
it("needs the added capabilities listed to accept an update", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [
registryEntry("docker", [
{
version: "1.1.0",
capabilities: ["hosts:read", "kv:own", "notify:send"],
},
{ version: "1.0.0" },
]),
];
await boot();
await expect(
manage.updatePlugin("docker", {
userId: "admin",
acceptCapabilities: true,
capabilities: ["ssh:connect"],
}),
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
await expect(
manage.updatePlugin("docker", {
userId: "admin",
acceptCapabilities: true,
}),
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
expect(registry.download).not.toHaveBeenCalled();
});
});
describe("installing from a file", () => {
async function artifactBuffer(
id: string,
manifestOverrides: Record<string, unknown> = {},
) {
const staged = await stageArtifact(id, manifestOverrides);
const buffer = fs.readFileSync(staged.file);
fs.rmSync(staged.file);
return buffer;
}
afterEach(() => {
manage.resetUploads();
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
});
it("is refused while developer mode is off", async () => {
await boot();
await expect(
manage.stageUpload(await artifactBuffer("notes")),
).rejects.toMatchObject({ code: "DEVELOPER_MODE_OFF" });
});
it("is refused when signed plugins are required", async () => {
await boot();
await manage.setDeveloperMode(true);
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
await expect(
manage.stageUpload(await artifactBuffer("notes")),
).rejects.toMatchObject({ code: "SIGNED_ONLY" });
});
it("stages, asks for consent, then installs it marked unverified", async () => {
const loader = await boot();
await manage.setDeveloperMode(true);
const preview = await manage.stageUpload(
await artifactBuffer("notes", { capabilities: ["kv:own"] }),
);
expect(preview).toMatchObject({
id: "notes",
capabilities: ["kv:own"],
replaces: null,
});
expect(loader.get("notes")).toBeUndefined();
await expect(
manage.installUpload(preview.token, {
userId: "admin",
capabilities: ["kv:own", "hosts:read"],
}),
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
const result = await manage.installUpload(preview.token, {
userId: "admin",
capabilities: ["kv:own"],
});
expect(result.state).toBe("active");
expect(db.rows.get("notes")).toMatchObject({
tier: manage.UNVERIFIED_TIER,
registryId: null,
});
expect(loader.get("notes")?.signedBy).toBeUndefined();
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
true,
);
// A token is used once.
await expect(
manage.installUpload(preview.token, {
userId: "admin",
capabilities: ["kv:own"],
}),
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
});
it("replaces an earlier upload but never a bundled plugin", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
await manage.setDeveloperMode(true);
await expect(
manage.stageUpload(await artifactBuffer("docker")),
).rejects.toMatchObject({ code: "BUNDLED_ID" });
const first = await manage.stageUpload(await artifactBuffer("notes"));
await manage.installUpload(first.token, {
userId: "admin",
capabilities: first.capabilities,
});
const second = await manage.stageUpload(
await artifactBuffer("notes", { version: "1.1.0" }),
);
expect(second.replaces).toBe("1.0.0");
await manage.installUpload(second.token, {
userId: "admin",
capabilities: second.capabilities,
});
expect(loader.get("notes")?.manifest.version).toBe("1.1.0");
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
});
it("refuses a file that is not a plugin archive", async () => {
await boot();
await manage.setDeveloperMode(true);
await expect(
manage.stageUpload(Buffer.from("not a tarball")),
).rejects.toMatchObject({ code: "INVALID_ARTIFACT" });
});
it("drops staged files when developer mode is turned off", async () => {
await boot();
await manage.setDeveloperMode(true);
const preview = await manage.stageUpload(await artifactBuffer("notes"));
await manage.setDeveloperMode(false);
await manage.setDeveloperMode(true);
await expect(
manage.installUpload(preview.token, {
userId: "admin",
capabilities: preview.capabilities,
}),
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
});
});
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
const fetchMock = vi.hoisted(() => vi.fn());
const testKey = vi.hoisted(() => ({ value: null as null | object }));
@@ -26,9 +27,12 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
import {
downloadRelease,
fetchRegistryIndex,
fetchRegistryStats,
findRelease,
getStatsUrl,
isApiCompatible,
parseRegistryIndex,
parseRegistryStats,
resetRegistryCache,
type RegistryVersion,
} from "../../plugins/registry-index.js";
@@ -186,8 +190,25 @@ describe("fetchRegistryIndex", () => {
});
});
/** A real .tmxplug holding only a manifest, enough for the checks. */
function pack(manifest: Record<string, unknown>): Buffer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
try {
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest));
const file = path.join(dir, "out.tmxplug");
tar.c({ gzip: true, sync: true, file, cwd: dir }, ["manifest.json"]);
return fs.readFileSync(file);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe("downloadRelease", () => {
const artifact = Buffer.from("plugin archive bytes");
const artifact = pack({
id: "docker",
version: "1.0.0",
capabilities: ["kv:own"],
});
it("writes a verified artifact and its signature to staging", async () => {
const signed = sign(artifact);
@@ -206,6 +227,47 @@ describe("downloadRelease", () => {
).toBe(true);
});
it("refuses a file signed by a key this build does not trust", async () => {
const other = crypto.generateKeyPairSync("ed25519").privateKey;
const digest = crypto.createHash("sha256").update(artifact).digest();
fetchMock.mockResolvedValueOnce(response(artifact));
await expect(
downloadRelease(
"docker",
version({
sha256: digest.toString("hex"),
signature: crypto.sign(null, digest, other).toString("base64"),
size: artifact.length,
}),
),
).rejects.toThrow(/trusted key/);
});
it.each([
["another plugin id", { id: "evil" }, /archive is for/],
["another version", { version: "9.9.9" }, /archive is version/],
[
"more capabilities than the index lists",
{ capabilities: ["kv:own", "credentials:read"] },
/different capabilities/,
],
])("refuses a signed file with %s", async (_label, override, error) => {
const tampered = pack({
id: "docker",
version: "1.0.0",
capabilities: ["kv:own"],
...override,
});
const signed = sign(tampered);
fetchMock.mockResolvedValueOnce(response(tampered));
await expect(
downloadRelease("docker", version({ ...signed, size: tampered.length })),
).rejects.toThrow(error);
expect(fs.existsSync(path.join(dataDir, "plugins", ".downloads"))).toBe(
false,
);
});
it("refuses a file that does not match its signature", async () => {
const signed = sign(artifact);
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
@@ -229,3 +291,38 @@ describe("downloadRelease", () => {
).rejects.toThrow(/larger than allowed/);
});
});
describe("registry stats", () => {
it("sits next to the index", () => {
expect(getStatsUrl()).toBe(
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/stats.json",
);
});
it("keeps valid counts and drops the rest", () => {
const stats = parseRegistryStats({
plugins: {
docker: { downloads: 120, activeInstalls: 40 },
tunnels: { downloads: 7 },
"../bad": { downloads: 1 },
broken: { downloads: -3, activeInstalls: "many" },
},
});
expect(Object.fromEntries(stats.plugins)).toEqual({
docker: { downloads: 120, activeInstalls: 40 },
tunnels: { downloads: 7, activeInstalls: null },
broken: { downloads: 0, activeInstalls: null },
});
});
it("fetches and parses stats.json", async () => {
fetchMock.mockResolvedValueOnce(
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
);
const stats = await fetchRegistryStats();
expect(stats?.plugins.get("docker")).toEqual({
downloads: 3,
activeInstalls: null,
});
});
});