mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: add a permission-gated backend service registry for plugins
This commit is contained in:
1 parent
ee2d6b4697
commit
225f618337
17 files changed
+1755
-2
No files matched your search
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"version": "1.0.0",
|
||||
"description": "Gates a service behind a permission its own group never declares.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": false,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": ["hosts.read"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sampleprovider",
|
||||
"permissions": ["sampleprovider.greet.use"]
|
||||
}
|
||||
},
|
||||
"provides": [
|
||||
{
|
||||
"service": "sampleprovider.greet",
|
||||
"version": "1.2.0",
|
||||
"permission": "sampleprovider.undeclared"
|
||||
}
|
||||
],
|
||||
"sidecars": []
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"version": "1.0.0",
|
||||
"description": "A plugin that both provides and requires a named service.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": false,
|
||||
"electron": false,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["hosts.read"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sampleprovider",
|
||||
"permissions": ["sampleprovider.greet.use"]
|
||||
}
|
||||
},
|
||||
"provides": [
|
||||
{
|
||||
"service": "sampleprovider.greet",
|
||||
"version": "1.2.0",
|
||||
"permission": "sampleprovider.greet.use"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
{
|
||||
"service": "other.thing",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"sidecars": []
|
||||
}
|
||||
@@ -224,6 +224,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"provides": {
|
||||
"type": "array",
|
||||
"description": "Named services this plugin exposes to other plugins.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["service", "version", "permission"],
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$",
|
||||
"description": "Dotted service name, e.g. \"ssh.transport\"."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
|
||||
"description": "Semver version of this service contract."
|
||||
},
|
||||
"permission": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Role permission a calling user needs. Must also appear in contributes.permissionGroup.permissions."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires": {
|
||||
"type": "array",
|
||||
"description": "Named services this plugin needs another active plugin to provide.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["service", "versionRange"],
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
|
||||
},
|
||||
"versionRange": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Semver range, e.g. \"^1.2.0\"."
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"description": "When true, an unsatisfied requirement is skipped instead of failing activation."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidecars": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const semver = require("semver");
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9-]+$/;
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
||||
|
||||
function loadSchema() {
|
||||
@@ -123,10 +125,116 @@ function validateManifest(manifest, schema) {
|
||||
errors.push('Field "sidecars" must be an array');
|
||||
}
|
||||
|
||||
errors.push(...validateProvides(manifest.provides));
|
||||
errors.push(...validateRequires(manifest.requires));
|
||||
|
||||
if (manifest.contributes && typeof manifest.contributes === "object") {
|
||||
errors.push(...validateContributes(manifest.contributes));
|
||||
}
|
||||
|
||||
// A service permission has to be one the plugin's own permissionGroup
|
||||
// declares, or no admin could ever grant it.
|
||||
const declared = manifest.contributes?.permissionGroup?.permissions;
|
||||
if (Array.isArray(manifest.provides) && Array.isArray(declared)) {
|
||||
for (const entry of manifest.provides) {
|
||||
if (entry && entry.permission && !declared.includes(entry.permission)) {
|
||||
errors.push(
|
||||
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateProvides(provides) {
|
||||
if (provides === undefined) return [];
|
||||
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
|
||||
|
||||
const errors = [];
|
||||
const seen = new Set();
|
||||
|
||||
provides.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`provides[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof entry.service !== "string" ||
|
||||
!SERVICE_PATTERN.test(entry.service)
|
||||
) {
|
||||
errors.push(
|
||||
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
|
||||
);
|
||||
} else if (seen.has(entry.service)) {
|
||||
errors.push(
|
||||
`provides[${index}].service is a duplicate: "${entry.service}"`,
|
||||
);
|
||||
} else {
|
||||
seen.add(entry.service);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof entry.version !== "string" ||
|
||||
!SEMVER_PATTERN.test(entry.version)
|
||||
) {
|
||||
errors.push(
|
||||
`provides[${index}].version must be valid semver, got: "${entry.version}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
|
||||
errors.push(`provides[${index}].permission is required`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRequires(requires) {
|
||||
if (requires === undefined) return [];
|
||||
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
|
||||
|
||||
const errors = [];
|
||||
const seen = new Set();
|
||||
|
||||
requires.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`requires[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof entry.service !== "string" ||
|
||||
!SERVICE_PATTERN.test(entry.service)
|
||||
) {
|
||||
errors.push(
|
||||
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
|
||||
);
|
||||
} else if (seen.has(entry.service)) {
|
||||
errors.push(
|
||||
`requires[${index}].service is a duplicate: "${entry.service}"`,
|
||||
);
|
||||
} else {
|
||||
seen.add(entry.service);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof entry.versionRange !== "string" ||
|
||||
semver.validRange(entry.versionRange) === null
|
||||
) {
|
||||
errors.push(
|
||||
`requires[${index}].versionRange must be a valid semver range, got: "${entry.versionRange}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if ("optional" in entry && typeof entry.optional !== "boolean") {
|
||||
errors.push(`requires[${index}].optional must be a boolean`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,18 @@ describe("validate-plugin-manifest.cjs", () => {
|
||||
expect(output).toMatch(/Field "id" must match/);
|
||||
});
|
||||
|
||||
it("accepts a manifest declaring provides and requires", () => {
|
||||
const { status, output } = runValidator("valid-services.json");
|
||||
expect(status).toBe(0);
|
||||
expect(output).toContain("Valid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects a service gated by an undeclared permission", () => {
|
||||
const { status, output } = runValidator("invalid-service-permission.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toContain("sampleprovider.undeclared");
|
||||
});
|
||||
|
||||
it("rejects a manifest with an unknown permission", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-permission.json");
|
||||
expect(status).not.toBe(0);
|
||||
|
||||
Reference in new issue
Block a user