Accept config schema for uploaded plugin (#7414)

* fix(plugin-config): Adds two-way binding of plugin config dialog

* feature(plugin-config): Adds config-schema support for uploaded plugins
This commit is contained in:
Florian Bachmann 2026-05-01 13:07:50 +02:00 committed by GitHub
parent 6c1a0b54f9
commit 79c6d903a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 170 additions and 5 deletions

View file

@ -8,6 +8,7 @@ export interface CachedPlugin {
indexHtml?: string;
icon?: string;
translations?: Record<string, string>; // language code -> JSON string
configSchema?: string;
uploadDate: number;
}
@ -57,6 +58,7 @@ export class PluginCacheService {
indexHtml?: string,
icon?: string,
translations?: Record<string, string>,
configSchema?: string,
): Promise<void> {
// Log plugin data sizes for debugging
const translationsSize = translations
@ -105,6 +107,7 @@ export class PluginCacheService {
indexHtml,
icon,
translations,
configSchema,
uploadDate: Date.now(),
};

View file

@ -0,0 +1,139 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { PluginConfigService } from './plugin-config.service';
import { PluginCacheService, CachedPlugin } from './plugin-cache.service';
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
import { PluginManifest } from './plugin-api.model';
const BASE_MANIFEST: PluginManifest = {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
manifestVersion: 1,
minSupVersion: '1.0.0',
hooks: [],
permissions: [],
jsonSchemaCfg: 'config-schema.json',
};
const VALID_SCHEMA = { type: 'object', properties: { apiKey: { type: 'string' } } };
const makeCache = (overrides: Partial<CachedPlugin> = {}): CachedPlugin => ({
id: 'test-plugin',
manifest: JSON.stringify(BASE_MANIFEST),
code: 'console.log("test");',
uploadDate: Date.now(),
...overrides,
});
describe('PluginConfigService', () => {
let service: PluginConfigService;
let cacheSpy: jasmine.SpyObj<PluginCacheService>;
let httpSpy: jasmine.SpyObj<HttpClient>;
beforeEach(() => {
cacheSpy = jasmine.createSpyObj('PluginCacheService', ['getPlugin']);
httpSpy = jasmine.createSpyObj('HttpClient', ['get']);
TestBed.configureTestingModule({
providers: [
PluginConfigService,
{ provide: PluginCacheService, useValue: cacheSpy },
{ provide: HttpClient, useValue: httpSpy },
{
provide: PluginUserPersistenceService,
useValue: jasmine.createSpyObj('PluginUserPersistenceService', [
'loadPluginUserData',
'persistPluginUserData',
]),
},
],
});
service = TestBed.inject(PluginConfigService);
});
describe('loadPluginConfigSchema - uploaded:// plugins', () => {
it('should return parsed schema from cache for uploaded plugin', async () => {
cacheSpy.getPlugin.and.resolveTo(
makeCache({ configSchema: JSON.stringify(VALID_SCHEMA) }),
);
const result = await service.loadPluginConfigSchema(
BASE_MANIFEST,
'uploaded://test-plugin',
);
expect(cacheSpy.getPlugin).toHaveBeenCalledWith('test-plugin');
expect(result).toEqual(VALID_SCHEMA as any);
});
it('should throw when configSchema is missing from cache', async () => {
cacheSpy.getPlugin.and.resolveTo(makeCache({ configSchema: undefined }));
await expectAsync(
service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'),
).toBeRejectedWithError(/No config schema found for uploaded plugin test-plugin/);
});
it('should throw when plugin is not in cache at all', async () => {
cacheSpy.getPlugin.and.resolveTo(null);
await expectAsync(
service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'),
).toBeRejectedWithError(/No config schema found for uploaded plugin test-plugin/);
});
it('should throw when configSchema contains invalid JSON', async () => {
cacheSpy.getPlugin.and.resolveTo(makeCache({ configSchema: '{ not valid json' }));
await expectAsync(
service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'),
).toBeRejected();
});
});
describe('loadPluginConfigSchema - bundled/remote plugins', () => {
it('should fetch schema via HTTP for non-uploaded plugins', async () => {
httpSpy.get.and.returnValue(of(VALID_SCHEMA) as any);
const result = await service.loadPluginConfigSchema(
BASE_MANIFEST,
'assets/bundled-plugins/test-plugin',
);
expect(httpSpy.get).toHaveBeenCalledWith(
'assets/bundled-plugins/test-plugin/config-schema.json',
);
expect(result).toEqual(VALID_SCHEMA as any);
});
it('should throw when HTTP fetch fails', async () => {
httpSpy.get.and.returnValue(throwError(() => new Error('404 Not Found')) as any);
await expectAsync(
service.loadPluginConfigSchema(
BASE_MANIFEST,
'assets/bundled-plugins/test-plugin',
),
).toBeRejected();
});
});
describe('loadPluginConfigSchema - guard conditions', () => {
it('should throw when manifest has no jsonSchemaCfg', async () => {
const manifest: PluginManifest = { ...BASE_MANIFEST, jsonSchemaCfg: undefined };
await expectAsync(
service.loadPluginConfigSchema(manifest, 'uploaded://test-plugin'),
).toBeRejectedWithError('Plugin does not have a JSON schema configuration');
});
it('should throw when pluginPath is empty', async () => {
await expectAsync(
service.loadPluginConfigSchema(BASE_MANIFEST, ''),
).toBeRejectedWithError(/Plugin path not provided/);
});
});
});

View file

@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { JSONSchema7 } from 'json-schema';
import { PluginManifest } from './plugin-api.model';
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
import { PluginCacheService } from './plugin-cache.service';
import { PluginLog } from '../core/log';
import { first } from 'rxjs/operators';
@ -17,6 +18,7 @@ interface PluginConfigData {
export class PluginConfigService {
private readonly _http = inject(HttpClient);
private readonly _pluginUserPersistenceService = inject(PluginUserPersistenceService);
private readonly _pluginCacheService = inject(PluginCacheService);
/**
* Load the JSON schema configuration file for a plugin
@ -36,11 +38,16 @@ export class PluginConfigService {
// Build the URL to the config schema file
let schemaUrl: string;
if (pluginPath.startsWith('uploaded://')) {
// For uploaded plugins, we need to fetch from the cached files
// This would require extending the cache service, but for now we'll throw an error
throw new Error(
'Loading config schema for uploaded plugins is not yet implemented',
);
const pluginId = pluginPath.replace('uploaded://', '');
const cached = await this._pluginCacheService.getPlugin(pluginId);
if (!cached?.configSchema) {
throw new Error(`No config schema found for uploaded plugin ${manifest.id}`);
}
const schema = JSON.parse(cached.configSchema) as JSONSchema7;
if (!schema || typeof schema !== 'object') {
throw new Error('Invalid JSON schema format');
}
return schema;
} else {
// For regular plugins, construct the URL
schemaUrl = `${pluginPath}/${manifest.jsonSchemaCfg}`;

View file

@ -1072,6 +1072,19 @@ export class PluginService implements OnDestroy {
}
}
// Extract config schema if specified in manifest
let configSchema: string | undefined;
if (manifest.jsonSchemaCfg && extractedFiles[manifest.jsonSchemaCfg]) {
const schemaBytes = extractedFiles[manifest.jsonSchemaCfg];
if (schemaBytes.length <= MAX_PLUGIN_MANIFEST_SIZE) {
configSchema = new TextDecoder().decode(schemaBytes);
} else {
PluginLog.err(
`Plugin config schema ${manifest.jsonSchemaCfg} is too large, skipping`,
);
}
}
// Analyze plugin code (informational only - KISS approach)
const codeAnalysis = this._pluginSecurity.analyzePluginCode(pluginCode, manifest);
if (codeAnalysis.warnings.length > 0) {
@ -1106,6 +1119,8 @@ export class PluginService implements OnDestroy {
pluginCode,
indexHtml || undefined,
iconContent || undefined,
undefined,
configSchema,
);
// Store index.html content if it exists

View file

@ -53,6 +53,7 @@ interface PluginConfigData {
[form]="form"
[fields]="fields"
[model]="model"
(modelChange)="model = $event"
></formly-form>
</form>
}