mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -14,10 +14,40 @@ import semver from "semver";
|
||||
import { isKnownCapability } from "./capabilities.js";
|
||||
|
||||
/**
|
||||
* The SDK major version this build implements. engine.api is the real
|
||||
* compatibility gate; engine.termix is a display string and is never enforced.
|
||||
* The plugin API this build implements. A minor bump adds to the API and
|
||||
* never removes; a major bump may break plugins.
|
||||
*
|
||||
* engine.api is a semver range checked against this ("1" means any 1.x,
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = "1";
|
||||
export const PLUGIN_API_VERSION = "1.0.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
semver.major(PLUGIN_API_VERSION),
|
||||
);
|
||||
|
||||
/** Whether this build's plugin API satisfies a manifest's engine.api. */
|
||||
export function isApiCompatible(range: string): boolean {
|
||||
return (
|
||||
semver.validRange(range) !== null &&
|
||||
semver.satisfies(PLUGIN_API_VERSION, range)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a core version satisfies a manifest's engine.termix. A prerelease
|
||||
* core (2.9.0-beta.1) counts as that release.
|
||||
*/
|
||||
export function isTermixCompatible(
|
||||
range: string,
|
||||
coreVersion: string | null,
|
||||
): boolean {
|
||||
if (!coreVersion || !semver.valid(semver.coerce(coreVersion))) return true;
|
||||
if (semver.validRange(range) === null) return false;
|
||||
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
|
||||
}
|
||||
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"Terminal",
|
||||
@@ -59,14 +89,12 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
|
||||
/** A preset key is a plain property name, never a prototype one. */
|
||||
const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||
/** Service names are dotted, e.g. "ssh.transport". */
|
||||
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
|
||||
/** Action ids name a frontend function, so segments may be camelCase. */
|
||||
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
|
||||
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
|
||||
/**
|
||||
* A plugin-relative permission name. Unlike a full id one segment is fine,
|
||||
* and underscores are allowed in the first segment too ("manage_providers").
|
||||
@@ -98,9 +126,12 @@ export interface PluginAuthor {
|
||||
}
|
||||
|
||||
export interface PluginEngine {
|
||||
/** Display string only, e.g. ">=2.9.0". Never enforced. */
|
||||
/** Semver range of Termix releases the plugin runs on, e.g. ">=2.9.0". */
|
||||
termix: string;
|
||||
/** SDK major version as a string. The real gate. */
|
||||
/**
|
||||
* Semver range of the plugin API it needs, e.g. "1" or "^1.2". Use the
|
||||
* lowest minor whose API you call, so older cores refuse it cleanly.
|
||||
*/
|
||||
api: string;
|
||||
}
|
||||
|
||||
@@ -257,6 +288,11 @@ export interface PluginSettingsField {
|
||||
shareRead?: "connect" | "view" | "edit" | "manage";
|
||||
/** Host scope only: only the host's owner may change it. */
|
||||
ownerOnly?: boolean;
|
||||
/**
|
||||
* JSON fields only: keys inside the stored object that hold secrets, such
|
||||
* as a gateway password. Core clears them from exports like any secret.
|
||||
*/
|
||||
secretKeys?: string[];
|
||||
/**
|
||||
* Stored and validated like any field, but not drawn by the generic
|
||||
* settings form, because the plugin edits it in its own UI (a host editor
|
||||
@@ -344,6 +380,13 @@ export interface PluginContributions {
|
||||
* off and a desktop can list them in its sync settings.
|
||||
*/
|
||||
syncEntities?: string[];
|
||||
/**
|
||||
* Other plugins' action, slot and extension point ids this plugin calls,
|
||||
* fills or extends. Declaration only: nothing breaks when the owner is
|
||||
* missing, but the coupling is written down where tools and people can
|
||||
* see it.
|
||||
*/
|
||||
uses?: string[];
|
||||
/**
|
||||
* Keybinding actions this plugin adds to Appearance > Keybindings, with
|
||||
* the parameters a saved binding carries. Core validates saved bindings
|
||||
@@ -562,6 +605,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"uiPresets",
|
||||
"auth",
|
||||
"syncEntities",
|
||||
"uses",
|
||||
"keybindingActions",
|
||||
"protocols",
|
||||
]);
|
||||
@@ -584,6 +628,7 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"defaultFrom",
|
||||
"shareRead",
|
||||
"ownerOnly",
|
||||
"secretKeys",
|
||||
];
|
||||
|
||||
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||
@@ -738,9 +783,20 @@ function validateEngine(engine: unknown, errors: string[]): void {
|
||||
}
|
||||
rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
|
||||
requireString(engine.termix, "engine.termix", errors);
|
||||
if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
|
||||
if (
|
||||
typeof engine.termix === "string" &&
|
||||
semver.validRange(engine.termix) === null
|
||||
) {
|
||||
errors.push(
|
||||
`Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`,
|
||||
`Field "engine.termix" must be a semver range, got: ${JSON.stringify(engine.termix)}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof engine.api !== "string" ||
|
||||
semver.validRange(engine.api) === null
|
||||
) {
|
||||
errors.push(
|
||||
`Field "engine.api" must be a semver range such as "1" or "^1.2", got: ${JSON.stringify(engine.api)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -895,6 +951,28 @@ function validateSyncEntities(value: unknown, errors: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateUses(value: unknown, errors: string[]) {
|
||||
const where = "contributes.uses";
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(`${where} must be a non-empty array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const id of value) {
|
||||
if (
|
||||
typeof id !== "string" ||
|
||||
!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)
|
||||
) {
|
||||
errors.push(
|
||||
`${where} entries must be dotted ids such as "terminal.open"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(id)) errors.push(`${where} duplicates "${id}"`);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNameList(value: unknown, where: string, errors: string[]) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(`${where} must be a non-empty array`);
|
||||
@@ -1025,6 +1103,9 @@ function validateContributes(
|
||||
if (contributes.syncEntities !== undefined) {
|
||||
validateSyncEntities(contributes.syncEntities, errors);
|
||||
}
|
||||
if (contributes.uses !== undefined) {
|
||||
validateUses(contributes.uses, errors);
|
||||
}
|
||||
validatePermissions(contributes.permissions, pluginId, errors);
|
||||
validateActions(contributes.actions, errors);
|
||||
validateActionSlots(contributes.actionSlots, errors);
|
||||
@@ -1534,6 +1615,18 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
|
||||
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
|
||||
}
|
||||
if ("secretKeys" in raw) {
|
||||
const keys = raw.secretKeys;
|
||||
if (raw.type !== "json") {
|
||||
errors.push(`${fieldAt}.secretKeys is only valid on json fields`);
|
||||
} else if (
|
||||
!Array.isArray(keys) ||
|
||||
keys.length === 0 ||
|
||||
keys.some((key) => typeof key !== "string" || key.length === 0)
|
||||
) {
|
||||
errors.push(`${fieldAt}.secretKeys must be a non-empty string array`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const scope of ["admin", "user"] as const) {
|
||||
@@ -1957,10 +2050,10 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
|
||||
const manifest = raw as PluginManifest;
|
||||
|
||||
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
|
||||
if (!isApiCompatible(manifest.engine.api)) {
|
||||
return {
|
||||
errors: [
|
||||
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
|
||||
`Plugin needs plugin API ${manifest.engine.api}, but this build implements ${PLUGIN_API_VERSION}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user