@objectstack/core 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API_REGISTRY.md +392 -0
- package/CHANGELOG.md +8 -0
- package/README.md +36 -0
- package/dist/api-registry-plugin.d.ts +54 -0
- package/dist/api-registry-plugin.d.ts.map +1 -0
- package/dist/api-registry-plugin.js +53 -0
- package/dist/api-registry-plugin.test.d.ts +2 -0
- package/dist/api-registry-plugin.test.d.ts.map +1 -0
- package/dist/api-registry-plugin.test.js +332 -0
- package/dist/api-registry.d.ts +259 -0
- package/dist/api-registry.d.ts.map +1 -0
- package/dist/api-registry.js +599 -0
- package/dist/api-registry.test.d.ts +2 -0
- package/dist/api-registry.test.d.ts.map +1 -0
- package/dist/api-registry.test.js +957 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/logger.d.ts +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +35 -11
- package/dist/plugin-loader.d.ts +3 -2
- package/dist/plugin-loader.d.ts.map +1 -1
- package/dist/plugin-loader.js +13 -11
- package/dist/qa/adapter.d.ts +14 -0
- package/dist/qa/adapter.d.ts.map +1 -0
- package/dist/qa/adapter.js +1 -0
- package/dist/qa/http-adapter.d.ts +16 -0
- package/dist/qa/http-adapter.d.ts.map +1 -0
- package/dist/qa/http-adapter.js +107 -0
- package/dist/qa/index.d.ts +4 -0
- package/dist/qa/index.d.ts.map +1 -0
- package/dist/qa/index.js +3 -0
- package/dist/qa/runner.d.ts +27 -0
- package/dist/qa/runner.d.ts.map +1 -0
- package/dist/qa/runner.js +157 -0
- package/dist/security/index.d.ts +14 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +13 -0
- package/dist/security/plugin-config-validator.d.ts +79 -0
- package/dist/security/plugin-config-validator.d.ts.map +1 -0
- package/dist/security/plugin-config-validator.js +166 -0
- package/dist/security/plugin-config-validator.test.d.ts +2 -0
- package/dist/security/plugin-config-validator.test.d.ts.map +1 -0
- package/dist/security/plugin-config-validator.test.js +223 -0
- package/dist/security/plugin-permission-enforcer.d.ts +154 -0
- package/dist/security/plugin-permission-enforcer.d.ts.map +1 -0
- package/dist/security/plugin-permission-enforcer.js +323 -0
- package/dist/security/plugin-permission-enforcer.test.d.ts +2 -0
- package/dist/security/plugin-permission-enforcer.test.d.ts.map +1 -0
- package/dist/security/plugin-permission-enforcer.test.js +205 -0
- package/dist/security/plugin-signature-verifier.d.ts +96 -0
- package/dist/security/plugin-signature-verifier.d.ts.map +1 -0
- package/dist/security/plugin-signature-verifier.js +250 -0
- package/examples/api-registry-example.ts +557 -0
- package/package.json +2 -2
- package/src/api-registry-plugin.test.ts +391 -0
- package/src/api-registry-plugin.ts +86 -0
- package/src/api-registry.test.ts +1089 -0
- package/src/api-registry.ts +736 -0
- package/src/index.ts +6 -0
- package/src/logger.ts +36 -11
- package/src/plugin-loader.ts +17 -13
- package/src/qa/adapter.ts +14 -0
- package/src/qa/http-adapter.ts +114 -0
- package/src/qa/index.ts +3 -0
- package/src/qa/runner.ts +179 -0
- package/src/security/index.ts +29 -0
- package/src/security/plugin-config-validator.test.ts +276 -0
- package/src/security/plugin-config-validator.ts +191 -0
- package/src/security/plugin-permission-enforcer.test.ts +251 -0
- package/src/security/plugin-permission-enforcer.ts +408 -0
- package/src/security/plugin-signature-verifier.ts +359 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { PluginConfigValidator } from './plugin-config-validator.js';
|
|
4
|
+
import { createLogger } from '../logger.js';
|
|
5
|
+
import type { PluginMetadata } from '../plugin-loader.js';
|
|
6
|
+
|
|
7
|
+
describe('PluginConfigValidator', () => {
|
|
8
|
+
let validator: PluginConfigValidator;
|
|
9
|
+
let logger: ReturnType<typeof createLogger>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
logger = createLogger({ level: 'error' });
|
|
13
|
+
validator = new PluginConfigValidator(logger);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('validatePluginConfig', () => {
|
|
17
|
+
it('should validate valid configuration', () => {
|
|
18
|
+
const configSchema = z.object({
|
|
19
|
+
port: z.number().min(1000).max(65535),
|
|
20
|
+
host: z.string(),
|
|
21
|
+
debug: z.boolean().default(false),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const plugin: PluginMetadata = {
|
|
25
|
+
name: 'com.test.plugin',
|
|
26
|
+
version: '1.0.0',
|
|
27
|
+
configSchema,
|
|
28
|
+
init: async () => {},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const config = {
|
|
32
|
+
port: 3000,
|
|
33
|
+
host: 'localhost',
|
|
34
|
+
debug: true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const validatedConfig = validator.validatePluginConfig(plugin, config);
|
|
38
|
+
|
|
39
|
+
expect(validatedConfig).toEqual(config);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should apply defaults for missing optional fields', () => {
|
|
43
|
+
const configSchema = z.object({
|
|
44
|
+
port: z.number().default(3000),
|
|
45
|
+
host: z.string().default('localhost'),
|
|
46
|
+
debug: z.boolean().default(false),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const plugin: PluginMetadata = {
|
|
50
|
+
name: 'com.test.plugin',
|
|
51
|
+
version: '1.0.0',
|
|
52
|
+
configSchema,
|
|
53
|
+
init: async () => {},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const config = {
|
|
57
|
+
port: 8080,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const validatedConfig = validator.validatePluginConfig(plugin, config);
|
|
61
|
+
|
|
62
|
+
expect(validatedConfig).toEqual({
|
|
63
|
+
port: 8080,
|
|
64
|
+
host: 'localhost',
|
|
65
|
+
debug: false,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should throw error for invalid configuration', () => {
|
|
70
|
+
const configSchema = z.object({
|
|
71
|
+
port: z.number().min(1000).max(65535),
|
|
72
|
+
host: z.string(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const plugin: PluginMetadata = {
|
|
76
|
+
name: 'com.test.plugin',
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
configSchema,
|
|
79
|
+
init: async () => {},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const config = {
|
|
83
|
+
port: 100, // Invalid: < 1000
|
|
84
|
+
host: 'localhost',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
expect(() => validator.validatePluginConfig(plugin, config)).toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should provide detailed error messages', () => {
|
|
91
|
+
const configSchema = z.object({
|
|
92
|
+
port: z.number().min(1000),
|
|
93
|
+
host: z.string().min(1),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const plugin: PluginMetadata = {
|
|
97
|
+
name: 'com.test.plugin',
|
|
98
|
+
version: '1.0.0',
|
|
99
|
+
configSchema,
|
|
100
|
+
init: async () => {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const config = {
|
|
104
|
+
port: 100,
|
|
105
|
+
host: '',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
validator.validatePluginConfig(plugin, config);
|
|
110
|
+
expect.fail('Should have thrown validation error');
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const errorMessage = (error as Error).message;
|
|
113
|
+
expect(errorMessage).toContain('com.test.plugin');
|
|
114
|
+
expect(errorMessage).toContain('port');
|
|
115
|
+
expect(errorMessage).toContain('host');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should skip validation when no schema is provided', () => {
|
|
120
|
+
const plugin: PluginMetadata = {
|
|
121
|
+
name: 'com.test.plugin',
|
|
122
|
+
version: '1.0.0',
|
|
123
|
+
init: async () => {},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const config = { anything: 'goes' };
|
|
127
|
+
|
|
128
|
+
const validatedConfig = validator.validatePluginConfig(plugin, config);
|
|
129
|
+
|
|
130
|
+
expect(validatedConfig).toEqual(config);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('validatePartialConfig', () => {
|
|
135
|
+
it('should validate partial configuration', () => {
|
|
136
|
+
const configSchema = z.object({
|
|
137
|
+
port: z.number().min(1000),
|
|
138
|
+
host: z.string(),
|
|
139
|
+
debug: z.boolean(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const plugin: PluginMetadata = {
|
|
143
|
+
name: 'com.test.plugin',
|
|
144
|
+
version: '1.0.0',
|
|
145
|
+
configSchema,
|
|
146
|
+
init: async () => {},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const partialConfig = {
|
|
150
|
+
port: 8080,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const validatedConfig = validator.validatePartialConfig(plugin, partialConfig);
|
|
154
|
+
|
|
155
|
+
expect(validatedConfig).toEqual({ port: 8080 });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('getDefaultConfig', () => {
|
|
160
|
+
it('should extract default configuration', () => {
|
|
161
|
+
const configSchema = z.object({
|
|
162
|
+
port: z.number().default(3000),
|
|
163
|
+
host: z.string().default('localhost'),
|
|
164
|
+
debug: z.boolean().default(false),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const plugin: PluginMetadata = {
|
|
168
|
+
name: 'com.test.plugin',
|
|
169
|
+
version: '1.0.0',
|
|
170
|
+
configSchema,
|
|
171
|
+
init: async () => {},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const defaults = validator.getDefaultConfig(plugin);
|
|
175
|
+
|
|
176
|
+
expect(defaults).toEqual({
|
|
177
|
+
port: 3000,
|
|
178
|
+
host: 'localhost',
|
|
179
|
+
debug: false,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return undefined when schema requires fields', () => {
|
|
184
|
+
const configSchema = z.object({
|
|
185
|
+
port: z.number(),
|
|
186
|
+
host: z.string(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const plugin: PluginMetadata = {
|
|
190
|
+
name: 'com.test.plugin',
|
|
191
|
+
version: '1.0.0',
|
|
192
|
+
configSchema,
|
|
193
|
+
init: async () => {},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const defaults = validator.getDefaultConfig(plugin);
|
|
197
|
+
|
|
198
|
+
expect(defaults).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('isConfigValid', () => {
|
|
203
|
+
it('should return true for valid config', () => {
|
|
204
|
+
const configSchema = z.object({
|
|
205
|
+
port: z.number(),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const plugin: PluginMetadata = {
|
|
209
|
+
name: 'com.test.plugin',
|
|
210
|
+
version: '1.0.0',
|
|
211
|
+
configSchema,
|
|
212
|
+
init: async () => {},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const isValid = validator.isConfigValid(plugin, { port: 3000 });
|
|
216
|
+
|
|
217
|
+
expect(isValid).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should return false for invalid config', () => {
|
|
221
|
+
const configSchema = z.object({
|
|
222
|
+
port: z.number(),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const plugin: PluginMetadata = {
|
|
226
|
+
name: 'com.test.plugin',
|
|
227
|
+
version: '1.0.0',
|
|
228
|
+
configSchema,
|
|
229
|
+
init: async () => {},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const isValid = validator.isConfigValid(plugin, { port: 'invalid' });
|
|
233
|
+
|
|
234
|
+
expect(isValid).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('getConfigErrors', () => {
|
|
239
|
+
it('should return errors for invalid config', () => {
|
|
240
|
+
const configSchema = z.object({
|
|
241
|
+
port: z.number().min(1000),
|
|
242
|
+
host: z.string().min(1),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const plugin: PluginMetadata = {
|
|
246
|
+
name: 'com.test.plugin',
|
|
247
|
+
version: '1.0.0',
|
|
248
|
+
configSchema,
|
|
249
|
+
init: async () => {},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const errors = validator.getConfigErrors(plugin, { port: 100, host: '' });
|
|
253
|
+
|
|
254
|
+
expect(errors).toHaveLength(2);
|
|
255
|
+
expect(errors[0].path).toBe('port');
|
|
256
|
+
expect(errors[1].path).toBe('host');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should return empty array for valid config', () => {
|
|
260
|
+
const configSchema = z.object({
|
|
261
|
+
port: z.number(),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const plugin: PluginMetadata = {
|
|
265
|
+
name: 'com.test.plugin',
|
|
266
|
+
version: '1.0.0',
|
|
267
|
+
configSchema,
|
|
268
|
+
init: async () => {},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const errors = validator.getConfigErrors(plugin, { port: 3000 });
|
|
272
|
+
|
|
273
|
+
expect(errors).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Logger } from '@objectstack/spec/contracts';
|
|
3
|
+
import type { PluginMetadata } from '../plugin-loader.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Plugin Configuration Validator
|
|
7
|
+
*
|
|
8
|
+
* Validates plugin configurations against Zod schemas to ensure:
|
|
9
|
+
* 1. Type safety - all config values have correct types
|
|
10
|
+
* 2. Business rules - values meet constraints (min/max, regex, etc.)
|
|
11
|
+
* 3. Required fields - all mandatory configuration is provided
|
|
12
|
+
* 4. Default values - missing optional fields get defaults
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* - Uses Zod for runtime validation
|
|
16
|
+
* - Provides detailed error messages with field paths
|
|
17
|
+
* - Supports nested configuration objects
|
|
18
|
+
* - Allows partial validation for incremental updates
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const validator = new PluginConfigValidator(logger);
|
|
23
|
+
* const validConfig = validator.validatePluginConfig(plugin, userConfig);
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export class PluginConfigValidator {
|
|
27
|
+
private logger: Logger;
|
|
28
|
+
|
|
29
|
+
constructor(logger: Logger) {
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate plugin configuration against its Zod schema
|
|
35
|
+
*
|
|
36
|
+
* @param plugin - Plugin metadata with configSchema
|
|
37
|
+
* @param config - User-provided configuration
|
|
38
|
+
* @returns Validated and typed configuration
|
|
39
|
+
* @throws Error with detailed validation errors
|
|
40
|
+
*/
|
|
41
|
+
validatePluginConfig<T = any>(plugin: PluginMetadata, config: any): T {
|
|
42
|
+
if (!plugin.configSchema) {
|
|
43
|
+
this.logger.debug(`Plugin ${plugin.name} has no config schema - skipping validation`);
|
|
44
|
+
return config as T;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Use Zod to parse and validate
|
|
49
|
+
const validatedConfig = plugin.configSchema.parse(config);
|
|
50
|
+
|
|
51
|
+
this.logger.debug(`✅ Plugin config validated: ${plugin.name}`, {
|
|
52
|
+
plugin: plugin.name,
|
|
53
|
+
configKeys: Object.keys(config || {}).length,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return validatedConfig as T;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof z.ZodError) {
|
|
59
|
+
const formattedErrors = this.formatZodErrors(error);
|
|
60
|
+
const errorMessage = [
|
|
61
|
+
`Plugin ${plugin.name} configuration validation failed:`,
|
|
62
|
+
...formattedErrors.map(e => ` - ${e.path}: ${e.message}`),
|
|
63
|
+
].join('\n');
|
|
64
|
+
|
|
65
|
+
this.logger.error(errorMessage, undefined, {
|
|
66
|
+
plugin: plugin.name,
|
|
67
|
+
errors: formattedErrors,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
throw new Error(errorMessage);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Re-throw other errors
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate partial configuration (for incremental updates)
|
|
80
|
+
*
|
|
81
|
+
* @param plugin - Plugin metadata
|
|
82
|
+
* @param partialConfig - Partial configuration to validate
|
|
83
|
+
* @returns Validated partial configuration
|
|
84
|
+
*/
|
|
85
|
+
validatePartialConfig<T = any>(plugin: PluginMetadata, partialConfig: any): Partial<T> {
|
|
86
|
+
if (!plugin.configSchema) {
|
|
87
|
+
return partialConfig as Partial<T>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Use Zod's partial() method for partial validation
|
|
92
|
+
// Cast to ZodObject to access partial() method
|
|
93
|
+
const partialSchema = (plugin.configSchema as any).partial();
|
|
94
|
+
const validatedConfig = partialSchema.parse(partialConfig);
|
|
95
|
+
|
|
96
|
+
this.logger.debug(`✅ Partial config validated: ${plugin.name}`);
|
|
97
|
+
return validatedConfig as Partial<T>;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error instanceof z.ZodError) {
|
|
100
|
+
const formattedErrors = this.formatZodErrors(error);
|
|
101
|
+
const errorMessage = [
|
|
102
|
+
`Plugin ${plugin.name} partial configuration validation failed:`,
|
|
103
|
+
...formattedErrors.map(e => ` - ${e.path}: ${e.message}`),
|
|
104
|
+
].join('\n');
|
|
105
|
+
|
|
106
|
+
throw new Error(errorMessage);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get default configuration from schema
|
|
115
|
+
*
|
|
116
|
+
* @param plugin - Plugin metadata
|
|
117
|
+
* @returns Default configuration object
|
|
118
|
+
*/
|
|
119
|
+
getDefaultConfig<T = any>(plugin: PluginMetadata): T | undefined {
|
|
120
|
+
if (!plugin.configSchema) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Parse empty object to get defaults
|
|
126
|
+
const defaults = plugin.configSchema.parse({});
|
|
127
|
+
this.logger.debug(`Default config extracted: ${plugin.name}`);
|
|
128
|
+
return defaults as T;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// Schema may require some fields - return undefined
|
|
131
|
+
this.logger.debug(`No default config available: ${plugin.name}`);
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if configuration is valid without throwing
|
|
138
|
+
*
|
|
139
|
+
* @param plugin - Plugin metadata
|
|
140
|
+
* @param config - Configuration to check
|
|
141
|
+
* @returns True if valid, false otherwise
|
|
142
|
+
*/
|
|
143
|
+
isConfigValid(plugin: PluginMetadata, config: any): boolean {
|
|
144
|
+
if (!plugin.configSchema) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const result = plugin.configSchema.safeParse(config);
|
|
149
|
+
return result.success;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get configuration errors without throwing
|
|
154
|
+
*
|
|
155
|
+
* @param plugin - Plugin metadata
|
|
156
|
+
* @param config - Configuration to check
|
|
157
|
+
* @returns Array of validation errors, or empty array if valid
|
|
158
|
+
*/
|
|
159
|
+
getConfigErrors(plugin: PluginMetadata, config: any): Array<{path: string; message: string}> {
|
|
160
|
+
if (!plugin.configSchema) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const result = plugin.configSchema.safeParse(config);
|
|
165
|
+
|
|
166
|
+
if (result.success) {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return this.formatZodErrors(result.error);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Private methods
|
|
174
|
+
|
|
175
|
+
private formatZodErrors(error: z.ZodError<any>): Array<{path: string; message: string}> {
|
|
176
|
+
return error.issues.map((e: z.ZodIssue) => ({
|
|
177
|
+
path: e.path.join('.') || 'root',
|
|
178
|
+
message: e.message,
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create a plugin config validator
|
|
185
|
+
*
|
|
186
|
+
* @param logger - Logger instance
|
|
187
|
+
* @returns Plugin config validator
|
|
188
|
+
*/
|
|
189
|
+
export function createPluginConfigValidator(logger: Logger): PluginConfigValidator {
|
|
190
|
+
return new PluginConfigValidator(logger);
|
|
191
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { PluginPermissionEnforcer, SecurePluginContext } from './plugin-permission-enforcer.js';
|
|
3
|
+
import { createLogger } from '../logger.js';
|
|
4
|
+
import type { PluginCapability } from '@objectstack/spec/system';
|
|
5
|
+
import type { PluginContext } from '../types.js';
|
|
6
|
+
|
|
7
|
+
describe('PluginPermissionEnforcer', () => {
|
|
8
|
+
let enforcer: PluginPermissionEnforcer;
|
|
9
|
+
let logger: ReturnType<typeof createLogger>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
logger = createLogger({ level: 'error' });
|
|
13
|
+
enforcer = new PluginPermissionEnforcer(logger);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('registerPluginPermissions', () => {
|
|
17
|
+
it('should register plugin capabilities', () => {
|
|
18
|
+
const capabilities: PluginCapability[] = [
|
|
19
|
+
{
|
|
20
|
+
protocol: {
|
|
21
|
+
id: 'com.objectstack.protocol.service.database.v1',
|
|
22
|
+
label: 'Database Service',
|
|
23
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
24
|
+
},
|
|
25
|
+
conformance: 'full',
|
|
26
|
+
certified: false,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
31
|
+
|
|
32
|
+
const registeredCapabilities = enforcer.getPluginCapabilities('com.test.plugin');
|
|
33
|
+
expect(registeredCapabilities).toEqual(capabilities);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('enforceServiceAccess', () => {
|
|
38
|
+
it('should allow access to declared services', () => {
|
|
39
|
+
const capabilities: PluginCapability[] = [
|
|
40
|
+
{
|
|
41
|
+
protocol: {
|
|
42
|
+
id: 'com.objectstack.protocol.service.database.v1',
|
|
43
|
+
label: 'Database Service',
|
|
44
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
45
|
+
},
|
|
46
|
+
conformance: 'full',
|
|
47
|
+
certified: false,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
52
|
+
|
|
53
|
+
// Should not throw
|
|
54
|
+
expect(() => {
|
|
55
|
+
enforcer.enforceServiceAccess('com.test.plugin', 'database');
|
|
56
|
+
}).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should deny access to undeclared services', () => {
|
|
60
|
+
const capabilities: PluginCapability[] = [
|
|
61
|
+
{
|
|
62
|
+
protocol: {
|
|
63
|
+
id: 'com.objectstack.protocol.service.database.v1',
|
|
64
|
+
label: 'Database Service',
|
|
65
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
66
|
+
},
|
|
67
|
+
conformance: 'full',
|
|
68
|
+
certified: false,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
73
|
+
|
|
74
|
+
expect(() => {
|
|
75
|
+
enforcer.enforceServiceAccess('com.test.plugin', 'network');
|
|
76
|
+
}).toThrow(/Permission denied/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should allow wildcard service access', () => {
|
|
80
|
+
const capabilities: PluginCapability[] = [
|
|
81
|
+
{
|
|
82
|
+
protocol: {
|
|
83
|
+
id: 'com.objectstack.protocol.service.all.v1',
|
|
84
|
+
label: 'All Services',
|
|
85
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
86
|
+
},
|
|
87
|
+
conformance: 'full',
|
|
88
|
+
certified: false,
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
93
|
+
|
|
94
|
+
// Should allow any service
|
|
95
|
+
expect(() => {
|
|
96
|
+
enforcer.enforceServiceAccess('com.test.plugin', 'database');
|
|
97
|
+
enforcer.enforceServiceAccess('com.test.plugin', 'network');
|
|
98
|
+
enforcer.enforceServiceAccess('com.test.plugin', 'filesystem');
|
|
99
|
+
}).not.toThrow();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('enforceHookTrigger', () => {
|
|
104
|
+
it('should allow triggering declared hooks', () => {
|
|
105
|
+
const capabilities: PluginCapability[] = [
|
|
106
|
+
{
|
|
107
|
+
protocol: {
|
|
108
|
+
id: 'com.objectstack.protocol.hook.data.v1',
|
|
109
|
+
label: 'Data Hooks',
|
|
110
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
111
|
+
},
|
|
112
|
+
conformance: 'full',
|
|
113
|
+
certified: false,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
118
|
+
|
|
119
|
+
expect(() => {
|
|
120
|
+
enforcer.enforceHookTrigger('com.test.plugin', 'data:beforeCreate');
|
|
121
|
+
}).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should deny triggering undeclared hooks', () => {
|
|
125
|
+
const capabilities: PluginCapability[] = [
|
|
126
|
+
{
|
|
127
|
+
protocol: {
|
|
128
|
+
id: 'com.objectstack.protocol.hook.data.v1',
|
|
129
|
+
label: 'Data Hooks',
|
|
130
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
131
|
+
},
|
|
132
|
+
conformance: 'full',
|
|
133
|
+
certified: false,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
138
|
+
|
|
139
|
+
expect(() => {
|
|
140
|
+
enforcer.enforceHookTrigger('com.test.plugin', 'kernel:shutdown');
|
|
141
|
+
}).toThrow(/Permission denied/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('revokePermissions', () => {
|
|
146
|
+
it('should revoke plugin permissions', () => {
|
|
147
|
+
const capabilities: PluginCapability[] = [
|
|
148
|
+
{
|
|
149
|
+
protocol: {
|
|
150
|
+
id: 'com.objectstack.protocol.service.database.v1',
|
|
151
|
+
label: 'Database Service',
|
|
152
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
153
|
+
},
|
|
154
|
+
conformance: 'full',
|
|
155
|
+
certified: false,
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
160
|
+
enforcer.revokePermissions('com.test.plugin');
|
|
161
|
+
|
|
162
|
+
expect(() => {
|
|
163
|
+
enforcer.enforceServiceAccess('com.test.plugin', 'database');
|
|
164
|
+
}).toThrow(/Permission denied/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('SecurePluginContext', () => {
|
|
170
|
+
let enforcer: PluginPermissionEnforcer;
|
|
171
|
+
let logger: ReturnType<typeof createLogger>;
|
|
172
|
+
let mockBaseContext: PluginContext;
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
logger = createLogger({ level: 'error' });
|
|
176
|
+
enforcer = new PluginPermissionEnforcer(logger);
|
|
177
|
+
|
|
178
|
+
mockBaseContext = {
|
|
179
|
+
registerService: () => {},
|
|
180
|
+
getService: <T>(name: string): T => ({ name } as any),
|
|
181
|
+
getServices: () => new Map(),
|
|
182
|
+
hook: () => {},
|
|
183
|
+
trigger: async () => {},
|
|
184
|
+
logger,
|
|
185
|
+
getKernel: () => ({} as any),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('getService', () => {
|
|
190
|
+
it('should check permission before accessing service', () => {
|
|
191
|
+
const capabilities: PluginCapability[] = [
|
|
192
|
+
{
|
|
193
|
+
protocol: {
|
|
194
|
+
id: 'com.objectstack.protocol.service.database.v1',
|
|
195
|
+
label: 'Database Service',
|
|
196
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
197
|
+
},
|
|
198
|
+
conformance: 'full',
|
|
199
|
+
certified: false,
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
204
|
+
|
|
205
|
+
const secureContext = new SecurePluginContext(
|
|
206
|
+
'com.test.plugin',
|
|
207
|
+
enforcer,
|
|
208
|
+
mockBaseContext
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Should succeed with permission
|
|
212
|
+
const service = secureContext.getService('database');
|
|
213
|
+
expect(service).toBeDefined();
|
|
214
|
+
|
|
215
|
+
// Should fail without permission
|
|
216
|
+
expect(() => {
|
|
217
|
+
secureContext.getService('network');
|
|
218
|
+
}).toThrow(/Permission denied/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('trigger', () => {
|
|
223
|
+
it('should check permission before triggering hook', async () => {
|
|
224
|
+
const capabilities: PluginCapability[] = [
|
|
225
|
+
{
|
|
226
|
+
protocol: {
|
|
227
|
+
id: 'com.objectstack.protocol.hook.data.v1',
|
|
228
|
+
label: 'Data Hooks',
|
|
229
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
230
|
+
},
|
|
231
|
+
conformance: 'full',
|
|
232
|
+
certified: false,
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
enforcer.registerPluginPermissions('com.test.plugin', capabilities);
|
|
237
|
+
|
|
238
|
+
const secureContext = new SecurePluginContext(
|
|
239
|
+
'com.test.plugin',
|
|
240
|
+
enforcer,
|
|
241
|
+
mockBaseContext
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Should succeed with permission
|
|
245
|
+
await expect(secureContext.trigger('data:beforeCreate')).resolves.not.toThrow();
|
|
246
|
+
|
|
247
|
+
// Should fail without permission
|
|
248
|
+
await expect(secureContext.trigger('kernel:shutdown')).rejects.toThrow(/Permission denied/);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|