mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user