@lesliechan721/aw-plugin-core 0.1.0-beta.6 → 0.1.0-beta.61
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/dist/aw-capabilities.d.ts +27 -0
- package/dist/aw-capabilities.js +117 -0
- package/dist/aw-capabilities.js.map +1 -0
- package/dist/catalog-file-modes.d.ts +5 -0
- package/dist/catalog-file-modes.js +67 -0
- package/dist/catalog-file-modes.js.map +1 -0
- package/dist/catalog-workspace-resolution.d.ts +3 -0
- package/dist/catalog-workspace-resolution.js +34 -0
- package/dist/catalog-workspace-resolution.js.map +1 -0
- package/dist/generated/plugin-manifest-v3.d.ts +1532 -0
- package/dist/generated/{plugin-manifest-v1.js → plugin-manifest-v3.js} +1 -1
- package/dist/generated/plugin-manifest-v3.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/managed-commands.d.ts +167 -0
- package/dist/managed-commands.js +463 -0
- package/dist/managed-commands.js.map +1 -0
- package/dist/manifest-contract.d.ts +3 -6
- package/dist/manifest-contract.js +271 -407
- package/dist/manifest-contract.js.map +1 -1
- package/dist/manifest-type-contract.d.ts +2 -2
- package/dist/manifest.d.ts +12 -8
- package/dist/manifest.js +18 -7
- package/dist/manifest.js.map +1 -1
- package/dist/native-runtime/reader-runtime.mjs +34 -0
- package/dist/native-runtime/reader.d.mts +6 -0
- package/dist/native-runtime/reader.mjs +11 -0
- package/dist/native-tools.d.ts +48 -0
- package/dist/native-tools.js +192 -0
- package/dist/native-tools.js.map +1 -0
- package/dist/plugin-authoring-capabilities.d.ts +1 -1
- package/dist/plugin-authoring-capabilities.js +133 -26
- package/dist/plugin-authoring-capabilities.js.map +1 -1
- package/dist/plugin-config.d.ts +47 -0
- package/dist/plugin-config.js +1355 -0
- package/dist/plugin-config.js.map +1 -0
- package/dist/plugin-requirements.d.ts +34 -0
- package/dist/plugin-requirements.js +300 -0
- package/dist/plugin-requirements.js.map +1 -0
- package/dist/plugin-script-contract.d.ts +11 -12
- package/dist/plugin-script-contract.js +44 -71
- package/dist/plugin-script-contract.js.map +1 -1
- package/dist/runtime-config.d.ts +38 -0
- package/dist/runtime-config.js +31 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/types.d.ts +266 -73
- package/dist/workspace.d.ts +37 -9
- package/dist/workspace.js +436 -195
- package/dist/workspace.js.map +1 -1
- package/package.json +16 -6
- package/schemas/plugin-manifest-v3.json +2655 -0
- package/dist/generated/plugin-manifest-v1.d.ts +0 -368
- package/dist/generated/plugin-manifest-v1.js.map +0 -1
- package/dist/plugin-release.d.ts +0 -30
- package/dist/plugin-release.js +0 -187
- package/dist/plugin-release.js.map +0 -1
- package/schemas/plugin-manifest-v1.json +0 -329
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
import { RE2 } from 're2-wasm';
|
|
2
|
+
const CONFIG_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/u;
|
|
3
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/u;
|
|
4
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
5
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:0|[1-9]\d*|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u;
|
|
6
|
+
const HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$/u;
|
|
7
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
|
|
8
|
+
const STRING_FORMATS = new Set([
|
|
9
|
+
'identifier',
|
|
10
|
+
'slug',
|
|
11
|
+
'semver',
|
|
12
|
+
'url',
|
|
13
|
+
'hostname',
|
|
14
|
+
'email',
|
|
15
|
+
]);
|
|
16
|
+
export const PLUGIN_CONFIG_V3_MAX_DEFINITIONS = 64;
|
|
17
|
+
export const PLUGIN_CONFIG_V3_MAX_FIELDS = 256;
|
|
18
|
+
/** Count each followed $ref edge, including edges from object properties; inline objects add zero. */
|
|
19
|
+
export const PLUGIN_CONFIG_V3_MAX_REF_DEPTH = 8;
|
|
20
|
+
export const PLUGIN_CONFIG_SUMMARY_MAX_PARTS = 4;
|
|
21
|
+
export const PLUGIN_CONFIG_SUMMARY_MAX_SEPARATOR_CODE_POINTS = 8;
|
|
22
|
+
export const PLUGIN_CONFIG_SUMMARY_MAX_PATH_LENGTH = 128;
|
|
23
|
+
export const PLUGIN_CONFIG_SUMMARY_MAX_CODE_POINTS = 256;
|
|
24
|
+
export const PLUGIN_CONFIG_ARRAY_MAX_ITEMS = 128;
|
|
25
|
+
export const PLUGIN_CONFIG_ARRAY_MAX_BYTES = 262144;
|
|
26
|
+
const MAX_OBJECT_PROPERTIES = 32;
|
|
27
|
+
const MAX_PATTERN_CODE_POINTS = 512;
|
|
28
|
+
const MAX_STRING_CODE_POINTS = 4096;
|
|
29
|
+
const DANGEROUS_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
30
|
+
function createPropertyMap() {
|
|
31
|
+
return Object.create(null);
|
|
32
|
+
}
|
|
33
|
+
function ownProperty(value, property) {
|
|
34
|
+
return value !== undefined && Object.hasOwn(value, property) ? value[property] : undefined;
|
|
35
|
+
}
|
|
36
|
+
function isDangerousConfigKey(value) {
|
|
37
|
+
return DANGEROUS_CONFIG_KEYS.has(value);
|
|
38
|
+
}
|
|
39
|
+
function asObject(value, path) {
|
|
40
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
41
|
+
throw new Error(`Invalid ${path}: expected an object.`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function assertAllowedKeys(value, path, allowed) {
|
|
46
|
+
for (const key of Object.keys(value)) {
|
|
47
|
+
if (!allowed.includes(key)) {
|
|
48
|
+
throw new Error(`Invalid ${path}: unknown field "${key}".`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function nonEmptyString(value, path) {
|
|
53
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
54
|
+
throw new Error(`Invalid ${path}: expected a non-empty string.`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function optionalText(value, path) {
|
|
59
|
+
return value === undefined ? undefined : nonEmptyString(value, path);
|
|
60
|
+
}
|
|
61
|
+
function optionalBoolean(value, path) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
if (typeof value !== 'boolean') {
|
|
65
|
+
throw new Error(`Invalid ${path}: expected a boolean.`);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function finiteNumber(value, path) {
|
|
70
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
71
|
+
throw new Error(`Invalid ${path}: expected a finite number.`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function nonNegativeInteger(value, path) {
|
|
76
|
+
const result = finiteNumber(value, path);
|
|
77
|
+
if (!Number.isInteger(result) || result < 0) {
|
|
78
|
+
throw new Error(`Invalid ${path}: expected a non-negative integer.`);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
function normalizeStringValidation(value, path) {
|
|
83
|
+
const input = asObject(value, path);
|
|
84
|
+
assertAllowedKeys(input, path, ['minLength', 'maxLength', 'pattern', 'format']);
|
|
85
|
+
const minLength = input.minLength === undefined
|
|
86
|
+
? undefined
|
|
87
|
+
: nonNegativeInteger(input.minLength, `${path}.minLength`);
|
|
88
|
+
const maxLength = input.maxLength === undefined
|
|
89
|
+
? undefined
|
|
90
|
+
: nonNegativeInteger(input.maxLength, `${path}.maxLength`);
|
|
91
|
+
if (maxLength !== undefined && maxLength > MAX_STRING_CODE_POINTS) {
|
|
92
|
+
throw new Error(`Invalid ${path}.maxLength: expected at most ${MAX_STRING_CODE_POINTS}.`);
|
|
93
|
+
}
|
|
94
|
+
if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
|
|
95
|
+
throw new Error(`Invalid ${path}: minLength must be less than or equal to maxLength.`);
|
|
96
|
+
}
|
|
97
|
+
if (input.pattern !== undefined && typeof input.pattern !== 'string') {
|
|
98
|
+
throw new Error(`Invalid ${path}.pattern: expected a string.`);
|
|
99
|
+
}
|
|
100
|
+
const pattern = input.pattern;
|
|
101
|
+
if (pattern !== undefined) {
|
|
102
|
+
if ([...pattern].length > MAX_PATTERN_CODE_POINTS) {
|
|
103
|
+
throw new Error(`Invalid ${path}.pattern: expected at most ${MAX_PATTERN_CODE_POINTS} Unicode code points.`);
|
|
104
|
+
}
|
|
105
|
+
if (maxLength === undefined) {
|
|
106
|
+
throw new Error(`Invalid ${path}: maxLength is required when pattern is declared.`);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
new RE2(pattern, 'u');
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new Error(`Invalid ${path}.pattern: expected a valid RE2 regular expression.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const format = input.format === undefined
|
|
116
|
+
? undefined
|
|
117
|
+
: nonEmptyString(input.format, `${path}.format`);
|
|
118
|
+
if (format !== undefined && !STRING_FORMATS.has(format)) {
|
|
119
|
+
throw new Error(`Invalid ${path}.format: unsupported string format "${format}".`);
|
|
120
|
+
}
|
|
121
|
+
return { minLength, maxLength, pattern, format };
|
|
122
|
+
}
|
|
123
|
+
function normalizeNumberValidation(value, path) {
|
|
124
|
+
const input = asObject(value, path);
|
|
125
|
+
assertAllowedKeys(input, path, ['integer', 'minimum', 'maximum', 'multipleOf']);
|
|
126
|
+
const integer = optionalBoolean(input.integer, `${path}.integer`);
|
|
127
|
+
const minimum = input.minimum === undefined
|
|
128
|
+
? undefined
|
|
129
|
+
: finiteNumber(input.minimum, `${path}.minimum`);
|
|
130
|
+
const maximum = input.maximum === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: finiteNumber(input.maximum, `${path}.maximum`);
|
|
133
|
+
const multipleOf = input.multipleOf === undefined
|
|
134
|
+
? undefined
|
|
135
|
+
: finiteNumber(input.multipleOf, `${path}.multipleOf`);
|
|
136
|
+
if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
|
|
137
|
+
throw new Error(`Invalid ${path}: minimum must be less than or equal to maximum.`);
|
|
138
|
+
}
|
|
139
|
+
if (multipleOf !== undefined && multipleOf <= 0) {
|
|
140
|
+
throw new Error(`Invalid ${path}.multipleOf: expected a number greater than zero.`);
|
|
141
|
+
}
|
|
142
|
+
return { integer, minimum, maximum, multipleOf };
|
|
143
|
+
}
|
|
144
|
+
function createIssue(code, field, message, property) {
|
|
145
|
+
return {
|
|
146
|
+
code,
|
|
147
|
+
path: property === undefined ? { field } : { field, property },
|
|
148
|
+
message,
|
|
149
|
+
recoverable: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function withArrayPath(issue, index) {
|
|
153
|
+
return {
|
|
154
|
+
...issue,
|
|
155
|
+
path: {
|
|
156
|
+
...issue.path,
|
|
157
|
+
segments: [issue.path.field, index, ...(issue.path.segments?.slice(1)
|
|
158
|
+
?? (issue.path.property === undefined ? [] : [issue.path.property]))],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function isSafeJsonRecord(value) {
|
|
163
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
164
|
+
return false;
|
|
165
|
+
const prototype = Object.getPrototypeOf(value);
|
|
166
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
167
|
+
return false;
|
|
168
|
+
return Reflect.ownKeys(value).every((key) => typeof key === 'string' && Object.hasOwn(Object.getOwnPropertyDescriptor(value, key) ?? {}, 'value'));
|
|
169
|
+
}
|
|
170
|
+
function isDenseDataArray(value) {
|
|
171
|
+
if (Object.getPrototypeOf(value) !== Array.prototype)
|
|
172
|
+
return false;
|
|
173
|
+
if (Reflect.ownKeys(value).length !== value.length + 1)
|
|
174
|
+
return false;
|
|
175
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
176
|
+
if (!Object.hasOwn(Object.getOwnPropertyDescriptor(value, String(index)) ?? {}, 'value'))
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
function stringFormatMatches(value, format) {
|
|
182
|
+
if (format === 'identifier')
|
|
183
|
+
return IDENTIFIER_PATTERN.test(value);
|
|
184
|
+
if (format === 'slug')
|
|
185
|
+
return SLUG_PATTERN.test(value);
|
|
186
|
+
if (format === 'semver')
|
|
187
|
+
return SEMVER_PATTERN.test(value);
|
|
188
|
+
if (format === 'hostname')
|
|
189
|
+
return HOSTNAME_PATTERN.test(value);
|
|
190
|
+
if (format === 'email')
|
|
191
|
+
return EMAIL_PATTERN.test(value);
|
|
192
|
+
try {
|
|
193
|
+
const url = new URL(value);
|
|
194
|
+
return url.protocol.length > 1;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function validateString(value, validation, field, property) {
|
|
201
|
+
if (typeof value !== 'string') {
|
|
202
|
+
return [createIssue('type', field, 'Expected a string.', property)];
|
|
203
|
+
}
|
|
204
|
+
const issues = [];
|
|
205
|
+
if (validation?.minLength !== undefined && [...value].length < validation.minLength) {
|
|
206
|
+
issues.push(createIssue('minLength', field, `Expected at least ${validation.minLength} characters.`, property));
|
|
207
|
+
}
|
|
208
|
+
if (validation?.maxLength !== undefined && [...value].length > validation.maxLength) {
|
|
209
|
+
issues.push(createIssue('maxLength', field, `Expected at most ${validation.maxLength} characters.`, property));
|
|
210
|
+
}
|
|
211
|
+
if (validation?.pattern !== undefined && !new RE2(validation.pattern, 'u').test(value)) {
|
|
212
|
+
issues.push(createIssue('pattern', field, `Expected a value that matches ${validation.pattern}.`, property));
|
|
213
|
+
}
|
|
214
|
+
if (validation?.format !== undefined && !stringFormatMatches(value, validation.format)) {
|
|
215
|
+
issues.push(createIssue('format', field, `Expected ${validation.format} format.`, property));
|
|
216
|
+
}
|
|
217
|
+
return issues;
|
|
218
|
+
}
|
|
219
|
+
function validateNumber(value, validation, field, property) {
|
|
220
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
221
|
+
return [createIssue('type', field, 'Expected a finite number.', property)];
|
|
222
|
+
}
|
|
223
|
+
const issues = [];
|
|
224
|
+
if (validation?.integer === true && !Number.isInteger(value)) {
|
|
225
|
+
issues.push(createIssue('integer', field, 'Expected an integer.', property));
|
|
226
|
+
}
|
|
227
|
+
if (validation?.minimum !== undefined && value < validation.minimum) {
|
|
228
|
+
issues.push(createIssue('minimum', field, `Expected a number greater than or equal to ${validation.minimum}.`, property));
|
|
229
|
+
}
|
|
230
|
+
if (validation?.maximum !== undefined && value > validation.maximum) {
|
|
231
|
+
issues.push(createIssue('maximum', field, `Expected a number less than or equal to ${validation.maximum}.`, property));
|
|
232
|
+
}
|
|
233
|
+
if (validation?.multipleOf !== undefined
|
|
234
|
+
&& Math.abs(value / validation.multipleOf - Math.round(value / validation.multipleOf)) > 1e-9) {
|
|
235
|
+
issues.push(createIssue('multipleOf', field, `Expected a multiple of ${validation.multipleOf}.`, property));
|
|
236
|
+
}
|
|
237
|
+
return issues;
|
|
238
|
+
}
|
|
239
|
+
function validateScalarField(fieldDefinition, value, field, property, checkChoices = true) {
|
|
240
|
+
const issues = fieldDefinition.type === 'string'
|
|
241
|
+
? validateString(value, fieldDefinition.validation, field, property)
|
|
242
|
+
: fieldDefinition.type === 'number'
|
|
243
|
+
? validateNumber(value, fieldDefinition.validation, field, property)
|
|
244
|
+
: typeof value === 'boolean'
|
|
245
|
+
? []
|
|
246
|
+
: [createIssue('type', field, 'Expected a boolean.', property)];
|
|
247
|
+
if (checkChoices
|
|
248
|
+
&& issues.length === 0
|
|
249
|
+
&& fieldDefinition.choices?.allowCustom === false
|
|
250
|
+
&& matchPluginConfigPreset(fieldDefinition, value) === undefined) {
|
|
251
|
+
issues.push(createIssue('choice', field, 'Expected a declared preset value.', property));
|
|
252
|
+
}
|
|
253
|
+
if (issues.length === 0 && fieldDefinition.const !== undefined && value !== fieldDefinition.const) {
|
|
254
|
+
issues.push(createIssue('const', field, 'Expected the declared constant value.', property));
|
|
255
|
+
}
|
|
256
|
+
return issues;
|
|
257
|
+
}
|
|
258
|
+
/** Resolve visible and allowed own properties without changing the supplied value. */
|
|
259
|
+
export function resolvePluginConfigObjectProperties(definition, value, field = 'value') {
|
|
260
|
+
const properties = { ...definition.properties };
|
|
261
|
+
if (definition.properties === undefined || definition.variants === undefined)
|
|
262
|
+
return { properties, issues: [] };
|
|
263
|
+
const variants = definition.variants;
|
|
264
|
+
const input = isSafeJsonRecord(value) ? value : undefined;
|
|
265
|
+
const missingSelector = input === undefined || !Object.hasOwn(input, variants.discriminator);
|
|
266
|
+
const selector = ownProperty(input, variants.discriminator);
|
|
267
|
+
const selectorField = definition.properties[variants.discriminator];
|
|
268
|
+
const issues = missingSelector ? [] : validateScalarField(selectorField, selector, field, variants.discriminator);
|
|
269
|
+
if (issues.length > 0)
|
|
270
|
+
return { properties, issues };
|
|
271
|
+
const caseName = missingSelector ? variants.missing : selector;
|
|
272
|
+
const selected = caseName === undefined ? undefined : ownProperty(variants.cases, caseName);
|
|
273
|
+
if (selected !== undefined) {
|
|
274
|
+
return { properties: { ...properties, ...selected.properties }, variant: { kind: 'case', case: caseName }, issues };
|
|
275
|
+
}
|
|
276
|
+
if (!missingSelector && variants.otherwise !== undefined) {
|
|
277
|
+
return { properties: { ...properties, ...variants.otherwise.properties }, variant: { kind: 'otherwise' }, issues };
|
|
278
|
+
}
|
|
279
|
+
return { properties, issues: [createIssue('variant', field, 'No object variant matches the discriminator.', variants.discriminator)] };
|
|
280
|
+
}
|
|
281
|
+
function withPropertyPath(issue, property) {
|
|
282
|
+
return {
|
|
283
|
+
...issue,
|
|
284
|
+
path: {
|
|
285
|
+
field: issue.path.field,
|
|
286
|
+
property,
|
|
287
|
+
segments: [issue.path.field, property, ...(issue.path.segments?.slice(1)
|
|
288
|
+
?? (issue.path.property === undefined ? [] : [issue.path.property]))],
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function validateObjectField(fieldDefinition, value, field, checkChoices = true) {
|
|
293
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
294
|
+
return [createIssue('type', field, 'Expected an object.')];
|
|
295
|
+
}
|
|
296
|
+
const input = value;
|
|
297
|
+
const keys = Object.keys(input);
|
|
298
|
+
const resolved = resolvePluginConfigObjectProperties(fieldDefinition, value, field);
|
|
299
|
+
const issues = [...resolved.issues];
|
|
300
|
+
if (keys.length > MAX_OBJECT_PROPERTIES) {
|
|
301
|
+
issues.push(createIssue('maxProperties', field, `Expected at most ${MAX_OBJECT_PROPERTIES} properties.`));
|
|
302
|
+
}
|
|
303
|
+
if (fieldDefinition.validation?.minProperties !== undefined
|
|
304
|
+
&& keys.length < fieldDefinition.validation.minProperties) {
|
|
305
|
+
issues.push(createIssue('minProperties', field, `Expected at least ${fieldDefinition.validation.minProperties} properties.`));
|
|
306
|
+
}
|
|
307
|
+
if (fieldDefinition.validation?.maxProperties !== undefined
|
|
308
|
+
&& keys.length > fieldDefinition.validation.maxProperties) {
|
|
309
|
+
issues.push(createIssue('maxProperties', field, `Expected at most ${fieldDefinition.validation.maxProperties} properties.`));
|
|
310
|
+
}
|
|
311
|
+
for (const [property, child] of Object.entries(resolved.properties)) {
|
|
312
|
+
if (child.required === true && !Object.hasOwn(input, property)) {
|
|
313
|
+
issues.push(createIssue('required', field, 'Required property is missing.', property));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const [property, childValue] of Object.entries(input)) {
|
|
317
|
+
if (isDangerousConfigKey(property)) {
|
|
318
|
+
issues.push(createIssue('dangerousKey', field, 'Dangerous config key is not allowed.', property));
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const child = ownProperty(resolved.properties, property);
|
|
322
|
+
if (child !== undefined) {
|
|
323
|
+
if (fieldDefinition.variants?.discriminator === property && resolved.issues.length > 0)
|
|
324
|
+
continue;
|
|
325
|
+
if (child.type === 'array' || child.type === 'object') {
|
|
326
|
+
if (child.type === 'object' && !isSafeJsonRecord(childValue)) {
|
|
327
|
+
issues.push(withPropertyPath(createIssue('type', field, 'Expected a JSON object.'), property));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
issues.push(...validatePluginConfigValue(child, childValue, field)
|
|
331
|
+
.map((issue) => withPropertyPath(issue, property)));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else
|
|
335
|
+
issues.push(...validateScalarField(child, childValue, field, property));
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const dynamicChild = fieldDefinition.additionalProperties;
|
|
339
|
+
if (dynamicChild === undefined || dynamicChild === false) {
|
|
340
|
+
issues.push(createIssue('additionalProperty', field, 'Additional property is not allowed.', property));
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (fieldDefinition.validation?.key !== undefined) {
|
|
344
|
+
issues.push(...validateString(property, fieldDefinition.validation.key, field, property));
|
|
345
|
+
}
|
|
346
|
+
issues.push(...validateScalarField(dynamicChild, childValue, field, property));
|
|
347
|
+
}
|
|
348
|
+
if (checkChoices
|
|
349
|
+
&& issues.length === 0
|
|
350
|
+
&& fieldDefinition.choices?.allowCustom === false
|
|
351
|
+
&& matchPluginConfigPreset(fieldDefinition, input) === undefined) {
|
|
352
|
+
issues.push(createIssue('choice', field, 'Expected a declared preset value.'));
|
|
353
|
+
}
|
|
354
|
+
return issues;
|
|
355
|
+
}
|
|
356
|
+
function validateArrayField(fieldDefinition, value, field, checkChoices = true) {
|
|
357
|
+
if (!Array.isArray(value)) {
|
|
358
|
+
return [{ ...createIssue('type', field, 'Expected a dense array of JSON values.'), path: { field, segments: [field] } }];
|
|
359
|
+
}
|
|
360
|
+
if (value.length > PLUGIN_CONFIG_ARRAY_MAX_ITEMS) {
|
|
361
|
+
return [{ ...createIssue('maxItems', field, `Expected at most ${PLUGIN_CONFIG_ARRAY_MAX_ITEMS} items.`),
|
|
362
|
+
path: { field, segments: [field] } }];
|
|
363
|
+
}
|
|
364
|
+
if (!isDenseDataArray(value)) {
|
|
365
|
+
return [{ ...createIssue('type', field, 'Expected a dense array of JSON values.'), path: { field, segments: [field] } }];
|
|
366
|
+
}
|
|
367
|
+
const issues = [];
|
|
368
|
+
const count = value.length;
|
|
369
|
+
const min = fieldDefinition.validation?.minItems;
|
|
370
|
+
const max = fieldDefinition.validation?.maxItems ?? PLUGIN_CONFIG_ARRAY_MAX_ITEMS;
|
|
371
|
+
if (count < (min ?? 0))
|
|
372
|
+
issues.push(createIssue('minItems', field, `Expected at least ${min} items.`));
|
|
373
|
+
if (count > max)
|
|
374
|
+
issues.push(createIssue('maxItems', field, `Expected at most ${max} items.`));
|
|
375
|
+
const seen = new Set();
|
|
376
|
+
const seenProperties = new Set();
|
|
377
|
+
for (const [index, item] of value.entries()) {
|
|
378
|
+
const itemIssues = fieldDefinition.items.type === 'object'
|
|
379
|
+
? isSafeJsonRecord(item)
|
|
380
|
+
? validateObjectField(fieldDefinition.items, item, field)
|
|
381
|
+
: [createIssue('type', field, 'Expected a JSON object.')]
|
|
382
|
+
: validateScalarField(fieldDefinition.items, item, field);
|
|
383
|
+
issues.push(...itemIssues.map((issue) => withArrayPath(issue, index)));
|
|
384
|
+
const uniqueBy = fieldDefinition.validation?.uniqueBy;
|
|
385
|
+
if (uniqueBy !== undefined && itemIssues.length === 0 && isSafeJsonRecord(item)) {
|
|
386
|
+
if (!Object.hasOwn(item, uniqueBy)) {
|
|
387
|
+
issues.push(withArrayPath(createIssue('uniqueBy', field, 'Unique property is missing.', uniqueBy), index));
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const key = item[uniqueBy];
|
|
391
|
+
if (seenProperties.has(key)) {
|
|
392
|
+
issues.push(withArrayPath(createIssue('uniqueBy', field, 'Expected a unique property value.', uniqueBy), index));
|
|
393
|
+
}
|
|
394
|
+
seenProperties.add(key);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (fieldDefinition.validation?.uniqueItems === true && itemIssues.length === 0) {
|
|
398
|
+
const key = stablePluginConfigStringify(item);
|
|
399
|
+
if (seen.has(key)) {
|
|
400
|
+
issues.push(withArrayPath(createIssue('uniqueItems', field, 'Expected unique items.'), index));
|
|
401
|
+
}
|
|
402
|
+
seen.add(key);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (issues.length === 0) {
|
|
406
|
+
const bytes = Buffer.byteLength(stablePluginConfigStringify(value), 'utf8');
|
|
407
|
+
if (bytes > PLUGIN_CONFIG_ARRAY_MAX_BYTES) {
|
|
408
|
+
issues.push(createIssue('maxBytes', field, `Expected at most ${PLUGIN_CONFIG_ARRAY_MAX_BYTES} UTF-8 JSON bytes.`));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (checkChoices && issues.length === 0 && fieldDefinition.choices?.allowCustom === false
|
|
412
|
+
&& matchPluginConfigPreset(fieldDefinition, value) === undefined) {
|
|
413
|
+
issues.push(createIssue('choice', field, 'Expected a declared preset value.'));
|
|
414
|
+
}
|
|
415
|
+
return issues.map((issue) => issue.path.segments === undefined
|
|
416
|
+
? { ...issue, path: { ...issue.path, segments: [field] } }
|
|
417
|
+
: issue);
|
|
418
|
+
}
|
|
419
|
+
function resolveValueArguments(first, second, third) {
|
|
420
|
+
if (typeof first === 'string') {
|
|
421
|
+
return {
|
|
422
|
+
field: first,
|
|
423
|
+
definition: second,
|
|
424
|
+
value: third,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
definition: first,
|
|
429
|
+
value: second,
|
|
430
|
+
field: typeof third === 'string' ? third : 'value',
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
export function validatePluginConfigValue(first, second, third) {
|
|
434
|
+
const resolved = resolveValueArguments(first, second, third);
|
|
435
|
+
return resolved.definition.type === 'object'
|
|
436
|
+
? validateObjectField(resolved.definition, resolved.value, resolved.field)
|
|
437
|
+
: resolved.definition.type === 'array'
|
|
438
|
+
? validateArrayField(resolved.definition, resolved.value, resolved.field)
|
|
439
|
+
: validateScalarField(resolved.definition, resolved.value, resolved.field);
|
|
440
|
+
}
|
|
441
|
+
export function validatePluginConfigDocument(definition, value) {
|
|
442
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
443
|
+
return [createIssue('type', '$document', 'Expected a config object.')];
|
|
444
|
+
}
|
|
445
|
+
const input = value;
|
|
446
|
+
const issues = [];
|
|
447
|
+
for (const [field, fieldDefinition] of Object.entries(definition.fields)) {
|
|
448
|
+
if (!Object.hasOwn(input, field)) {
|
|
449
|
+
if (fieldDefinition.required === true) {
|
|
450
|
+
issues.push(createIssue('required', field, 'Required field is missing.'));
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
issues.push(...validatePluginConfigValue(fieldDefinition, input[field], field));
|
|
455
|
+
}
|
|
456
|
+
for (const field of Object.keys(input)) {
|
|
457
|
+
if (isDangerousConfigKey(field)) {
|
|
458
|
+
issues.push(createIssue('dangerousKey', field, 'Dangerous config key is not allowed.'));
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (!Object.hasOwn(definition.fields, field)) {
|
|
462
|
+
issues.push(createIssue('unknownField', field, 'Unknown config field.'));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return [...issues, ...validatePluginConfigReferences(definition, input, Object.keys(definition.fields))];
|
|
466
|
+
}
|
|
467
|
+
function scalarSchemas(definition) {
|
|
468
|
+
if (definition.type === 'array')
|
|
469
|
+
return scalarSchemas(definition.items);
|
|
470
|
+
if (definition.type !== 'object')
|
|
471
|
+
return [definition];
|
|
472
|
+
return definition.properties === undefined
|
|
473
|
+
? [definition.additionalProperties]
|
|
474
|
+
: [definition.properties, ...Object.values(definition.variants?.cases ?? {}).map((variant) => variant.properties),
|
|
475
|
+
...(definition.variants?.otherwise === undefined ? [] : [definition.variants.otherwise.properties])]
|
|
476
|
+
.flatMap((properties) => Object.values(properties).flatMap((child) => scalarSchemas(child)));
|
|
477
|
+
}
|
|
478
|
+
/** Returns validation dependencies only; callers still project their original allowed keys. */
|
|
479
|
+
export function getPluginConfigDependencyKeys(definition, keys) {
|
|
480
|
+
const result = new Set();
|
|
481
|
+
const visit = (key) => {
|
|
482
|
+
if (result.has(key))
|
|
483
|
+
return;
|
|
484
|
+
const field = ownProperty(definition.fields, key);
|
|
485
|
+
if (field === undefined)
|
|
486
|
+
throw new Error(`Unknown config key "${key}".`);
|
|
487
|
+
result.add(key);
|
|
488
|
+
for (const scalar of scalarSchemas(field)) {
|
|
489
|
+
if ('valueRef' in scalar && scalar.valueRef !== undefined)
|
|
490
|
+
visit(scalar.valueRef.field);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
keys.forEach(visit);
|
|
494
|
+
return [...result];
|
|
495
|
+
}
|
|
496
|
+
function validatePluginConfigReferences(definition, input, keys) {
|
|
497
|
+
const issues = [];
|
|
498
|
+
const reservedPaths = new Set();
|
|
499
|
+
const sources = new Map();
|
|
500
|
+
const allowedValues = new Map();
|
|
501
|
+
const check = (scalar, value, field, segments = []) => {
|
|
502
|
+
if (!('valueRef' in scalar) || scalar.valueRef === undefined)
|
|
503
|
+
return;
|
|
504
|
+
const ref = scalar.valueRef;
|
|
505
|
+
let source = sources.get(ref.field);
|
|
506
|
+
if (source === undefined) {
|
|
507
|
+
const sourceSchema = definition.fields[ref.field];
|
|
508
|
+
const sourceValue = ownProperty(input, ref.field);
|
|
509
|
+
const valid = sourceValue === undefined
|
|
510
|
+
? sourceSchema.required !== true
|
|
511
|
+
: validatePluginConfigValue(sourceSchema, sourceValue, ref.field).length === 0;
|
|
512
|
+
source = { valid, entries: valid ? (sourceValue ?? []) : [] };
|
|
513
|
+
sources.set(ref.field, source);
|
|
514
|
+
}
|
|
515
|
+
const add = (code, message) => {
|
|
516
|
+
const property = segments.find((segment) => typeof segment === 'string');
|
|
517
|
+
const issue = createIssue(code, field, message, property);
|
|
518
|
+
issues.push(segments.length < 2 && typeof segments[0] !== 'number'
|
|
519
|
+
? issue : { ...issue, path: { ...issue.path, segments: [field, ...segments] } });
|
|
520
|
+
};
|
|
521
|
+
if (!source.valid) {
|
|
522
|
+
add('valueRefSource', `Referenced config "${ref.field}" is invalid; fix its complete array value.`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
let allowed = allowedValues.get(ref);
|
|
526
|
+
if (allowed === undefined) {
|
|
527
|
+
allowed = new Set(ref.include);
|
|
528
|
+
for (const [sourceIndex, entry] of source.entries.entries()) {
|
|
529
|
+
if (ref.include?.includes(entry[ref.key]) === true) {
|
|
530
|
+
const pathKey = `${ref.field}\0${sourceIndex}\0${ref.key}`;
|
|
531
|
+
if (!reservedPaths.has(pathKey)) {
|
|
532
|
+
reservedPaths.add(pathKey);
|
|
533
|
+
issues.push(withArrayPath(createIssue('valueRefReserved', ref.field, 'Referenced property collides with a reserved included value.', ref.key), sourceIndex));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
allowed.add(entry[ref.key]);
|
|
537
|
+
}
|
|
538
|
+
allowedValues.set(ref, allowed);
|
|
539
|
+
}
|
|
540
|
+
if (value === undefined || validateScalarField(scalar, value, field).length > 0)
|
|
541
|
+
return;
|
|
542
|
+
if (!allowed.has(value)) {
|
|
543
|
+
add('valueRef', `Expected an included value or a key from config "${ref.field}". Add that key first, update dependent fields, then remove the old key.`);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
const walk = (schema, value, field, segments = []) => {
|
|
547
|
+
if (schema.type === 'array') {
|
|
548
|
+
// Reserved source values must remain invalid when a dependent array is absent.
|
|
549
|
+
for (const scalar of scalarSchemas(schema))
|
|
550
|
+
check(scalar, undefined, field, segments);
|
|
551
|
+
if (Array.isArray(value) && validatePluginConfigValue(schema, value, field).length === 0) {
|
|
552
|
+
value.forEach((item, index) => walk(schema.items, item, field, [...segments, index]));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else if (schema.type === 'object') {
|
|
556
|
+
if (schema.properties !== undefined) {
|
|
557
|
+
const resolved = resolvePluginConfigObjectProperties(schema, value, field);
|
|
558
|
+
for (const [property, child] of Object.entries(resolved.properties)) {
|
|
559
|
+
walk(child, isSafeJsonRecord(value) ? ownProperty(value, property) : undefined, field, [...segments, property]);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
check(schema.additionalProperties, undefined, field, segments);
|
|
564
|
+
if (isSafeJsonRecord(value)) {
|
|
565
|
+
for (const [property, child] of Object.entries(value)) {
|
|
566
|
+
walk(schema.additionalProperties, child, field, [...segments, property]);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else
|
|
572
|
+
check(schema, value, field, segments);
|
|
573
|
+
};
|
|
574
|
+
for (const field of keys) {
|
|
575
|
+
const schema = definition.fields[field];
|
|
576
|
+
walk(schema, ownProperty(input, field), field);
|
|
577
|
+
}
|
|
578
|
+
return issues;
|
|
579
|
+
}
|
|
580
|
+
/** Validate one effective candidate, including complete sources required by selected fields. */
|
|
581
|
+
export function validatePluginConfigCandidate(definition, values, keys) {
|
|
582
|
+
if (!isSafeJsonRecord(values))
|
|
583
|
+
return [createIssue('type', '$document', 'Expected a config JSON object.')];
|
|
584
|
+
if (keys === undefined)
|
|
585
|
+
return validatePluginConfigDocument(definition, values);
|
|
586
|
+
const closure = getPluginConfigDependencyKeys(definition, keys);
|
|
587
|
+
const projected = Object.fromEntries(closure.flatMap((key) => Object.hasOwn(values, key) ? [[key, values[key]]] : []));
|
|
588
|
+
return validatePluginConfigDocument({ ...definition, fields: Object.fromEntries(closure.map((key) => [key, definition.fields[key]])) }, projected);
|
|
589
|
+
}
|
|
590
|
+
/** Project reference choices from an already resolved scope candidate, using the same validation as writes. */
|
|
591
|
+
export function getPluginConfigReferenceChoices(definition, scalar, values) {
|
|
592
|
+
if (!('valueRef' in scalar) || scalar.valueRef === undefined)
|
|
593
|
+
return { choices: [], issues: [] };
|
|
594
|
+
const ref = scalar.valueRef;
|
|
595
|
+
const sourceIssues = validatePluginConfigCandidate(definition, values, [ref.field]);
|
|
596
|
+
if (sourceIssues.length > 0)
|
|
597
|
+
return { choices: [], issues: sourceIssues };
|
|
598
|
+
// Other references can reserve values in the same source or its dependencies.
|
|
599
|
+
// Keep those source errors, but do not block choices on unrelated field errors.
|
|
600
|
+
const sourceKeys = new Set(getPluginConfigDependencyKeys(definition, [ref.field]));
|
|
601
|
+
const sharedSourceIssues = validatePluginConfigReferences(definition, values, Object.keys(definition.fields))
|
|
602
|
+
.filter((issue) => sourceKeys.has(issue.path.field));
|
|
603
|
+
if (sharedSourceIssues.length > 0)
|
|
604
|
+
return { choices: [], issues: sharedSourceIssues };
|
|
605
|
+
// A scalar may be nested in an inactive branch or an empty array. Validate its reference
|
|
606
|
+
// independently of the current parent value, without requiring a selection to exist yet.
|
|
607
|
+
const referenceField = '$reference';
|
|
608
|
+
const referenceDefinition = { ...definition, fields: { ...definition.fields, [referenceField]: scalar } };
|
|
609
|
+
const issues = validatePluginConfigReferences(referenceDefinition, { ...values, [referenceField]: undefined }, [referenceField]);
|
|
610
|
+
if (issues.length > 0)
|
|
611
|
+
return { choices: [], issues };
|
|
612
|
+
const entries = (ownProperty(values, ref.field) ?? []);
|
|
613
|
+
const candidates = [
|
|
614
|
+
...(ref.include ?? []).map((value) => ({ value, title: String(value) })),
|
|
615
|
+
...entries.map((entry) => {
|
|
616
|
+
const value = entry[ref.key];
|
|
617
|
+
const label = ref.label === undefined ? undefined : entry[ref.label];
|
|
618
|
+
const title = label === undefined || String(label).trim() === ''
|
|
619
|
+
? String(value) : `${String(label)} (${String(value)})`;
|
|
620
|
+
return { value, title };
|
|
621
|
+
}),
|
|
622
|
+
];
|
|
623
|
+
return { choices: candidates.filter(({ value }) => validatePluginConfigValue(scalar, value).length === 0), issues: [] };
|
|
624
|
+
}
|
|
625
|
+
function assertValueReferenceSchemas(fields, definitions, path, allowReferences = true) {
|
|
626
|
+
for (const definition of definitions) {
|
|
627
|
+
for (const scalar of scalarSchemas(definition)) {
|
|
628
|
+
if (!('valueRef' in scalar) || scalar.valueRef === undefined)
|
|
629
|
+
continue;
|
|
630
|
+
if (!allowReferences)
|
|
631
|
+
throw new Error(`Invalid ${path}: valueRef requires Config Definition v3.`);
|
|
632
|
+
const ref = scalar.valueRef;
|
|
633
|
+
const source = ownProperty(fields, ref.field);
|
|
634
|
+
if (source?.type !== 'array' || source.items.type !== 'object' || source.items.properties === undefined) {
|
|
635
|
+
throw new Error(`Invalid ${path}.valueRef.field: expected a declared fixed-object array.`);
|
|
636
|
+
}
|
|
637
|
+
const key = ownProperty(source.items.properties, ref.key);
|
|
638
|
+
if (key === undefined || key.type !== scalar.type || source.validation?.uniqueBy !== ref.key) {
|
|
639
|
+
throw new Error(`Invalid ${path}.valueRef.key: expected a scalar of the same type with matching uniqueBy.`);
|
|
640
|
+
}
|
|
641
|
+
if (ref.label !== undefined && ownProperty(source.items.properties, ref.label) === undefined) {
|
|
642
|
+
throw new Error(`Invalid ${path}.valueRef.label: expected a declared scalar property.`);
|
|
643
|
+
}
|
|
644
|
+
const label = ref.label === undefined ? undefined : ownProperty(source.items.properties, ref.label);
|
|
645
|
+
if (label?.type === 'array' || label?.type === 'object') {
|
|
646
|
+
throw new Error(`Invalid ${path}.valueRef.label: expected a declared scalar property.`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
export function canonicalizePluginConfigValue(value) {
|
|
652
|
+
if (typeof value !== 'object')
|
|
653
|
+
return value;
|
|
654
|
+
if (Array.isArray(value))
|
|
655
|
+
return value.map((item) => canonicalizePluginConfigValue(item));
|
|
656
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
657
|
+
.map(([key, child]) => [key, canonicalizePluginConfigValue(child)]));
|
|
658
|
+
}
|
|
659
|
+
export function canonicalizePluginConfigValues(values) {
|
|
660
|
+
return Object.fromEntries(Object.entries(values)
|
|
661
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
662
|
+
.map(([key, value]) => [key, canonicalizePluginConfigValue(value)]));
|
|
663
|
+
}
|
|
664
|
+
export function stablePluginConfigStringify(value) {
|
|
665
|
+
return JSON.stringify(canonicalizePluginConfigValue(value));
|
|
666
|
+
}
|
|
667
|
+
export function matchPluginConfigPreset(definition, value) {
|
|
668
|
+
return definition.choices?.presets.find((preset) => stablePluginConfigStringify(preset.value) === stablePluginConfigStringify(value));
|
|
669
|
+
}
|
|
670
|
+
export function validatePluginConfigPreset(definition, preset, field = 'value') {
|
|
671
|
+
return definition.type === 'object'
|
|
672
|
+
? validateObjectField(definition, preset.value, field, false)
|
|
673
|
+
: definition.type === 'array'
|
|
674
|
+
? validateArrayField(definition, preset.value, field, false)
|
|
675
|
+
: validateScalarField(definition, preset.value, field, undefined, false);
|
|
676
|
+
}
|
|
677
|
+
export function redactPluginConfigValue(definition, value) {
|
|
678
|
+
if (definition.type === 'string' && definition.sensitive === true)
|
|
679
|
+
return '[redacted]';
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
export function formatPluginConfigIssue(issue) {
|
|
683
|
+
const path = issue.path.segments !== undefined
|
|
684
|
+
? issue.path.segments.map((segment, index) => typeof segment === 'number' ? `[${segment}]` : index === 0 ? segment : `.${segment}`).join('')
|
|
685
|
+
: issue.path.property === undefined
|
|
686
|
+
? issue.path.field
|
|
687
|
+
: `${issue.path.field}.${issue.path.property}`;
|
|
688
|
+
return `${path}: ${issue.message}`;
|
|
689
|
+
}
|
|
690
|
+
function normalizePreset(value, path) {
|
|
691
|
+
const input = asObject(value, path);
|
|
692
|
+
assertAllowedKeys(input, path, ['id', 'title', 'description', 'value', 'recommended']);
|
|
693
|
+
if (!Object.hasOwn(input, 'value')) {
|
|
694
|
+
throw new Error(`Invalid ${path}.value: value is required.`);
|
|
695
|
+
}
|
|
696
|
+
if (input.recommended !== undefined && input.recommended !== true) {
|
|
697
|
+
throw new Error(`Invalid ${path}.recommended: expected true.`);
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
id: nonEmptyString(input.id, `${path}.id`),
|
|
701
|
+
title: nonEmptyString(input.title, `${path}.title`),
|
|
702
|
+
description: optionalText(input.description, `${path}.description`),
|
|
703
|
+
value: input.value,
|
|
704
|
+
recommended: input.recommended,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function normalizeChoices(value, path) {
|
|
708
|
+
const input = asObject(value, path);
|
|
709
|
+
assertAllowedKeys(input, path, ['allowCustom', 'presets']);
|
|
710
|
+
if (typeof input.allowCustom !== 'boolean') {
|
|
711
|
+
throw new Error(`Invalid ${path}.allowCustom: expected a boolean.`);
|
|
712
|
+
}
|
|
713
|
+
if (!Array.isArray(input.presets)) {
|
|
714
|
+
throw new Error(`Invalid ${path}.presets: expected an array.`);
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
allowCustom: input.allowCustom,
|
|
718
|
+
presets: input.presets.map((preset, index) => normalizePreset(preset, `${path}.presets[${index}]`)),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
function normalizeScalarField(value, path, options) {
|
|
722
|
+
const input = asObject(value, path);
|
|
723
|
+
const type = nonEmptyString(input.type, `${path}.type`);
|
|
724
|
+
if (type !== 'string' && type !== 'number' && type !== 'boolean') {
|
|
725
|
+
throw new Error(`Invalid ${path}.type: expected string, number, or boolean.`);
|
|
726
|
+
}
|
|
727
|
+
const allowed = ['type', 'title', 'description'];
|
|
728
|
+
if (options.allowDefault)
|
|
729
|
+
allowed.push('default');
|
|
730
|
+
if (options.allowRequired)
|
|
731
|
+
allowed.push('required');
|
|
732
|
+
if (options.allowSensitive && type === 'string')
|
|
733
|
+
allowed.push('sensitive');
|
|
734
|
+
allowed.push('choices', 'valueRef');
|
|
735
|
+
if (options.allowConst)
|
|
736
|
+
allowed.push('const');
|
|
737
|
+
if (type !== 'boolean')
|
|
738
|
+
allowed.push('validation');
|
|
739
|
+
assertAllowedKeys(input, path, allowed);
|
|
740
|
+
const sensitive = options.allowSensitive && type === 'string'
|
|
741
|
+
? optionalBoolean(input.sensitive, `${path}.sensitive`)
|
|
742
|
+
: undefined;
|
|
743
|
+
if (sensitive === true && input.default !== undefined) {
|
|
744
|
+
throw new Error(`Invalid ${path}.default: sensitive fields cannot declare a default.`);
|
|
745
|
+
}
|
|
746
|
+
if (sensitive === true && input.choices !== undefined) {
|
|
747
|
+
throw new Error(`Invalid ${path}.choices: sensitive fields cannot declare choices.`);
|
|
748
|
+
}
|
|
749
|
+
if (sensitive === true && input.valueRef !== undefined) {
|
|
750
|
+
throw new Error(`Invalid ${path}.valueRef: sensitive fields cannot declare value references.`);
|
|
751
|
+
}
|
|
752
|
+
if (sensitive === true && Object.hasOwn(input, 'const')) {
|
|
753
|
+
throw new Error(`Invalid ${path}.const: sensitive fields cannot declare a constant.`);
|
|
754
|
+
}
|
|
755
|
+
if (Object.hasOwn(input, 'const') && (typeof input.const !== type
|
|
756
|
+
|| (type === 'number' && !Number.isFinite(input.const)))) {
|
|
757
|
+
throw new Error(`Invalid ${path}.const: expected a finite scalar of type ${type}.`);
|
|
758
|
+
}
|
|
759
|
+
let valueRef;
|
|
760
|
+
if (input.valueRef !== undefined) {
|
|
761
|
+
const rawRef = asObject(input.valueRef, `${path}.valueRef`);
|
|
762
|
+
assertAllowedKeys(rawRef, `${path}.valueRef`, ['field', 'key', 'label', 'include']);
|
|
763
|
+
let include;
|
|
764
|
+
if (rawRef.include !== undefined) {
|
|
765
|
+
if (!Array.isArray(rawRef.include) || !isDenseDataArray(rawRef.include)
|
|
766
|
+
|| rawRef.include.length > PLUGIN_CONFIG_ARRAY_MAX_ITEMS) {
|
|
767
|
+
throw new Error(`Invalid ${path}.valueRef.include: expected at most ${PLUGIN_CONFIG_ARRAY_MAX_ITEMS} scalar values.`);
|
|
768
|
+
}
|
|
769
|
+
include = rawRef.include.map((entry) => {
|
|
770
|
+
if (typeof entry !== type || (type === 'number' && !Number.isFinite(entry))) {
|
|
771
|
+
throw new Error(`Invalid ${path}.valueRef.include: expected values of type ${type}.`);
|
|
772
|
+
}
|
|
773
|
+
return entry;
|
|
774
|
+
});
|
|
775
|
+
if (new Set(include).size !== include.length) {
|
|
776
|
+
throw new Error(`Invalid ${path}.valueRef.include: expected unique scalar values.`);
|
|
777
|
+
}
|
|
778
|
+
if (Buffer.byteLength(JSON.stringify(include), 'utf8') > PLUGIN_CONFIG_ARRAY_MAX_BYTES) {
|
|
779
|
+
throw new Error(`Invalid ${path}.valueRef.include: expected at most ${PLUGIN_CONFIG_ARRAY_MAX_BYTES} UTF-8 JSON bytes.`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
valueRef = {
|
|
783
|
+
field: nonEmptyString(rawRef.field, `${path}.valueRef.field`),
|
|
784
|
+
key: nonEmptyString(rawRef.key, `${path}.valueRef.key`),
|
|
785
|
+
label: optionalText(rawRef.label, `${path}.valueRef.label`),
|
|
786
|
+
include,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const common = {
|
|
790
|
+
title: optionalText(input.title, `${path}.title`),
|
|
791
|
+
description: optionalText(input.description, `${path}.description`),
|
|
792
|
+
...(options.allowRequired ? { required: optionalBoolean(input.required, `${path}.required`) } : {}),
|
|
793
|
+
...(valueRef === undefined ? {} : { valueRef }),
|
|
794
|
+
};
|
|
795
|
+
const defaultValue = options.allowDefault && input.default !== undefined
|
|
796
|
+
? input.default
|
|
797
|
+
: undefined;
|
|
798
|
+
if (type === 'string') {
|
|
799
|
+
if (sensitive === true) {
|
|
800
|
+
return {
|
|
801
|
+
type,
|
|
802
|
+
...common,
|
|
803
|
+
sensitive: true,
|
|
804
|
+
validation: input.validation === undefined
|
|
805
|
+
? undefined
|
|
806
|
+
: normalizeStringValidation(input.validation, `${path}.validation`),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
type,
|
|
811
|
+
...common,
|
|
812
|
+
...(Object.hasOwn(input, 'const') ? { const: input.const } : {}),
|
|
813
|
+
...(options.allowSensitive ? { sensitive } : {}),
|
|
814
|
+
...(options.allowDefault ? { default: defaultValue } : {}),
|
|
815
|
+
choices: input.choices === undefined
|
|
816
|
+
? undefined
|
|
817
|
+
: normalizeChoices(input.choices, `${path}.choices`),
|
|
818
|
+
validation: input.validation === undefined
|
|
819
|
+
? undefined
|
|
820
|
+
: normalizeStringValidation(input.validation, `${path}.validation`),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
if (type === 'number') {
|
|
824
|
+
return {
|
|
825
|
+
type,
|
|
826
|
+
...common,
|
|
827
|
+
...(Object.hasOwn(input, 'const') ? { const: input.const } : {}),
|
|
828
|
+
...(options.allowDefault ? { default: defaultValue } : {}),
|
|
829
|
+
choices: input.choices === undefined
|
|
830
|
+
? undefined
|
|
831
|
+
: normalizeChoices(input.choices, `${path}.choices`),
|
|
832
|
+
validation: input.validation === undefined
|
|
833
|
+
? undefined
|
|
834
|
+
: normalizeNumberValidation(input.validation, `${path}.validation`),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
type,
|
|
839
|
+
...common,
|
|
840
|
+
...(Object.hasOwn(input, 'const') ? { const: input.const } : {}),
|
|
841
|
+
...(options.allowDefault ? { default: defaultValue } : {}),
|
|
842
|
+
choices: input.choices === undefined
|
|
843
|
+
? undefined
|
|
844
|
+
: normalizeChoices(input.choices, `${path}.choices`),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
function assertChoices(definition, path) {
|
|
848
|
+
if (definition.choices === undefined)
|
|
849
|
+
return;
|
|
850
|
+
if (definition.choices.allowCustom === false && definition.choices.presets.length === 0) {
|
|
851
|
+
throw new Error(`Invalid ${path}.choices.presets: expected at least one preset when allowCustom is false.`);
|
|
852
|
+
}
|
|
853
|
+
const ids = new Set();
|
|
854
|
+
const values = new Set();
|
|
855
|
+
let recommendedCount = 0;
|
|
856
|
+
for (const [index, preset] of definition.choices.presets.entries()) {
|
|
857
|
+
if (ids.has(preset.id)) {
|
|
858
|
+
throw new Error(`Invalid ${path}.choices.presets[${index}].id: duplicate preset id "${preset.id}".`);
|
|
859
|
+
}
|
|
860
|
+
ids.add(preset.id);
|
|
861
|
+
const canonicalValue = stablePluginConfigStringify(preset.value);
|
|
862
|
+
if (values.has(canonicalValue)) {
|
|
863
|
+
throw new Error(`Invalid ${path}.choices.presets[${index}].value: duplicate preset value.`);
|
|
864
|
+
}
|
|
865
|
+
values.add(canonicalValue);
|
|
866
|
+
if (preset.recommended === true)
|
|
867
|
+
recommendedCount += 1;
|
|
868
|
+
const issues = validatePluginConfigPreset(definition, preset, path);
|
|
869
|
+
if (issues.length > 0) {
|
|
870
|
+
throw new Error(`Invalid ${path}.choices.presets[${index}].value: ${formatPluginConfigIssue(issues[0])}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (recommendedCount > 1) {
|
|
874
|
+
throw new Error(`Invalid ${path}.choices: expected at most one recommended preset.`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
function assertFieldConstraints(definition, path) {
|
|
878
|
+
assertChoices(definition, path);
|
|
879
|
+
if (definition.type !== 'object' && definition.type !== 'array' && definition.const !== undefined) {
|
|
880
|
+
const issues = validateScalarField(definition, definition.const, path);
|
|
881
|
+
if (issues.length > 0)
|
|
882
|
+
throw new Error(`Invalid ${path}.const: ${formatPluginConfigIssue(issues[0])}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
function normalizeObjectProperty(value, path, v4) {
|
|
886
|
+
const input = asObject(value, path);
|
|
887
|
+
let definition;
|
|
888
|
+
if (v4 && (input.type === 'array' || input.type === 'object')) {
|
|
889
|
+
if (Object.hasOwn(input, 'default') || Object.hasOwn(input, 'sensitive')) {
|
|
890
|
+
throw new Error(`Invalid ${path}: object properties cannot declare default or sensitive.`);
|
|
891
|
+
}
|
|
892
|
+
if (input.type === 'object' && (Object.hasOwn(input, 'properties') || Object.hasOwn(input, 'variants'))) {
|
|
893
|
+
throw new Error(`Invalid ${path}: nested objects must be scalar key-value objects.`);
|
|
894
|
+
}
|
|
895
|
+
definition = (input.type === 'array'
|
|
896
|
+
? normalizeArrayField(input, path, true, true)
|
|
897
|
+
: normalizeObjectField(input, path, true));
|
|
898
|
+
delete definition.default;
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
definition = normalizeScalarField(input, path, {
|
|
902
|
+
allowDefault: false, allowRequired: true, allowSensitive: false, allowConst: v4,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
assertFieldConstraints(definition, path);
|
|
906
|
+
return definition;
|
|
907
|
+
}
|
|
908
|
+
function normalizeObjectVariants(value, properties, path) {
|
|
909
|
+
const input = asObject(value, path);
|
|
910
|
+
assertAllowedKeys(input, path, ['discriminator', 'cases', 'missing', 'otherwise']);
|
|
911
|
+
const discriminator = nonEmptyString(input.discriminator, `${path}.discriminator`);
|
|
912
|
+
if (ownProperty(properties, discriminator)?.type !== 'string') {
|
|
913
|
+
throw new Error(`Invalid ${path}.discriminator: expected a declared own string property.`);
|
|
914
|
+
}
|
|
915
|
+
const casesInput = asObject(input.cases, `${path}.cases`);
|
|
916
|
+
if (Object.keys(casesInput).length < 1 || Object.keys(casesInput).length > MAX_OBJECT_PROPERTIES) {
|
|
917
|
+
throw new Error(`Invalid ${path}.cases: expected 1..${MAX_OBJECT_PROPERTIES} cases.`);
|
|
918
|
+
}
|
|
919
|
+
const normalizeVariant = (value, variantPath) => {
|
|
920
|
+
const variant = asObject(value, variantPath);
|
|
921
|
+
assertAllowedKeys(variant, variantPath, ['properties']);
|
|
922
|
+
const children = asObject(variant.properties, `${variantPath}.properties`);
|
|
923
|
+
if (Object.keys(children).length + Object.keys(properties).length > MAX_OBJECT_PROPERTIES) {
|
|
924
|
+
throw new Error(`Invalid ${variantPath}.properties: common and case properties must total at most ${MAX_OBJECT_PROPERTIES}.`);
|
|
925
|
+
}
|
|
926
|
+
const result = createPropertyMap();
|
|
927
|
+
for (const [key, child] of Object.entries(children)) {
|
|
928
|
+
if (isDangerousConfigKey(key))
|
|
929
|
+
throw new Error(`Invalid ${variantPath}.properties: dangerous config key.`);
|
|
930
|
+
if (Object.hasOwn(properties, key))
|
|
931
|
+
throw new Error(`Invalid ${variantPath}.properties: case cannot override common property "${key}".`);
|
|
932
|
+
result[key] = normalizeObjectProperty(child, `${variantPath}.properties.${key}`, true);
|
|
933
|
+
}
|
|
934
|
+
return { properties: result };
|
|
935
|
+
};
|
|
936
|
+
const cases = createPropertyMap();
|
|
937
|
+
for (const [name, variant] of Object.entries(casesInput)) {
|
|
938
|
+
if (isDangerousConfigKey(name))
|
|
939
|
+
throw new Error(`Invalid ${path}.cases: dangerous case key.`);
|
|
940
|
+
cases[name] = normalizeVariant(variant, `${path}.cases.${name}`);
|
|
941
|
+
}
|
|
942
|
+
const missing = optionalText(input.missing, `${path}.missing`);
|
|
943
|
+
if (missing !== undefined && !Object.hasOwn(cases, missing)) {
|
|
944
|
+
throw new Error(`Invalid ${path}.missing: expected a declared case.`);
|
|
945
|
+
}
|
|
946
|
+
return { discriminator, cases, ...(missing === undefined ? {} : { missing }),
|
|
947
|
+
...(input.otherwise === undefined ? {} : { otherwise: normalizeVariant(input.otherwise, `${path}.otherwise`) }) };
|
|
948
|
+
}
|
|
949
|
+
function normalizeObjectField(value, path, v4 = false) {
|
|
950
|
+
const input = asObject(value, path);
|
|
951
|
+
assertAllowedKeys(input, path, [
|
|
952
|
+
'type',
|
|
953
|
+
'title',
|
|
954
|
+
'description',
|
|
955
|
+
'default',
|
|
956
|
+
'required',
|
|
957
|
+
'choices',
|
|
958
|
+
'properties',
|
|
959
|
+
'additionalProperties',
|
|
960
|
+
'validation',
|
|
961
|
+
...(v4 ? ['variants'] : []),
|
|
962
|
+
]);
|
|
963
|
+
if (input.properties === undefined && (input.additionalProperties === undefined || input.additionalProperties === false)) {
|
|
964
|
+
throw new Error(`Invalid ${path}: key-value object config requires a scalar additionalProperties field.`);
|
|
965
|
+
}
|
|
966
|
+
if (input.properties !== undefined && input.additionalProperties !== undefined && input.additionalProperties !== false) {
|
|
967
|
+
throw new Error(`Invalid ${path}: fixed and key-value object config modes cannot be mixed.`);
|
|
968
|
+
}
|
|
969
|
+
let properties;
|
|
970
|
+
if (input.properties !== undefined) {
|
|
971
|
+
const propertiesInput = asObject(input.properties, `${path}.properties`);
|
|
972
|
+
if (Object.keys(propertiesInput).length > MAX_OBJECT_PROPERTIES) {
|
|
973
|
+
throw new Error(`Invalid ${path}.properties: expected at most ${MAX_OBJECT_PROPERTIES} properties.`);
|
|
974
|
+
}
|
|
975
|
+
properties = createPropertyMap();
|
|
976
|
+
for (const [property, child] of Object.entries(propertiesInput)) {
|
|
977
|
+
if (isDangerousConfigKey(property)) {
|
|
978
|
+
throw new Error(`Invalid ${path}.properties: dangerous config key "${property}" is not allowed.`);
|
|
979
|
+
}
|
|
980
|
+
properties[property] = normalizeObjectProperty(child, `${path}.properties.${property}`, v4);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
let additionalProperties;
|
|
984
|
+
if (input.additionalProperties === undefined || input.additionalProperties === false) {
|
|
985
|
+
additionalProperties = false;
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
additionalProperties = normalizeScalarField(input.additionalProperties, `${path}.additionalProperties`, { allowDefault: false, allowRequired: false, allowSensitive: false, allowConst: v4 });
|
|
989
|
+
assertFieldConstraints(additionalProperties, `${path}.additionalProperties`);
|
|
990
|
+
}
|
|
991
|
+
let validation;
|
|
992
|
+
if (input.validation !== undefined) {
|
|
993
|
+
const validationInput = asObject(input.validation, `${path}.validation`);
|
|
994
|
+
assertAllowedKeys(validationInput, `${path}.validation`, ['minProperties', 'maxProperties', 'key']);
|
|
995
|
+
const minProperties = validationInput.minProperties === undefined
|
|
996
|
+
? undefined
|
|
997
|
+
: nonNegativeInteger(validationInput.minProperties, `${path}.validation.minProperties`);
|
|
998
|
+
const maxProperties = validationInput.maxProperties === undefined
|
|
999
|
+
? undefined
|
|
1000
|
+
: nonNegativeInteger(validationInput.maxProperties, `${path}.validation.maxProperties`);
|
|
1001
|
+
if (minProperties !== undefined && minProperties > MAX_OBJECT_PROPERTIES) {
|
|
1002
|
+
throw new Error(`Invalid ${path}.validation.minProperties: expected at most ${MAX_OBJECT_PROPERTIES}.`);
|
|
1003
|
+
}
|
|
1004
|
+
if (maxProperties !== undefined && maxProperties > MAX_OBJECT_PROPERTIES) {
|
|
1005
|
+
throw new Error(`Invalid ${path}.validation.maxProperties: expected at most ${MAX_OBJECT_PROPERTIES}.`);
|
|
1006
|
+
}
|
|
1007
|
+
if (minProperties !== undefined && maxProperties !== undefined && minProperties > maxProperties) {
|
|
1008
|
+
throw new Error(`Invalid ${path}.validation: minProperties must be less than or equal to maxProperties.`);
|
|
1009
|
+
}
|
|
1010
|
+
validation = {
|
|
1011
|
+
minProperties,
|
|
1012
|
+
maxProperties,
|
|
1013
|
+
key: validationInput.key === undefined
|
|
1014
|
+
? undefined
|
|
1015
|
+
: normalizeStringValidation(validationInput.key, `${path}.validation.key`),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (input.variants !== undefined && properties === undefined) {
|
|
1019
|
+
throw new Error(`Invalid ${path}.variants: variants require a fixed object.`);
|
|
1020
|
+
}
|
|
1021
|
+
const variants = input.variants === undefined ? undefined : normalizeObjectVariants(input.variants, properties, `${path}.variants`);
|
|
1022
|
+
return {
|
|
1023
|
+
type: 'object',
|
|
1024
|
+
title: optionalText(input.title, `${path}.title`),
|
|
1025
|
+
description: optionalText(input.description, `${path}.description`),
|
|
1026
|
+
default: input.default,
|
|
1027
|
+
required: optionalBoolean(input.required, `${path}.required`),
|
|
1028
|
+
choices: input.choices === undefined
|
|
1029
|
+
? undefined
|
|
1030
|
+
: normalizeChoices(input.choices, `${path}.choices`),
|
|
1031
|
+
...(properties === undefined
|
|
1032
|
+
? { additionalProperties: additionalProperties }
|
|
1033
|
+
: { properties, additionalProperties: false, ...(variants === undefined ? {} : { variants }) }),
|
|
1034
|
+
validation,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
function normalizeArrayField(value, path, v4 = false, scalarItemsOnly = false) {
|
|
1038
|
+
const input = asObject(value, path);
|
|
1039
|
+
assertAllowedKeys(input, path, ['type', 'title', 'description', 'default', 'required', 'items', 'choices', 'validation']);
|
|
1040
|
+
const itemPath = `${path}.items`;
|
|
1041
|
+
const item = asObject(input.items, itemPath);
|
|
1042
|
+
for (const forbidden of ['default', 'required', 'sensitive']) {
|
|
1043
|
+
if (Object.hasOwn(item, forbidden)) {
|
|
1044
|
+
throw new Error(`Invalid ${itemPath}.${forbidden}: array items cannot declare ${forbidden}.`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
let items;
|
|
1048
|
+
if (item.type === 'object' && !scalarItemsOnly) {
|
|
1049
|
+
const { default: _default, required: _required, ...objectItem } = normalizeObjectField(item, itemPath, v4);
|
|
1050
|
+
items = objectItem;
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
items = normalizeScalarField(item, itemPath, {
|
|
1054
|
+
allowDefault: false, allowRequired: false, allowSensitive: false, allowConst: v4,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
assertFieldConstraints(items, itemPath);
|
|
1058
|
+
let validation;
|
|
1059
|
+
if (input.validation !== undefined) {
|
|
1060
|
+
const rawValidation = asObject(input.validation, `${path}.validation`);
|
|
1061
|
+
assertAllowedKeys(rawValidation, `${path}.validation`, ['minItems', 'maxItems', 'uniqueItems', 'uniqueBy']);
|
|
1062
|
+
const minItems = rawValidation.minItems === undefined
|
|
1063
|
+
? undefined
|
|
1064
|
+
: nonNegativeInteger(rawValidation.minItems, `${path}.validation.minItems`);
|
|
1065
|
+
const maxItems = rawValidation.maxItems === undefined
|
|
1066
|
+
? undefined
|
|
1067
|
+
: nonNegativeInteger(rawValidation.maxItems, `${path}.validation.maxItems`);
|
|
1068
|
+
if (maxItems !== undefined && maxItems > PLUGIN_CONFIG_ARRAY_MAX_ITEMS) {
|
|
1069
|
+
throw new Error(`Invalid ${path}.validation.maxItems: expected at most ${PLUGIN_CONFIG_ARRAY_MAX_ITEMS}.`);
|
|
1070
|
+
}
|
|
1071
|
+
if (minItems !== undefined && minItems > (maxItems ?? PLUGIN_CONFIG_ARRAY_MAX_ITEMS)) {
|
|
1072
|
+
throw new Error(`Invalid ${path}.validation.minItems: must not exceed effective maxItems.`);
|
|
1073
|
+
}
|
|
1074
|
+
const uniqueBy = optionalText(rawValidation.uniqueBy, `${path}.validation.uniqueBy`);
|
|
1075
|
+
const uniqueProperty = items.type === 'object' && uniqueBy !== undefined ? ownProperty(items.properties, uniqueBy) : undefined;
|
|
1076
|
+
if (uniqueBy !== undefined && (uniqueProperty === undefined
|
|
1077
|
+
|| uniqueProperty.type === 'array' || uniqueProperty.type === 'object')) {
|
|
1078
|
+
throw new Error(`Invalid ${path}.validation.uniqueBy: expected a declared fixed scalar property.`);
|
|
1079
|
+
}
|
|
1080
|
+
validation = {
|
|
1081
|
+
minItems,
|
|
1082
|
+
maxItems,
|
|
1083
|
+
uniqueItems: optionalBoolean(rawValidation.uniqueItems, `${path}.validation.uniqueItems`),
|
|
1084
|
+
...(uniqueBy === undefined ? {} : { uniqueBy }),
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
return {
|
|
1088
|
+
type: 'array',
|
|
1089
|
+
title: optionalText(input.title, `${path}.title`),
|
|
1090
|
+
description: optionalText(input.description, `${path}.description`),
|
|
1091
|
+
default: input.default,
|
|
1092
|
+
required: optionalBoolean(input.required, `${path}.required`),
|
|
1093
|
+
items,
|
|
1094
|
+
choices: input.choices === undefined
|
|
1095
|
+
? undefined
|
|
1096
|
+
: normalizeChoices(input.choices, `${path}.choices`),
|
|
1097
|
+
validation,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
/** Decode exactly one local property token, never a traversal path. */
|
|
1101
|
+
export function decodePluginConfigSummaryPath(value) {
|
|
1102
|
+
if ([...value].length > PLUGIN_CONFIG_SUMMARY_MAX_PATH_LENGTH || !/^\/(?:[^~/]|~[01])*$/u.test(value)) {
|
|
1103
|
+
throw new Error('Invalid summary path: expected one local JSON Pointer property token (at most 128 characters).');
|
|
1104
|
+
}
|
|
1105
|
+
const property = value.slice(1).replace(/~1/gu, '/').replace(/~0/gu, '~');
|
|
1106
|
+
if (isDangerousConfigKey(property))
|
|
1107
|
+
throw new Error('Invalid summary path: dangerous config key.');
|
|
1108
|
+
return property;
|
|
1109
|
+
}
|
|
1110
|
+
function expandConfigReferences(input, path, v4 = false) {
|
|
1111
|
+
const definitions = input.definitions === undefined ? {} : asObject(input.definitions, `${path}.definitions`);
|
|
1112
|
+
if (Object.keys(definitions).length > PLUGIN_CONFIG_V3_MAX_DEFINITIONS) {
|
|
1113
|
+
throw new Error(`Invalid ${path}.definitions: expected at most ${PLUGIN_CONFIG_V3_MAX_DEFINITIONS} definitions.`);
|
|
1114
|
+
}
|
|
1115
|
+
for (const name of Object.keys(definitions)) {
|
|
1116
|
+
if (!CONFIG_KEY_PATTERN.test(name) || isDangerousConfigKey(name)) {
|
|
1117
|
+
throw new Error(`Invalid ${path}.definitions: invalid definition name "${name}".`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
const expand = (value, fieldPath, chain, context = 'field') => {
|
|
1121
|
+
const field = asObject(value, fieldPath);
|
|
1122
|
+
if (Object.hasOwn(field, '$ref')) {
|
|
1123
|
+
assertAllowedKeys(field, fieldPath, ['$ref', 'title', 'description', 'default', 'required']);
|
|
1124
|
+
const ref = nonEmptyString(field.$ref, `${fieldPath}.$ref`);
|
|
1125
|
+
const match = /^#\/config\/definitions\/([A-Za-z][A-Za-z0-9_]*)$/u.exec(ref);
|
|
1126
|
+
const name = match?.[1];
|
|
1127
|
+
if (name === undefined || isDangerousConfigKey(name) || !Object.hasOwn(definitions, name)) {
|
|
1128
|
+
throw new Error(`Invalid ${fieldPath}.$ref: unknown or unsupported local definition reference.`);
|
|
1129
|
+
}
|
|
1130
|
+
if (chain.includes(name))
|
|
1131
|
+
throw new Error(`Invalid ${fieldPath}.$ref: circular definition reference.`);
|
|
1132
|
+
if (chain.length >= PLUGIN_CONFIG_V3_MAX_REF_DEPTH) {
|
|
1133
|
+
throw new Error(`Invalid ${fieldPath}.$ref: expected at most ${PLUGIN_CONFIG_V3_MAX_REF_DEPTH} reference edges.`);
|
|
1134
|
+
}
|
|
1135
|
+
const result = expand(definitions[name], `${path}.definitions.${name}`, [...chain, name], context);
|
|
1136
|
+
for (const key of ['title', 'description', 'default', 'required']) {
|
|
1137
|
+
if (Object.hasOwn(field, key))
|
|
1138
|
+
result[key] = field[key];
|
|
1139
|
+
}
|
|
1140
|
+
return result;
|
|
1141
|
+
}
|
|
1142
|
+
const result = { ...field };
|
|
1143
|
+
if (context === 'scalar' || (!v4 && context === 'property'))
|
|
1144
|
+
return result;
|
|
1145
|
+
if (context === 'property' && field.type === 'object' && (field.properties !== undefined || field.variants !== undefined)) {
|
|
1146
|
+
throw new Error(`Invalid ${fieldPath}: nested objects must be scalar key-value objects.`);
|
|
1147
|
+
}
|
|
1148
|
+
if (field.type === 'array' && field.items !== undefined) {
|
|
1149
|
+
result.items = expand(field.items, `${fieldPath}.items`, chain, context === 'property' ? 'scalar' : 'item');
|
|
1150
|
+
}
|
|
1151
|
+
if (field.properties !== undefined) {
|
|
1152
|
+
const properties = asObject(field.properties, `${fieldPath}.properties`);
|
|
1153
|
+
if (Object.keys(properties).length > MAX_OBJECT_PROPERTIES) {
|
|
1154
|
+
throw new Error(`Invalid ${fieldPath}.properties: expected at most ${MAX_OBJECT_PROPERTIES} properties.`);
|
|
1155
|
+
}
|
|
1156
|
+
result.properties = Object.fromEntries(Object.entries(properties).map(([key, child]) => [key, expand(child, `${fieldPath}.properties.${key}`, chain, 'property')]));
|
|
1157
|
+
}
|
|
1158
|
+
if (field.additionalProperties !== undefined && field.additionalProperties !== false) {
|
|
1159
|
+
result.additionalProperties = expand(field.additionalProperties, `${fieldPath}.additionalProperties`, chain, 'scalar');
|
|
1160
|
+
}
|
|
1161
|
+
if (v4 && field.variants !== undefined) {
|
|
1162
|
+
const variants = asObject(field.variants, `${fieldPath}.variants`);
|
|
1163
|
+
const cases = asObject(variants.cases, `${fieldPath}.variants.cases`);
|
|
1164
|
+
if (Object.keys(cases).length > MAX_OBJECT_PROPERTIES)
|
|
1165
|
+
throw new Error(`Invalid ${fieldPath}.variants.cases: expected at most ${MAX_OBJECT_PROPERTIES} cases.`);
|
|
1166
|
+
const expandVariant = (value, variantPath) => {
|
|
1167
|
+
const variant = asObject(value, variantPath);
|
|
1168
|
+
const properties = asObject(variant.properties, `${variantPath}.properties`);
|
|
1169
|
+
if (Object.keys(properties).length > MAX_OBJECT_PROPERTIES)
|
|
1170
|
+
throw new Error(`Invalid ${variantPath}.properties: expected at most ${MAX_OBJECT_PROPERTIES} properties.`);
|
|
1171
|
+
return { ...variant, properties: Object.fromEntries(Object.entries(properties).map(([key, child]) => [key, expand(child, `${variantPath}.properties.${key}`, chain, 'property')])) };
|
|
1172
|
+
};
|
|
1173
|
+
result.variants = { ...variants, cases: Object.fromEntries(Object.entries(cases).map(([name, variant]) => [name, expandVariant(variant, `${fieldPath}.variants.cases.${name}`)])),
|
|
1174
|
+
...(variants.otherwise === undefined ? {} : { otherwise: expandVariant(variants.otherwise, `${fieldPath}.variants.otherwise`) }) };
|
|
1175
|
+
}
|
|
1176
|
+
return result;
|
|
1177
|
+
};
|
|
1178
|
+
// Validate unused definitions too, through the same field/default/choice validator.
|
|
1179
|
+
const normalizedDefinitions = [];
|
|
1180
|
+
for (const [name, value] of Object.entries(definitions)) {
|
|
1181
|
+
normalizedDefinitions.push(normalizeConfigField(expand(value, `${path}.definitions.${name}`, []), `${path}.definitions.${name}`, true, v4));
|
|
1182
|
+
}
|
|
1183
|
+
const fields = asObject(input.fields, `${path}.fields`);
|
|
1184
|
+
if (Object.keys(fields).length > PLUGIN_CONFIG_V3_MAX_FIELDS) {
|
|
1185
|
+
throw new Error(`Invalid ${path}.fields: expected at most ${PLUGIN_CONFIG_V3_MAX_FIELDS} fields.`);
|
|
1186
|
+
}
|
|
1187
|
+
const expanded = Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, expand(value, `${path}.fields.${key}`, [])]));
|
|
1188
|
+
if (normalizedDefinitions.some((definition) => scalarSchemas(definition).some((scalar) => 'valueRef' in scalar && scalar.valueRef !== undefined))) {
|
|
1189
|
+
const normalizedFields = Object.fromEntries(Object.entries(expanded).map(([key, value]) => [key, normalizeConfigField(value, `${path}.fields.${key}`, true, v4)]));
|
|
1190
|
+
assertValueReferenceSchemas(normalizedFields, normalizedDefinitions, `${path}.definitions`);
|
|
1191
|
+
}
|
|
1192
|
+
return expanded;
|
|
1193
|
+
}
|
|
1194
|
+
function normalizeConfigField(rawDefinition, path, allowArray, v4 = false) {
|
|
1195
|
+
const rawField = asObject(rawDefinition, path);
|
|
1196
|
+
const definition = rawField.type === 'object'
|
|
1197
|
+
? normalizeObjectField(rawField, path, v4)
|
|
1198
|
+
: rawField.type === 'array' && allowArray
|
|
1199
|
+
? normalizeArrayField(rawField, path, v4)
|
|
1200
|
+
: normalizeScalarField(rawField, path, {
|
|
1201
|
+
allowDefault: true, allowRequired: true, allowSensitive: true, allowConst: v4,
|
|
1202
|
+
});
|
|
1203
|
+
assertFieldConstraints(definition, path);
|
|
1204
|
+
if (definition.default !== undefined) {
|
|
1205
|
+
const issues = validatePluginConfigValue(definition, definition.default, path);
|
|
1206
|
+
if (issues.length > 0)
|
|
1207
|
+
throw new Error(`Invalid ${path}.default: ${formatPluginConfigIssue(issues[0])}`);
|
|
1208
|
+
}
|
|
1209
|
+
return definition;
|
|
1210
|
+
}
|
|
1211
|
+
function normalizeConfigSummaries(input, fields, path) {
|
|
1212
|
+
const summaries = createPropertyMap();
|
|
1213
|
+
for (const [name, raw] of Object.entries(input.summaries === undefined ? {} : asObject(input.summaries, `${path}.summaries`))) {
|
|
1214
|
+
const summaryPath = `${path}.summaries.${name}`;
|
|
1215
|
+
if (!CONFIG_KEY_PATTERN.test(name) || isDangerousConfigKey(name))
|
|
1216
|
+
throw new Error(`Invalid ${summaryPath}: invalid summary name.`);
|
|
1217
|
+
const summary = asObject(raw, summaryPath);
|
|
1218
|
+
assertAllowedKeys(summary, summaryPath, ['kind', 'parts', 'separator', 'missing']);
|
|
1219
|
+
if (summary.kind !== 'join')
|
|
1220
|
+
throw new Error(`Invalid ${summaryPath}.kind: expected join.`);
|
|
1221
|
+
if (!Array.isArray(summary.parts) || summary.parts.length < 1 || summary.parts.length > PLUGIN_CONFIG_SUMMARY_MAX_PARTS) {
|
|
1222
|
+
throw new Error(`Invalid ${summaryPath}.parts: expected 1..${PLUGIN_CONFIG_SUMMARY_MAX_PARTS} parts.`);
|
|
1223
|
+
}
|
|
1224
|
+
if (typeof summary.separator !== 'string' || [...summary.separator].length > PLUGIN_CONFIG_SUMMARY_MAX_SEPARATOR_CODE_POINTS) {
|
|
1225
|
+
throw new Error(`Invalid ${summaryPath}.separator: expected at most 8 Unicode code points.`);
|
|
1226
|
+
}
|
|
1227
|
+
if (typeof summary.missing !== 'string')
|
|
1228
|
+
throw new Error(`Invalid ${summaryPath}.missing: expected a string.`);
|
|
1229
|
+
summaries[name] = {
|
|
1230
|
+
kind: 'join', separator: summary.separator, missing: summary.missing,
|
|
1231
|
+
parts: summary.parts.map((rawPart, index) => {
|
|
1232
|
+
const partPath = `${summaryPath}.parts[${index}]`;
|
|
1233
|
+
const part = asObject(rawPart, partPath);
|
|
1234
|
+
assertAllowedKeys(part, partPath, ['path', 'format']);
|
|
1235
|
+
const propertyPath = nonEmptyString(part.path, `${partPath}.path`);
|
|
1236
|
+
try {
|
|
1237
|
+
decodePluginConfigSummaryPath(propertyPath);
|
|
1238
|
+
}
|
|
1239
|
+
catch (error) {
|
|
1240
|
+
throw new Error(`Invalid ${partPath}.path: ${error.message}`);
|
|
1241
|
+
}
|
|
1242
|
+
if (part.format !== undefined && part.format !== 'raw' && part.format !== 'choiceTitle') {
|
|
1243
|
+
throw new Error(`Invalid ${partPath}.format: expected raw or choiceTitle.`);
|
|
1244
|
+
}
|
|
1245
|
+
return { path: propertyPath, ...(part.format === undefined ? {} : { format: part.format }) };
|
|
1246
|
+
}),
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
const fieldViews = createPropertyMap();
|
|
1250
|
+
for (const [key, raw] of Object.entries(input.fieldViews === undefined ? {} : asObject(input.fieldViews, `${path}.fieldViews`))) {
|
|
1251
|
+
const viewPath = `${path}.fieldViews.${key}`;
|
|
1252
|
+
const field = ownProperty(fields, key);
|
|
1253
|
+
const view = asObject(raw, viewPath);
|
|
1254
|
+
assertAllowedKeys(view, viewPath, ['summary', 'itemSummary']);
|
|
1255
|
+
const itemSummary = Object.hasOwn(view, 'itemSummary');
|
|
1256
|
+
if (itemSummary === Object.hasOwn(view, 'summary')) {
|
|
1257
|
+
throw new Error(`Invalid ${viewPath}: expected exactly one summary reference.`);
|
|
1258
|
+
}
|
|
1259
|
+
const objectField = itemSummary
|
|
1260
|
+
? field?.type === 'array' && field.items.type === 'object' ? field.items : undefined
|
|
1261
|
+
: field?.type === 'object' ? field : undefined;
|
|
1262
|
+
if (!objectField?.properties) {
|
|
1263
|
+
throw new Error(`Invalid ${viewPath}: summary requires a declared fixed object${itemSummary ? ' array item' : ' field'}.`);
|
|
1264
|
+
}
|
|
1265
|
+
const name = nonEmptyString(itemSummary ? view.itemSummary : view.summary, `${viewPath}.${itemSummary ? 'itemSummary' : 'summary'}`);
|
|
1266
|
+
const summary = ownProperty(summaries, name);
|
|
1267
|
+
if (!summary)
|
|
1268
|
+
throw new Error(`Invalid ${viewPath}.${itemSummary ? 'itemSummary' : 'summary'}: unknown summary.`);
|
|
1269
|
+
for (const part of summary.parts) {
|
|
1270
|
+
const propertyName = decodePluginConfigSummaryPath(part.path);
|
|
1271
|
+
const declarations = [objectField.properties,
|
|
1272
|
+
...Object.values(objectField.variants?.cases ?? {}).map((variant) => variant.properties),
|
|
1273
|
+
...(objectField.variants?.otherwise === undefined ? [] : [objectField.variants.otherwise.properties])]
|
|
1274
|
+
.flatMap((properties) => {
|
|
1275
|
+
const property = ownProperty(properties, propertyName);
|
|
1276
|
+
return property === undefined ? [] : [property];
|
|
1277
|
+
});
|
|
1278
|
+
if (declarations.length === 0 || declarations.some((property) => property.type === 'array' || property.type === 'object')) {
|
|
1279
|
+
throw new Error(`Invalid ${viewPath}: summary path "${part.path}" must name a declared scalar property.`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
fieldViews[key] = itemSummary ? { itemSummary: name } : { summary: name };
|
|
1283
|
+
}
|
|
1284
|
+
return {
|
|
1285
|
+
...(input.summaries === undefined ? {} : { summaries }),
|
|
1286
|
+
...(input.fieldViews === undefined ? {} : { fieldViews }),
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
export function normalizePluginConfigDefinition(value, path = 'config') {
|
|
1290
|
+
const input = asObject(value, path);
|
|
1291
|
+
const v4 = input.schemaVersion === 4;
|
|
1292
|
+
const v3 = input.schemaVersion === 3 || v4;
|
|
1293
|
+
assertAllowedKeys(input, path, v3 ? ['schemaVersion', 'definitions', 'fields', 'presentation'] : ['fields', 'presentation']);
|
|
1294
|
+
const fieldsInput = v3 ? expandConfigReferences(input, path, v4) : asObject(input.fields, `${path}.fields`);
|
|
1295
|
+
const fields = createPropertyMap();
|
|
1296
|
+
for (const [field, rawDefinition] of Object.entries(fieldsInput)) {
|
|
1297
|
+
if (!CONFIG_KEY_PATTERN.test(field) || isDangerousConfigKey(field)) {
|
|
1298
|
+
throw new Error(`Invalid ${path}.fields: config key "${field}" is not valid.`);
|
|
1299
|
+
}
|
|
1300
|
+
fields[field] = normalizeConfigField(rawDefinition, `${path}.fields.${field}`, v3, v4);
|
|
1301
|
+
}
|
|
1302
|
+
assertValueReferenceSchemas(fields, Object.values(fields), path, v3);
|
|
1303
|
+
const defaultValues = Object.fromEntries(Object.entries(fields).flatMap(([field, definition]) => definition.default === undefined ? [] : [[field, definition.default]]));
|
|
1304
|
+
const defaultIssues = validatePluginConfigReferences({ schemaVersion: 4, fields }, defaultValues, Object.keys(fields))
|
|
1305
|
+
.filter((issue) => Object.hasOwn(defaultValues, issue.path.field));
|
|
1306
|
+
if (defaultIssues.length > 0) {
|
|
1307
|
+
throw new Error(`Invalid ${path}.default: ${formatPluginConfigIssue(defaultIssues[0])}`);
|
|
1308
|
+
}
|
|
1309
|
+
let presentation;
|
|
1310
|
+
if (input.presentation !== undefined) {
|
|
1311
|
+
const presentationInput = asObject(input.presentation, `${path}.presentation`);
|
|
1312
|
+
assertAllowedKeys(presentationInput, `${path}.presentation`, v3 ? ['groups', 'summaries', 'fieldViews'] : ['groups']);
|
|
1313
|
+
if (presentationInput.groups !== undefined && !Array.isArray(presentationInput.groups)) {
|
|
1314
|
+
throw new Error(`Invalid ${path}.presentation.groups: expected an array.`);
|
|
1315
|
+
}
|
|
1316
|
+
const groupIds = new Set();
|
|
1317
|
+
const groupedFields = new Set();
|
|
1318
|
+
presentation = {
|
|
1319
|
+
...(v3 ? normalizeConfigSummaries(presentationInput, fields, `${path}.presentation`) : {}),
|
|
1320
|
+
groups: presentationInput.groups?.map((rawGroup, index) => {
|
|
1321
|
+
const groupPath = `${path}.presentation.groups[${index}]`;
|
|
1322
|
+
const group = asObject(rawGroup, groupPath);
|
|
1323
|
+
assertAllowedKeys(group, groupPath, ['id', 'title', 'description', 'fields', 'advanced']);
|
|
1324
|
+
const id = nonEmptyString(group.id, `${groupPath}.id`);
|
|
1325
|
+
if (groupIds.has(id)) {
|
|
1326
|
+
throw new Error(`Invalid ${groupPath}.id: duplicate group id "${id}".`);
|
|
1327
|
+
}
|
|
1328
|
+
groupIds.add(id);
|
|
1329
|
+
if (!Array.isArray(group.fields)) {
|
|
1330
|
+
throw new Error(`Invalid ${groupPath}.fields: expected an array.`);
|
|
1331
|
+
}
|
|
1332
|
+
const groupFields = group.fields.map((entry, fieldIndex) => {
|
|
1333
|
+
const field = nonEmptyString(entry, `${groupPath}.fields[${fieldIndex}]`);
|
|
1334
|
+
if (!Object.hasOwn(fields, field)) {
|
|
1335
|
+
throw new Error(`Invalid ${groupPath}.fields[${fieldIndex}]: unknown config key "${field}".`);
|
|
1336
|
+
}
|
|
1337
|
+
if (groupedFields.has(field)) {
|
|
1338
|
+
throw new Error(`Invalid ${groupPath}.fields[${fieldIndex}]: config key "${field}" is assigned to more than one group.`);
|
|
1339
|
+
}
|
|
1340
|
+
groupedFields.add(field);
|
|
1341
|
+
return field;
|
|
1342
|
+
});
|
|
1343
|
+
return {
|
|
1344
|
+
id,
|
|
1345
|
+
title: nonEmptyString(group.title, `${groupPath}.title`),
|
|
1346
|
+
description: optionalText(group.description, `${groupPath}.description`),
|
|
1347
|
+
fields: groupFields,
|
|
1348
|
+
advanced: optionalBoolean(group.advanced, `${groupPath}.advanced`),
|
|
1349
|
+
};
|
|
1350
|
+
}),
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
return { ...(v3 ? { schemaVersion: v4 ? 4 : 3 } : {}), fields, presentation };
|
|
1354
|
+
}
|
|
1355
|
+
//# sourceMappingURL=plugin-config.js.map
|