openxiangda-devkit-core 2.0.0-alpha.29 → 2.0.0-alpha.34
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/README.md +2 -1
- package/dist/application-services.d.ts +79 -43
- package/dist/application-services.d.ts.map +1 -1
- package/dist/application-services.js +257 -160
- package/dist/application-services.js.map +1 -1
- package/dist/backend-image-build.d.ts +23 -0
- package/dist/backend-image-build.d.ts.map +1 -0
- package/dist/backend-image-build.js +192 -0
- package/dist/backend-image-build.js.map +1 -0
- package/dist/command-registry.d.ts +26 -576
- package/dist/command-registry.d.ts.map +1 -1
- package/dist/command-registry.js +9 -374
- package/dist/command-registry.js.map +1 -1
- package/dist/compiler/bundle.d.ts +23 -0
- package/dist/compiler/bundle.d.ts.map +1 -0
- package/dist/compiler/bundle.js +604 -0
- package/dist/compiler/bundle.js.map +1 -0
- package/dist/compiler/config.d.ts +377 -0
- package/dist/compiler/config.d.ts.map +1 -0
- package/dist/compiler/config.js +1137 -0
- package/dist/compiler/config.js.map +1 -0
- package/dist/compiler/index.d.ts +4 -0
- package/dist/compiler/index.d.ts.map +1 -0
- package/dist/compiler/index.js +4 -0
- package/dist/compiler/index.js.map +1 -0
- package/dist/compiler/package-compiler.d.ts +22 -0
- package/dist/compiler/package-compiler.d.ts.map +1 -0
- package/dist/compiler/package-compiler.js +60 -0
- package/dist/compiler/package-compiler.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/connected-development.d.ts +62 -0
- package/dist/connected-development.d.ts.map +1 -0
- package/dist/connected-development.js +389 -0
- package/dist/connected-development.js.map +1 -0
- package/dist/control-plane-client.d.ts +50 -1
- package/dist/control-plane-client.d.ts.map +1 -1
- package/dist/control-plane-client.js +43 -2
- package/dist/control-plane-client.js.map +1 -1
- package/dist/deployment.d.ts +3 -1
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +18 -8
- package/dist/deployment.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/workflow.d.ts +81 -0
- package/dist/internal/workflow.d.ts.map +1 -0
- package/dist/internal/workflow.js +351 -0
- package/dist/internal/workflow.js.map +1 -0
- package/dist/package-compiler.d.ts +1 -1
- package/dist/package-compiler.d.ts.map +1 -1
- package/dist/package-compiler.js +1 -1
- package/dist/package-compiler.js.map +1 -1
- package/dist/testing.d.ts +38 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +46 -0
- package/dist/testing.js.map +1 -0
- package/dist/workspace-loader.d.ts +1 -1
- package/package.json +8 -4
- package/dist/local-development.d.ts +0 -85
- package/dist/local-development.d.ts.map +0 -1
- package/dist/local-development.js +0 -919
- package/dist/local-development.js.map +0 -1
- package/dist/local-postgres.d.ts +0 -32
- package/dist/local-postgres.d.ts.map +0 -1
- package/dist/local-postgres.js +0 -324
- package/dist/local-postgres.js.map +0 -1
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
import { SCHEMA_VERSIONS, nativePlatformCapabilityCatalog, validateDataResource, } from 'openxiangda-contracts';
|
|
2
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
3
|
+
// The compiler owns the application source contract. Devkit and every adapter
|
|
4
|
+
// re-export this module instead of maintaining separate configuration models.
|
|
5
|
+
import { validateWorkflowBinding, validateWorkflowDefinition, } from '../internal/workflow.js';
|
|
6
|
+
export const openXiangdaAppConfigSchema = {
|
|
7
|
+
$id: 'openxiangda.app-config/v3',
|
|
8
|
+
type: 'object',
|
|
9
|
+
additionalProperties: false,
|
|
10
|
+
required: ['schemaVersion', 'app', 'frontend', 'backend', 'platform'],
|
|
11
|
+
properties: {
|
|
12
|
+
schemaVersion: { const: 3 },
|
|
13
|
+
app: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
required: ['code', 'name'],
|
|
17
|
+
properties: {
|
|
18
|
+
code: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
pattern: '^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$',
|
|
21
|
+
minLength: 2,
|
|
22
|
+
maxLength: 128,
|
|
23
|
+
},
|
|
24
|
+
name: { type: 'string', minLength: 1, maxLength: 255 },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
frontend: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
required: ['root'],
|
|
31
|
+
properties: {
|
|
32
|
+
root: { type: 'string', minLength: 1 },
|
|
33
|
+
routes: { type: 'array', maxItems: 500, items: { type: 'object' } },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
backend: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
required: ['root', 'runtime', 'framework'],
|
|
40
|
+
properties: {
|
|
41
|
+
root: { type: 'string', minLength: 1 },
|
|
42
|
+
runtime: { const: 'node' },
|
|
43
|
+
framework: { const: 'nestjs' },
|
|
44
|
+
runMode: { enum: ['shared', 'dedicated'] },
|
|
45
|
+
secrets: {
|
|
46
|
+
type: 'array',
|
|
47
|
+
maxItems: 100,
|
|
48
|
+
items: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
required: ['name', 'env'],
|
|
52
|
+
properties: {
|
|
53
|
+
name: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
pattern: '^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$',
|
|
56
|
+
maxLength: 128,
|
|
57
|
+
},
|
|
58
|
+
env: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
pattern: '^[A-Z][A-Z0-9_]*$',
|
|
61
|
+
maxLength: 128,
|
|
62
|
+
},
|
|
63
|
+
description: { type: 'string', maxLength: 2000 },
|
|
64
|
+
required: { type: 'boolean' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
operations: {
|
|
69
|
+
type: 'array',
|
|
70
|
+
maxItems: 500,
|
|
71
|
+
items: { type: 'object' },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
platform: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
required: ['root'],
|
|
79
|
+
properties: { root: { type: 'string', minLength: 1 } },
|
|
80
|
+
},
|
|
81
|
+
data: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
required: ['resources'],
|
|
85
|
+
properties: {
|
|
86
|
+
resources: { type: 'array', maxItems: 100, items: { type: 'object' } },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
authz: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
required: ['capabilities', 'roles'],
|
|
93
|
+
properties: {
|
|
94
|
+
capabilities: {
|
|
95
|
+
type: 'array',
|
|
96
|
+
maxItems: 2000,
|
|
97
|
+
items: { type: 'object' },
|
|
98
|
+
},
|
|
99
|
+
roles: { type: 'array', maxItems: 100, items: { type: 'object' } },
|
|
100
|
+
scopeDimensions: {
|
|
101
|
+
type: 'array',
|
|
102
|
+
maxItems: 100,
|
|
103
|
+
items: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
required: ['code', 'name'],
|
|
107
|
+
properties: {
|
|
108
|
+
code: { type: 'string' },
|
|
109
|
+
name: { type: 'string' },
|
|
110
|
+
resourceCode: { type: 'string' },
|
|
111
|
+
valueType: { enum: ['string', 'uuid'] },
|
|
112
|
+
hierarchyMode: { enum: ['flat', 'self_parent'] },
|
|
113
|
+
valueSource: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
required: ['kind', 'resourceCode', 'labelField'],
|
|
117
|
+
properties: {
|
|
118
|
+
kind: { const: 'native_resource' },
|
|
119
|
+
resourceCode: { type: 'string' },
|
|
120
|
+
labelField: { type: 'string' },
|
|
121
|
+
enabledField: { type: 'string' },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
scopeSources: {
|
|
128
|
+
type: 'array',
|
|
129
|
+
maxItems: 100,
|
|
130
|
+
items: { type: 'object' },
|
|
131
|
+
},
|
|
132
|
+
dataPolicies: {
|
|
133
|
+
type: 'array',
|
|
134
|
+
maxItems: 100,
|
|
135
|
+
items: { type: 'object' },
|
|
136
|
+
},
|
|
137
|
+
authorizationTransitions: {
|
|
138
|
+
type: 'array',
|
|
139
|
+
maxItems: 100,
|
|
140
|
+
items: { type: 'object' },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
events: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
additionalProperties: false,
|
|
147
|
+
required: ['subscriptions'],
|
|
148
|
+
properties: {
|
|
149
|
+
subscriptions: {
|
|
150
|
+
type: 'array',
|
|
151
|
+
maxItems: 100,
|
|
152
|
+
items: { type: 'object' },
|
|
153
|
+
},
|
|
154
|
+
timers: {
|
|
155
|
+
type: 'array',
|
|
156
|
+
maxItems: 100,
|
|
157
|
+
items: { type: 'object' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
workflows: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
required: ['definitions', 'bindings', 'activations'],
|
|
165
|
+
properties: {
|
|
166
|
+
definitions: {
|
|
167
|
+
type: 'array',
|
|
168
|
+
maxItems: 100,
|
|
169
|
+
items: { type: 'object' },
|
|
170
|
+
},
|
|
171
|
+
bindings: {
|
|
172
|
+
type: 'array',
|
|
173
|
+
maxItems: 100,
|
|
174
|
+
items: { type: 'object' },
|
|
175
|
+
},
|
|
176
|
+
activations: {
|
|
177
|
+
type: 'array',
|
|
178
|
+
maxItems: 100,
|
|
179
|
+
items: { type: 'object' },
|
|
180
|
+
},
|
|
181
|
+
providers: {
|
|
182
|
+
type: 'array',
|
|
183
|
+
maxItems: 100,
|
|
184
|
+
items: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
required: ['code', 'endpointPath'],
|
|
188
|
+
properties: {
|
|
189
|
+
code: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
pattern: '^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$',
|
|
192
|
+
},
|
|
193
|
+
endpointPath: {
|
|
194
|
+
type: 'string',
|
|
195
|
+
pattern: '^/[A-Za-z0-9/_-]+$',
|
|
196
|
+
},
|
|
197
|
+
timeoutMs: { type: 'integer', minimum: 100, maximum: 10000 },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
editableParameters: {
|
|
202
|
+
type: 'array',
|
|
203
|
+
maxItems: 100,
|
|
204
|
+
items: { type: 'object' },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
function diagnostic(code, message, path, remediation) {
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: SCHEMA_VERSIONS.diagnostic,
|
|
213
|
+
code,
|
|
214
|
+
severity: 'error',
|
|
215
|
+
message,
|
|
216
|
+
path,
|
|
217
|
+
source: 'openxiangda-app.config.ts',
|
|
218
|
+
retryable: false,
|
|
219
|
+
...(remediation ? { remediation } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function object(value) {
|
|
223
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
224
|
+
? value
|
|
225
|
+
: {};
|
|
226
|
+
}
|
|
227
|
+
function string(value) {
|
|
228
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
229
|
+
}
|
|
230
|
+
export function validateAppConfig(value) {
|
|
231
|
+
const diagnostics = [];
|
|
232
|
+
const config = object(value);
|
|
233
|
+
rejectNativeEnvironmentFields(config, diagnostics);
|
|
234
|
+
if (config.schemaVersion !== 3) {
|
|
235
|
+
diagnostics.push(diagnostic('APP_CONFIG_SCHEMA_UNSUPPORTED', 'schemaVersion 必须是 3', 'schemaVersion', '使用 Native 2.0 模板重新生成 openxiangda-app.config.ts;不支持 v2 自动转换'));
|
|
236
|
+
}
|
|
237
|
+
const app = object(config.app);
|
|
238
|
+
const appCode = string(app.code);
|
|
239
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(appCode)) {
|
|
240
|
+
diagnostics.push(diagnostic('APP_CONFIG_CODE_INVALID', 'app.code 必须是 kebab-case 稳定应用代码', 'app.code'));
|
|
241
|
+
}
|
|
242
|
+
if (!string(app.name)) {
|
|
243
|
+
diagnostics.push(diagnostic('APP_CONFIG_NAME_REQUIRED', 'app.name 必须是非空字符串', 'app.name'));
|
|
244
|
+
}
|
|
245
|
+
const frontend = object(config.frontend);
|
|
246
|
+
const backend = object(config.backend);
|
|
247
|
+
const platform = object(config.platform);
|
|
248
|
+
for (const [path, root] of [
|
|
249
|
+
['frontend.root', frontend.root],
|
|
250
|
+
['backend.root', backend.root],
|
|
251
|
+
['platform.root', platform.root],
|
|
252
|
+
]) {
|
|
253
|
+
if (!string(root)) {
|
|
254
|
+
diagnostics.push(diagnostic('APP_CONFIG_ROOT_REQUIRED', `${path} 必须是非空相对路径`, path));
|
|
255
|
+
}
|
|
256
|
+
else if (string(root).startsWith('/') || string(root).includes('..')) {
|
|
257
|
+
diagnostics.push(diagnostic('APP_CONFIG_ROOT_UNSAFE', `${path} 必须位于工作区内`, path));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (backend.runtime !== 'node') {
|
|
261
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_RUNTIME_UNSUPPORTED', 'backend.runtime 首期只支持 node', 'backend.runtime'));
|
|
262
|
+
}
|
|
263
|
+
if (backend.framework !== 'nestjs') {
|
|
264
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_FRAMEWORK_UNSUPPORTED', 'backend.framework 首期只支持 nestjs', 'backend.framework'));
|
|
265
|
+
}
|
|
266
|
+
if (backend.runMode !== undefined &&
|
|
267
|
+
!['shared', 'dedicated'].includes(String(backend.runMode))) {
|
|
268
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_RUN_MODE_UNSUPPORTED', 'backend.runMode 必须是 shared 或 dedicated', 'backend.runMode'));
|
|
269
|
+
}
|
|
270
|
+
validateFrontendRoutes(frontend.routes, diagnostics);
|
|
271
|
+
validateBackendOperations(backend.operations, diagnostics);
|
|
272
|
+
const secretNames = new Set();
|
|
273
|
+
const secretEnvs = new Set();
|
|
274
|
+
(Array.isArray(backend.secrets) ? backend.secrets : []).forEach((raw, index) => {
|
|
275
|
+
const secret = object(raw);
|
|
276
|
+
const name = string(secret.name);
|
|
277
|
+
const env = string(secret.env);
|
|
278
|
+
const path = `backend.secrets[${index}]`;
|
|
279
|
+
if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(name)) {
|
|
280
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_SECRET_NAME_INVALID', '后端 Secret name 必须是稳定的 kebab/snake-case 逻辑名称', `${path}.name`));
|
|
281
|
+
}
|
|
282
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(env) ||
|
|
283
|
+
env.startsWith('OPENXIANGDA_') ||
|
|
284
|
+
['NODE_ENV', 'PORT'].includes(env)) {
|
|
285
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_SECRET_ENV_INVALID', '后端 Secret env 必须是合法大写环境变量,且不能占用平台保留变量', `${path}.env`));
|
|
286
|
+
}
|
|
287
|
+
if (secretNames.has(name) || secretEnvs.has(env)) {
|
|
288
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_SECRET_DUPLICATE', '后端 Secret 的 name 和 env 必须各自唯一', path));
|
|
289
|
+
}
|
|
290
|
+
if (secret.required !== undefined &&
|
|
291
|
+
typeof secret.required !== 'boolean') {
|
|
292
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_SECRET_REQUIRED_INVALID', '后端 Secret required 必须是 boolean', `${path}.required`));
|
|
293
|
+
}
|
|
294
|
+
secretNames.add(name);
|
|
295
|
+
secretEnvs.add(env);
|
|
296
|
+
});
|
|
297
|
+
const data = object(config.data);
|
|
298
|
+
if (config.data !== undefined && !Array.isArray(data.resources)) {
|
|
299
|
+
diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCES_REQUIRED', 'data.resources 必须是数组', 'data.resources'));
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
(Array.isArray(data.resources) ? data.resources : []).forEach((resource, index) => {
|
|
303
|
+
const resourceDiagnostics = validateDataResource(resource);
|
|
304
|
+
diagnostics.push(...resourceDiagnostics.map(item => ({
|
|
305
|
+
...item,
|
|
306
|
+
path: `data.resources[${index}]${item.path === '$' ? '' : `.${item.path}`}`,
|
|
307
|
+
source: 'openxiangda-app.config.ts',
|
|
308
|
+
})));
|
|
309
|
+
if (object(resource).appCode &&
|
|
310
|
+
string(object(resource).appCode) !== appCode) {
|
|
311
|
+
diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_APP_MISMATCH', 'DataResource.appCode 必须与 app.code 一致', `data.resources[${index}].appCode`));
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const authz = object(config.authz);
|
|
316
|
+
const declaredCapabilities = Array.isArray(authz.capabilities)
|
|
317
|
+
? authz.capabilities
|
|
318
|
+
: [];
|
|
319
|
+
if (config.authz !== undefined) {
|
|
320
|
+
if (!Array.isArray(authz.capabilities)) {
|
|
321
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_CAPABILITIES_REQUIRED', 'authz.capabilities 必须是显式 backend/ui capability 数组', 'authz.capabilities'));
|
|
322
|
+
}
|
|
323
|
+
validateCapabilityDeclarations(appCode, declaredCapabilities, diagnostics);
|
|
324
|
+
const roles = Array.isArray(authz.roles) ? authz.roles : [];
|
|
325
|
+
if (!Array.isArray(authz.roles)) {
|
|
326
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_ROLES_REQUIRED', 'authz.roles 必须是数组', 'authz.roles'));
|
|
327
|
+
}
|
|
328
|
+
const roleCodes = new Set();
|
|
329
|
+
roles.forEach((raw, index) => {
|
|
330
|
+
const role = object(raw);
|
|
331
|
+
const code = string(role.code);
|
|
332
|
+
if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(code)) {
|
|
333
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_ROLE_CODE_INVALID', '角色 code 必须是稳定的小写标识', `authz.roles[${index}].code`));
|
|
334
|
+
}
|
|
335
|
+
else if (roleCodes.has(code)) {
|
|
336
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_ROLE_DUPLICATE', '角色 code 不能重复', `authz.roles[${index}].code`));
|
|
337
|
+
}
|
|
338
|
+
roleCodes.add(code);
|
|
339
|
+
if (!string(role.name) ||
|
|
340
|
+
!Array.isArray(role.capabilities) ||
|
|
341
|
+
'isAppSuperAdmin' in role) {
|
|
342
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_ROLE_INVALID', '角色必须声明 name 和 capabilities', `authz.roles[${index}]`));
|
|
343
|
+
}
|
|
344
|
+
const capabilities = Array.isArray(role.capabilities)
|
|
345
|
+
? role.capabilities
|
|
346
|
+
: [];
|
|
347
|
+
const seenCapabilities = new Set();
|
|
348
|
+
capabilities.forEach((rawCapability, capabilityIndex) => {
|
|
349
|
+
const capability = string(rawCapability);
|
|
350
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9:._*-]{0,254}$/.test(capability) ||
|
|
351
|
+
seenCapabilities.has(capability)) {
|
|
352
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_CAPABILITY_INVALID', 'capability 必须是非空稳定标识,且同一角色内不能重复', `authz.roles[${index}].capabilities[${capabilityIndex}]`));
|
|
353
|
+
}
|
|
354
|
+
seenCapabilities.add(capability);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
const dimensions = Array.isArray(authz.scopeDimensions)
|
|
358
|
+
? authz.scopeDimensions
|
|
359
|
+
: [];
|
|
360
|
+
const policies = Array.isArray(authz.dataPolicies)
|
|
361
|
+
? authz.dataPolicies
|
|
362
|
+
: [];
|
|
363
|
+
for (const [name, values] of [
|
|
364
|
+
['scopeDimensions', authz.scopeDimensions],
|
|
365
|
+
['scopeSources', authz.scopeSources],
|
|
366
|
+
['dataPolicies', authz.dataPolicies],
|
|
367
|
+
]) {
|
|
368
|
+
if (values !== undefined && !Array.isArray(values)) {
|
|
369
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_COLLECTION_INVALID', `authz.${name} 必须是数组`, `authz.${name}`));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const dataResources = Array.isArray(data.resources) ? data.resources : [];
|
|
373
|
+
const resourceFieldTypes = new Map(dataResources.map(rawResource => {
|
|
374
|
+
const resource = object(rawResource);
|
|
375
|
+
const schema = object(resource.schema);
|
|
376
|
+
const fields = Array.isArray(schema.fields) ? schema.fields : [];
|
|
377
|
+
return [
|
|
378
|
+
string(resource.code),
|
|
379
|
+
new Map(fields.map(rawField => {
|
|
380
|
+
const field = object(rawField);
|
|
381
|
+
return [string(field.code), string(field.type)];
|
|
382
|
+
})),
|
|
383
|
+
];
|
|
384
|
+
}));
|
|
385
|
+
const resourceFields = new Map([...resourceFieldTypes].map(([resourceCode, fields]) => [
|
|
386
|
+
resourceCode,
|
|
387
|
+
new Set(fields.keys()),
|
|
388
|
+
]));
|
|
389
|
+
const dimensionCodes = new Set();
|
|
390
|
+
dimensions.forEach((raw, index) => {
|
|
391
|
+
const dimension = object(raw);
|
|
392
|
+
const code = string(dimension.code);
|
|
393
|
+
const dimensionPath = `authz.scopeDimensions[${index}]`;
|
|
394
|
+
if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(code) ||
|
|
395
|
+
dimensionCodes.has(code) ||
|
|
396
|
+
!string(dimension.name) ||
|
|
397
|
+
(dimension.valueType !== undefined &&
|
|
398
|
+
!['string', 'uuid'].includes(string(dimension.valueType))) ||
|
|
399
|
+
(dimension.hierarchyMode !== undefined &&
|
|
400
|
+
!['flat', 'self_parent'].includes(string(dimension.hierarchyMode)))) {
|
|
401
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DIMENSION_INVALID', 'scope dimension 必须声明唯一 code、name 和受支持的类型', dimensionPath));
|
|
402
|
+
}
|
|
403
|
+
if (dimension.valueSource !== undefined) {
|
|
404
|
+
const valueSource = object(dimension.valueSource);
|
|
405
|
+
const valueSourcePath = `${dimensionPath}.valueSource`;
|
|
406
|
+
const valueSourceKeys = Object.keys(valueSource);
|
|
407
|
+
const allowedKeys = new Set([
|
|
408
|
+
'kind',
|
|
409
|
+
'resourceCode',
|
|
410
|
+
'labelField',
|
|
411
|
+
'enabledField',
|
|
412
|
+
]);
|
|
413
|
+
const resourceCode = string(valueSource.resourceCode);
|
|
414
|
+
const labelField = string(valueSource.labelField);
|
|
415
|
+
const enabledField = valueSource.enabledField === undefined
|
|
416
|
+
? undefined
|
|
417
|
+
: string(valueSource.enabledField);
|
|
418
|
+
const declaredFieldTypes = resourceFieldTypes.get(resourceCode);
|
|
419
|
+
if (string(valueSource.kind) !== 'native_resource' ||
|
|
420
|
+
string(dimension.valueType) !== 'uuid' ||
|
|
421
|
+
!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(resourceCode) ||
|
|
422
|
+
!/^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(labelField) ||
|
|
423
|
+
(enabledField !== undefined &&
|
|
424
|
+
!/^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(enabledField)) ||
|
|
425
|
+
valueSourceKeys.some(key => !allowedKeys.has(key))) {
|
|
426
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DIMENSION_VALUE_SOURCE_INVALID', 'native_resource valueSource 必须声明 UUID 维度、资源、标签字段和可选启用字段', valueSourcePath));
|
|
427
|
+
}
|
|
428
|
+
if (!declaredFieldTypes) {
|
|
429
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DIMENSION_VALUE_SOURCE_RESOURCE_INVALID', 'native_resource valueSource 必须引用同一应用内已声明的 Data Resource', `${valueSourcePath}.resourceCode`));
|
|
430
|
+
}
|
|
431
|
+
else if (!['string', 'text'].includes(declaredFieldTypes.get(labelField) || '') ||
|
|
432
|
+
(enabledField !== undefined &&
|
|
433
|
+
declaredFieldTypes.get(enabledField) !== 'boolean')) {
|
|
434
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DIMENSION_VALUE_SOURCE_FIELD_INVALID', 'valueSource 标签字段必须为 string/text,启用字段必须为 boolean', valueSourcePath));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
dimensionCodes.add(code);
|
|
438
|
+
});
|
|
439
|
+
const scopeSources = Array.isArray(authz.scopeSources)
|
|
440
|
+
? authz.scopeSources
|
|
441
|
+
: [];
|
|
442
|
+
const sourceCodes = new Set();
|
|
443
|
+
scopeSources.forEach((raw, index) => {
|
|
444
|
+
const source = object(raw);
|
|
445
|
+
const sourcePath = `authz.scopeSources[${index}]`;
|
|
446
|
+
const code = string(source.code);
|
|
447
|
+
const subject = object(source.subject);
|
|
448
|
+
const grants = Array.isArray(source.grants) ? source.grants : [];
|
|
449
|
+
const subjectType = string(subject.type);
|
|
450
|
+
const roleCode = string(subject.roleCode);
|
|
451
|
+
const resourceCode = string(source.resourceCode);
|
|
452
|
+
const declaredFields = resourceFields.get(resourceCode);
|
|
453
|
+
if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(code) ||
|
|
454
|
+
sourceCodes.has(code) ||
|
|
455
|
+
!string(source.name) ||
|
|
456
|
+
!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(resourceCode) ||
|
|
457
|
+
!['user', 'role_membership'].includes(subjectType) ||
|
|
458
|
+
!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(subject.userIdField)) ||
|
|
459
|
+
(subjectType === 'role_membership' && !roleCodes.has(roleCode)) ||
|
|
460
|
+
(subjectType === 'user' && Boolean(roleCode)) ||
|
|
461
|
+
grants.length === 0 ||
|
|
462
|
+
!['strict', 'last_known_good'].includes(string(source.failureMode))) {
|
|
463
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_SCOPE_SOURCE_INVALID', 'scope source 必须声明唯一 code、Data Resource、主体映射、grant 映射与失败策略', sourcePath));
|
|
464
|
+
}
|
|
465
|
+
const grantDimensions = new Set();
|
|
466
|
+
grants.forEach((rawGrant, grantIndex) => {
|
|
467
|
+
const grant = object(rawGrant);
|
|
468
|
+
const dimensionCode = string(grant.dimensionCode);
|
|
469
|
+
if (!dimensionCodes.has(dimensionCode) ||
|
|
470
|
+
grantDimensions.has(dimensionCode) ||
|
|
471
|
+
!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(grant.valueField)) ||
|
|
472
|
+
(grant.parentValueField !== undefined &&
|
|
473
|
+
!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(grant.parentValueField)))) {
|
|
474
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_SCOPE_SOURCE_GRANT_INVALID', 'scope source grant 必须引用唯一已声明维度及有效字段', `${sourcePath}.grants[${grantIndex}]`));
|
|
475
|
+
}
|
|
476
|
+
grantDimensions.add(dimensionCode);
|
|
477
|
+
});
|
|
478
|
+
for (const field of [
|
|
479
|
+
'operationField',
|
|
480
|
+
'enabledField',
|
|
481
|
+
'effectiveFromField',
|
|
482
|
+
'effectiveToField',
|
|
483
|
+
]) {
|
|
484
|
+
if (source[field] !== undefined &&
|
|
485
|
+
!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(source[field]))) {
|
|
486
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_SCOPE_SOURCE_FIELD_INVALID', 'scope source 可选字段必须是有效 Data Resource 字段名', `${sourcePath}.${field}`));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const referencedFields = [
|
|
490
|
+
string(subject.userIdField),
|
|
491
|
+
...grants.flatMap(rawGrant => {
|
|
492
|
+
const grant = object(rawGrant);
|
|
493
|
+
return [
|
|
494
|
+
string(grant.valueField),
|
|
495
|
+
...(grant.parentValueField === undefined
|
|
496
|
+
? []
|
|
497
|
+
: [string(grant.parentValueField)]),
|
|
498
|
+
];
|
|
499
|
+
}),
|
|
500
|
+
...[
|
|
501
|
+
source.operationField,
|
|
502
|
+
source.enabledField,
|
|
503
|
+
source.effectiveFromField,
|
|
504
|
+
source.effectiveToField,
|
|
505
|
+
]
|
|
506
|
+
.filter(value => value !== undefined)
|
|
507
|
+
.map(string),
|
|
508
|
+
];
|
|
509
|
+
if (!declaredFields ||
|
|
510
|
+
referencedFields.some(field => !declaredFields.has(field))) {
|
|
511
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_SCOPE_SOURCE_RESOURCE_INVALID', 'scope source 必须引用已声明 Data Resource 及其真实字段', sourcePath));
|
|
512
|
+
}
|
|
513
|
+
sourceCodes.add(code);
|
|
514
|
+
});
|
|
515
|
+
const policyCodes = new Set();
|
|
516
|
+
policies.forEach((raw, index) => {
|
|
517
|
+
const policy = object(raw);
|
|
518
|
+
const code = string(policy.code);
|
|
519
|
+
const rules = Array.isArray(policy.rules) ? policy.rules : [];
|
|
520
|
+
if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(code) ||
|
|
521
|
+
policyCodes.has(code) ||
|
|
522
|
+
!string(policy.name) ||
|
|
523
|
+
!['AND', 'OR'].includes(string(policy.matchMode)) ||
|
|
524
|
+
rules.length === 0) {
|
|
525
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_INVALID', 'data policy 必须声明唯一 code、name、matchMode 和至少一条规则', `authz.dataPolicies[${index}]`));
|
|
526
|
+
}
|
|
527
|
+
policyCodes.add(code);
|
|
528
|
+
if (policy.unrestrictedRoleCodes !== undefined) {
|
|
529
|
+
const unrestrictedRoleCodes = Array.isArray(policy.unrestrictedRoleCodes)
|
|
530
|
+
? policy.unrestrictedRoleCodes
|
|
531
|
+
: [];
|
|
532
|
+
const seenUnrestrictedRoleCodes = new Set();
|
|
533
|
+
if (unrestrictedRoleCodes.length === 0) {
|
|
534
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_UNRESTRICTED_ROLES_INVALID', 'unrestrictedRoleCodes 必须是非空的已声明角色数组', `authz.dataPolicies[${index}].unrestrictedRoleCodes`));
|
|
535
|
+
}
|
|
536
|
+
unrestrictedRoleCodes.forEach((rawRoleCode, roleIndex) => {
|
|
537
|
+
const roleCode = string(rawRoleCode);
|
|
538
|
+
if (!roleCodes.has(roleCode) ||
|
|
539
|
+
seenUnrestrictedRoleCodes.has(roleCode)) {
|
|
540
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_UNRESTRICTED_ROLE_INVALID', 'unrestrictedRoleCodes 只能引用已声明的不重复角色', `authz.dataPolicies[${index}].unrestrictedRoleCodes[${roleIndex}]`));
|
|
541
|
+
}
|
|
542
|
+
seenUnrestrictedRoleCodes.add(roleCode);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
rules.forEach((rawRule, ruleIndex) => {
|
|
546
|
+
const rule = object(rawRule);
|
|
547
|
+
const rulePath = `authz.dataPolicies[${index}].rules[${ruleIndex}]`;
|
|
548
|
+
const subject = string(rule.subject);
|
|
549
|
+
const dimensionCode = string(rule.dimensionCode);
|
|
550
|
+
const relationCode = string(rule.relationCode);
|
|
551
|
+
const modes = [subject, dimensionCode, relationCode].filter(Boolean);
|
|
552
|
+
const validCurrentUser = subject === 'current_user';
|
|
553
|
+
const validDimension = Boolean(dimensionCode) && dimensionCodes.has(dimensionCode);
|
|
554
|
+
const validRelationship = Boolean(relationCode) && Boolean(string(rule.resourceCode));
|
|
555
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(rule.field)) ||
|
|
556
|
+
modes.length !== 1 ||
|
|
557
|
+
(!validCurrentUser && !validDimension && !validRelationship)) {
|
|
558
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_RULE_INVALID', '策略规则必须且只能声明 current_user、已声明 dimension 或 relationship 中的一种', rulePath));
|
|
559
|
+
}
|
|
560
|
+
if (rule.roleCodes !== undefined) {
|
|
561
|
+
const scopedRoleCodes = Array.isArray(rule.roleCodes)
|
|
562
|
+
? rule.roleCodes
|
|
563
|
+
: [];
|
|
564
|
+
const seenRuleRoleCodes = new Set();
|
|
565
|
+
if (scopedRoleCodes.length === 0) {
|
|
566
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_ROLE_CODES_INVALID', 'roleCodes 必须是非空的已声明角色数组', `${rulePath}.roleCodes`));
|
|
567
|
+
}
|
|
568
|
+
scopedRoleCodes.forEach((rawRoleCode, roleIndex) => {
|
|
569
|
+
const roleCode = string(rawRoleCode);
|
|
570
|
+
if (!roleCode ||
|
|
571
|
+
!roleCodes.has(roleCode) ||
|
|
572
|
+
seenRuleRoleCodes.has(roleCode)) {
|
|
573
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_ROLE_CODE_INVALID', '数据策略的 roleCodes 只能引用已声明的不重复角色', `${rulePath}.roleCodes[${roleIndex}]`));
|
|
574
|
+
}
|
|
575
|
+
seenRuleRoleCodes.add(roleCode);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
scopeSources.forEach((rawSource, sourceIndex) => {
|
|
581
|
+
const source = object(rawSource);
|
|
582
|
+
if (string(source.failureMode) !== 'last_known_good')
|
|
583
|
+
return;
|
|
584
|
+
const sourceDimensions = new Set((Array.isArray(source.grants) ? source.grants : []).map(rawGrant => string(object(rawGrant).dimensionCode)));
|
|
585
|
+
const matchingRules = policies.flatMap(rawPolicy => {
|
|
586
|
+
const rules = object(rawPolicy).rules;
|
|
587
|
+
return (Array.isArray(rules) ? rules : []).filter(rawRule => sourceDimensions.has(string(object(rawRule).dimensionCode)));
|
|
588
|
+
});
|
|
589
|
+
if (matchingRules.length === 0 ||
|
|
590
|
+
matchingRules.some(rawRule => string(object(rawRule).operation) !== 'read')) {
|
|
591
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_SCOPE_SOURCE_LAST_KNOWN_GOOD_UNSAFE', 'last_known_good 只能用于全部显式声明 operation=read 的低风险数据策略;其他场景使用 strict', `authz.scopeSources[${sourceIndex}].failureMode`));
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
dataResources.forEach((raw, index) => {
|
|
595
|
+
const policyCode = string(object(raw).dataPolicyCode);
|
|
596
|
+
if (policyCode && !policyCodes.has(policyCode)) {
|
|
597
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DATA_POLICY_NOT_FOUND', `DataResource 引用了未声明的数据策略: ${policyCode}`, `data.resources[${index}].dataPolicyCode`));
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
validateAuthorizationTransitions(authz.authorizationTransitions, roleCodes, diagnostics);
|
|
601
|
+
}
|
|
602
|
+
validateCapabilityClosure(appCode, config, declaredCapabilities, diagnostics);
|
|
603
|
+
const events = object(config.events);
|
|
604
|
+
if (config.events !== undefined && !Array.isArray(events.subscriptions)) {
|
|
605
|
+
diagnostics.push(diagnostic('APP_CONFIG_EVENT_SUBSCRIPTIONS_REQUIRED', 'events.subscriptions 必须是数组', 'events.subscriptions'));
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
(Array.isArray(events.subscriptions) ? events.subscriptions : []).forEach((item, index) => {
|
|
609
|
+
const subscription = object(item);
|
|
610
|
+
const path = `events.subscriptions[${index}]`;
|
|
611
|
+
if ('environmentKey' in subscription) {
|
|
612
|
+
diagnostics.push(diagnostic('APP_CONFIG_ENVIRONMENT_OVERRIDE_FORBIDDEN', '事件声明属于环境中立 AppVersion,不能声明 environmentKey', `${path}.environmentKey`));
|
|
613
|
+
}
|
|
614
|
+
if (!/^[a-z][a-z0-9-]*$/.test(string(subscription.code))) {
|
|
615
|
+
diagnostics.push(diagnostic('APP_CONFIG_EVENT_SUBSCRIPTION_CODE_INVALID', '事件订阅 code 必须是 kebab-case', `${path}.code`));
|
|
616
|
+
}
|
|
617
|
+
if (!Array.isArray(subscription.eventTypes) ||
|
|
618
|
+
subscription.eventTypes.length === 0) {
|
|
619
|
+
diagnostics.push(diagnostic('APP_CONFIG_EVENT_TYPES_REQUIRED', '事件订阅必须声明 eventTypes', `${path}.eventTypes`));
|
|
620
|
+
}
|
|
621
|
+
const endpointPath = string(subscription.endpointPath);
|
|
622
|
+
if (!endpointPath.startsWith('/') ||
|
|
623
|
+
endpointPath.startsWith('//') ||
|
|
624
|
+
endpointPath.includes('..')) {
|
|
625
|
+
diagnostics.push(diagnostic('APP_CONFIG_EVENT_ENDPOINT_INVALID', '事件订阅 endpointPath 必须是应用后端相对路径', `${path}.endpointPath`));
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
(Array.isArray(events.timers) ? events.timers : []).forEach((item, index) => {
|
|
630
|
+
const timer = object(item);
|
|
631
|
+
const path = `events.timers[${index}]`;
|
|
632
|
+
if ('environmentKey' in timer) {
|
|
633
|
+
diagnostics.push(diagnostic('APP_CONFIG_ENVIRONMENT_OVERRIDE_FORBIDDEN', '定时声明属于环境中立 AppVersion,不能声明 environmentKey', `${path}.environmentKey`));
|
|
634
|
+
}
|
|
635
|
+
if (!/^[a-z][a-z0-9-]*$/.test(string(timer.code))) {
|
|
636
|
+
diagnostics.push(diagnostic('APP_CONFIG_TIMER_CODE_INVALID', '定时订阅 code 必须是 kebab-case', `${path}.code`));
|
|
637
|
+
}
|
|
638
|
+
if (!string(timer.eventType) ||
|
|
639
|
+
!string(timer.cronExpression) ||
|
|
640
|
+
!string(timer.timezone)) {
|
|
641
|
+
diagnostics.push(diagnostic('APP_CONFIG_TIMER_SCHEDULE_REQUIRED', '定时订阅必须声明 eventType、六段 cronExpression 和 IANA timezone', path));
|
|
642
|
+
}
|
|
643
|
+
const cronExpression = string(timer.cronExpression);
|
|
644
|
+
const timezone = string(timer.timezone);
|
|
645
|
+
if (cronExpression && timezone) {
|
|
646
|
+
try {
|
|
647
|
+
const first = CronExpressionParser.parse(cronExpression, {
|
|
648
|
+
currentDate: '2026-01-01T00:00:00.000Z',
|
|
649
|
+
tz: timezone,
|
|
650
|
+
strict: true,
|
|
651
|
+
hashSeed: string(timer.code),
|
|
652
|
+
});
|
|
653
|
+
const firstDueAt = first.next().toDate();
|
|
654
|
+
const secondDueAt = first.next().toDate();
|
|
655
|
+
if (secondDueAt.getTime() - firstDueAt.getTime() < 60_000) {
|
|
656
|
+
diagnostics.push(diagnostic('APP_CONFIG_TIMER_FREQUENCY_TOO_HIGH', '定时订阅最短间隔为 60 秒', `${path}.cronExpression`));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
diagnostics.push(diagnostic('APP_CONFIG_TIMER_CRON_INVALID', `定时订阅必须使用明确的六段 Cron 与有效 IANA 时区:${error instanceof Error ? error.message : String(error)}`, cronExpression ? `${path}.cronExpression` : `${path}.timezone`));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (timer.payload === null ||
|
|
664
|
+
typeof timer.payload !== 'object' ||
|
|
665
|
+
Array.isArray(timer.payload)) {
|
|
666
|
+
diagnostics.push(diagnostic('APP_CONFIG_TIMER_PAYLOAD_INVALID', '定时事件 payload 必须是对象', `${path}.payload`));
|
|
667
|
+
}
|
|
668
|
+
else if (Buffer.byteLength(JSON.stringify(timer.payload || {}), 'utf8') > 64 * 1024) {
|
|
669
|
+
diagnostics.push(diagnostic('APP_CONFIG_TIMER_PAYLOAD_TOO_LARGE', '定时事件 payload 不能超过 64 KiB', `${path}.payload`));
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
const workflows = object(config.workflows);
|
|
673
|
+
if (config.workflows !== undefined &&
|
|
674
|
+
(!Array.isArray(workflows.definitions) ||
|
|
675
|
+
!Array.isArray(workflows.bindings) ||
|
|
676
|
+
!Array.isArray(workflows.activations))) {
|
|
677
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DECLARATIONS_REQUIRED', 'workflows 必须同时声明 definitions、bindings 和 activations 数组', 'workflows'));
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
const definitions = Array.isArray(workflows.definitions)
|
|
681
|
+
? workflows.definitions
|
|
682
|
+
: [];
|
|
683
|
+
const bindings = Array.isArray(workflows.bindings) ? workflows.bindings : [];
|
|
684
|
+
const activations = Array.isArray(workflows.activations)
|
|
685
|
+
? workflows.activations
|
|
686
|
+
: [];
|
|
687
|
+
const providers = Array.isArray(workflows.providers)
|
|
688
|
+
? workflows.providers
|
|
689
|
+
: [];
|
|
690
|
+
const editableParameters = Array.isArray(workflows.editableParameters)
|
|
691
|
+
? workflows.editableParameters
|
|
692
|
+
: [];
|
|
693
|
+
const definitionByKey = new Map();
|
|
694
|
+
const bindingByKey = new Map();
|
|
695
|
+
const providerKeys = new Set();
|
|
696
|
+
providers.forEach((item, index) => {
|
|
697
|
+
const provider = object(item);
|
|
698
|
+
const path = `workflows.providers[${index}]`;
|
|
699
|
+
const code = string(provider.code);
|
|
700
|
+
const endpointPath = string(provider.endpointPath);
|
|
701
|
+
const key = code;
|
|
702
|
+
if ('environmentKey' in provider) {
|
|
703
|
+
diagnostics.push(diagnostic('APP_CONFIG_ENVIRONMENT_OVERRIDE_FORBIDDEN', 'Workflow Provider 属于环境中立 AppVersion,不能声明 environmentKey', `${path}.environmentKey`));
|
|
704
|
+
}
|
|
705
|
+
if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(code) ||
|
|
706
|
+
!/^\/[A-Za-z0-9/_-]+$/.test(endpointPath) ||
|
|
707
|
+
endpointPath.split('/').includes('..')) {
|
|
708
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_PROVIDER_INVALID', 'App Provider 必须声明合法 code 和应用内 endpointPath', path));
|
|
709
|
+
}
|
|
710
|
+
if (providerKeys.has(key)) {
|
|
711
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_PROVIDER_DUPLICATE', `App Provider 重复: ${key}`, path));
|
|
712
|
+
}
|
|
713
|
+
providerKeys.add(key);
|
|
714
|
+
});
|
|
715
|
+
const editableParameterCodes = new Set();
|
|
716
|
+
editableParameters.forEach((item, index) => {
|
|
717
|
+
const parameter = object(item);
|
|
718
|
+
const code = string(parameter.code);
|
|
719
|
+
const path = `workflows.editableParameters[${index}]`;
|
|
720
|
+
const allowedRoleCodes = Array.isArray(parameter.allowedRoleCodes)
|
|
721
|
+
? parameter.allowedRoleCodes.map(string)
|
|
722
|
+
: [];
|
|
723
|
+
const allowedProviderCodes = Array.isArray(parameter.allowedProviderCodes)
|
|
724
|
+
? parameter.allowedProviderCodes.map(string)
|
|
725
|
+
: [];
|
|
726
|
+
if (!OPERATION_CODE_PATTERN.test(code) ||
|
|
727
|
+
editableParameterCodes.has(code) ||
|
|
728
|
+
!string(parameter.workflowCode) ||
|
|
729
|
+
!string(parameter.bindingKey) ||
|
|
730
|
+
!string(parameter.label) ||
|
|
731
|
+
!['role_code', 'user_ids', 'provider_code'].includes(string(parameter.valueType)) ||
|
|
732
|
+
new Set(allowedRoleCodes).size !== allowedRoleCodes.length ||
|
|
733
|
+
new Set(allowedProviderCodes).size !== allowedProviderCodes.length) {
|
|
734
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_EDITABLE_PARAMETER_INVALID', 'Workflow editable parameter 必须声明唯一 code、workflow/binding、label 和受支持的 valueType', path));
|
|
735
|
+
}
|
|
736
|
+
editableParameterCodes.add(code);
|
|
737
|
+
});
|
|
738
|
+
definitions.forEach((item, index) => {
|
|
739
|
+
const declaration = object(item);
|
|
740
|
+
const definition = object(declaration.definition);
|
|
741
|
+
const version = Number(declaration.version);
|
|
742
|
+
const path = `workflows.definitions[${index}]`;
|
|
743
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
744
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_VERSION_INVALID', 'Workflow definition version 必须是正整数', `${path}.version`));
|
|
745
|
+
}
|
|
746
|
+
for (const error of validateWorkflowDefinition(definition)) {
|
|
747
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DEFINITION_INVALID', error, path));
|
|
748
|
+
}
|
|
749
|
+
const key = `${definition.code}:${version}`;
|
|
750
|
+
if (definitionByKey.has(key)) {
|
|
751
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DEFINITION_DUPLICATE', `Workflow definition 重复: ${key}`, path));
|
|
752
|
+
}
|
|
753
|
+
definitionByKey.set(key, definition);
|
|
754
|
+
});
|
|
755
|
+
bindings.forEach((item, index) => {
|
|
756
|
+
const declaration = object(item);
|
|
757
|
+
const binding = object(declaration.binding);
|
|
758
|
+
const version = Number(declaration.version);
|
|
759
|
+
const path = `workflows.bindings[${index}]`;
|
|
760
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
761
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_VERSION_INVALID', 'Workflow binding version 必须是正整数', `${path}.version`));
|
|
762
|
+
}
|
|
763
|
+
const key = `${binding.workflowCode}:${version}`;
|
|
764
|
+
if (bindingByKey.has(key)) {
|
|
765
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_BINDING_DUPLICATE', `Workflow binding 重复: ${key}`, path));
|
|
766
|
+
}
|
|
767
|
+
bindingByKey.set(key, binding);
|
|
768
|
+
});
|
|
769
|
+
activations.forEach((item, index) => {
|
|
770
|
+
const activation = object(item);
|
|
771
|
+
const path = `workflows.activations[${index}]`;
|
|
772
|
+
const workflowCode = string(activation.workflowCode);
|
|
773
|
+
if ('environmentKey' in activation) {
|
|
774
|
+
diagnostics.push(diagnostic('APP_CONFIG_ENVIRONMENT_OVERRIDE_FORBIDDEN', 'Workflow activation 属于环境中立 AppVersion,不能声明 environmentKey', `${path}.environmentKey`));
|
|
775
|
+
}
|
|
776
|
+
const definitionVersion = Number(activation.definitionVersion);
|
|
777
|
+
const bindingVersion = Number(activation.bindingVersion);
|
|
778
|
+
const definition = definitionByKey.get(`${workflowCode}:${definitionVersion}`);
|
|
779
|
+
const binding = bindingByKey.get(`${workflowCode}:${bindingVersion}`);
|
|
780
|
+
if (!definition || !binding) {
|
|
781
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_ACTIVATION_VERSION_NOT_FOUND', 'Workflow activation 引用的 definition 或 binding 版本不存在', path));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
for (const error of validateWorkflowBinding(definition, binding)) {
|
|
785
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_BINDING_INVALID', error, path));
|
|
786
|
+
}
|
|
787
|
+
for (const entry of Object.values(binding.bindings || {})) {
|
|
788
|
+
if (entry.provider === 'application_provider' &&
|
|
789
|
+
!providerKeys.has(entry.providerCode || '')) {
|
|
790
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_PROVIDER_NOT_FOUND', `Workflow binding 引用了未声明的 App Provider: ${entry.providerCode || ''}`, path));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
editableParameters.forEach((item, index) => {
|
|
795
|
+
const parameter = object(item);
|
|
796
|
+
const workflowCode = string(parameter.workflowCode);
|
|
797
|
+
const bindingKey = string(parameter.bindingKey);
|
|
798
|
+
const active = activations.find(item => string(object(item).workflowCode) === workflowCode);
|
|
799
|
+
const binding = active
|
|
800
|
+
? bindingByKey.get(`${workflowCode}:${Number(object(active).bindingVersion)}`)
|
|
801
|
+
: undefined;
|
|
802
|
+
if (!binding || !binding.bindings[bindingKey]) {
|
|
803
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_EDITABLE_PARAMETER_BINDING_NOT_FOUND', 'Workflow editable parameter 必须引用活动版本中的 binding key', `workflows.editableParameters[${index}]`));
|
|
804
|
+
}
|
|
805
|
+
for (const roleCode of Array.isArray(parameter.allowedRoleCodes)
|
|
806
|
+
? parameter.allowedRoleCodes.map(string)
|
|
807
|
+
: []) {
|
|
808
|
+
if (!new Set((Array.isArray(authz.roles) ? authz.roles : []).map(item => string(object(item).code))).has(roleCode)) {
|
|
809
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_EDITABLE_PARAMETER_ROLE_NOT_FOUND', `editable parameter 引用了未声明 role: ${roleCode}`, `workflows.editableParameters[${index}].allowedRoleCodes`));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const providerCode of Array.isArray(parameter.allowedProviderCodes)
|
|
813
|
+
? parameter.allowedProviderCodes.map(string)
|
|
814
|
+
: []) {
|
|
815
|
+
if (!providerKeys.has(providerCode)) {
|
|
816
|
+
diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_EDITABLE_PARAMETER_PROVIDER_NOT_FOUND', `editable parameter 引用了未声明 provider: ${providerCode}`, `workflows.editableParameters[${index}].allowedProviderCodes`));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
return diagnostics;
|
|
822
|
+
}
|
|
823
|
+
const CAPABILITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._*-]{0,254}$/;
|
|
824
|
+
const OPERATION_CODE_PATTERN = /^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/;
|
|
825
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
826
|
+
const RESERVED_NATIVE_KEYS = new Map([
|
|
827
|
+
['environmentKey', 'environmentKey'],
|
|
828
|
+
['environmentId', 'environmentId'],
|
|
829
|
+
['secretValue', 'Secret value'],
|
|
830
|
+
['ciphertext', 'Secret ciphertext'],
|
|
831
|
+
['oauthClient', 'OAuth client'],
|
|
832
|
+
['oauthClientId', 'OAuth client'],
|
|
833
|
+
['oauthClientSecret', 'OAuth client Secret'],
|
|
834
|
+
['signingSecret', 'signing Secret'],
|
|
835
|
+
['kubernetesNamespace', 'Kubernetes namespace'],
|
|
836
|
+
['nodePort', 'Kubernetes NodePort'],
|
|
837
|
+
['productionUrl', 'production URL'],
|
|
838
|
+
]);
|
|
839
|
+
function rejectNativeEnvironmentFields(value, diagnostics, path = '$', seen = new Set()) {
|
|
840
|
+
if (!value || typeof value !== 'object')
|
|
841
|
+
return;
|
|
842
|
+
if (seen.has(value))
|
|
843
|
+
return;
|
|
844
|
+
seen.add(value);
|
|
845
|
+
if (Array.isArray(value)) {
|
|
846
|
+
value.forEach((item, index) => rejectNativeEnvironmentFields(item, diagnostics, `${path}[${index}]`, seen));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
for (const [key, child] of Object.entries(value)) {
|
|
850
|
+
const reserved = RESERVED_NATIVE_KEYS.get(key);
|
|
851
|
+
if (reserved) {
|
|
852
|
+
diagnostics.push(diagnostic('APP_CONFIG_NATIVE_RUNTIME_FIELD_FORBIDDEN', `${reserved} 属于环境运行态,不能进入环境中立 AppVersion`, path === '$' ? key : `${path}.${key}`));
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
rejectNativeEnvironmentFields(child, diagnostics, path === '$' ? key : `${path}.${key}`, seen);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
function validateCapabilityDeclarations(appCode, declarations, diagnostics) {
|
|
859
|
+
const seen = new Set();
|
|
860
|
+
declarations.forEach((raw, index) => {
|
|
861
|
+
const capability = object(raw);
|
|
862
|
+
const code = string(capability.code);
|
|
863
|
+
const path = `authz.capabilities[${index}]`;
|
|
864
|
+
if (!CAPABILITY_PATTERN.test(code) ||
|
|
865
|
+
!code.startsWith(`app:${appCode}:`) ||
|
|
866
|
+
seen.has(code) ||
|
|
867
|
+
!['backend', 'ui'].includes(string(capability.kind)) ||
|
|
868
|
+
!string(capability.name)) {
|
|
869
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_CAPABILITY_DECLARATION_INVALID', '显式 capability 必须属于当前应用、code 唯一,并声明 backend/ui kind 与 name', path));
|
|
870
|
+
}
|
|
871
|
+
if (platformCapabilityCodes(appCode).has(code)) {
|
|
872
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_PLATFORM_CAPABILITY_RESERVED', '平台 capability 由平台保留 catalog 提供,应用不能重复声明', `${path}.code`));
|
|
873
|
+
}
|
|
874
|
+
seen.add(code);
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
function validateBackendOperations(rawOperations, diagnostics) {
|
|
878
|
+
if (rawOperations !== undefined && !Array.isArray(rawOperations)) {
|
|
879
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_OPERATIONS_INVALID', 'backend.operations 必须是数组', 'backend.operations'));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const codes = new Set();
|
|
883
|
+
const routes = new Set();
|
|
884
|
+
(Array.isArray(rawOperations) ? rawOperations : []).forEach((raw, index) => {
|
|
885
|
+
const operation = object(raw);
|
|
886
|
+
const code = string(operation.code);
|
|
887
|
+
const method = string(operation.method).toUpperCase();
|
|
888
|
+
const route = string(operation.path);
|
|
889
|
+
const path = `backend.operations[${index}]`;
|
|
890
|
+
const routeKey = `${method} ${route}`;
|
|
891
|
+
if (!OPERATION_CODE_PATTERN.test(code) ||
|
|
892
|
+
codes.has(code) ||
|
|
893
|
+
!HTTP_METHODS.has(method) ||
|
|
894
|
+
!validAppPath(route) ||
|
|
895
|
+
route.startsWith('/__platform/') ||
|
|
896
|
+
routes.has(routeKey) ||
|
|
897
|
+
!CAPABILITY_PATTERN.test(string(operation.capability)) ||
|
|
898
|
+
!isRecord(operation.requestSchema) ||
|
|
899
|
+
!isRecord(operation.responseSchema)) {
|
|
900
|
+
diagnostics.push(diagnostic('APP_CONFIG_BACKEND_OPERATION_INVALID', 'App API operation 必须声明唯一 code/route、静态 HTTP path、capability 与请求响应 JSON Schema', path));
|
|
901
|
+
}
|
|
902
|
+
codes.add(code);
|
|
903
|
+
routes.add(routeKey);
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
function validateFrontendRoutes(rawRoutes, diagnostics) {
|
|
907
|
+
if (rawRoutes !== undefined && !Array.isArray(rawRoutes)) {
|
|
908
|
+
diagnostics.push(diagnostic('APP_CONFIG_FRONTEND_ROUTES_INVALID', 'frontend.routes 必须是数组', 'frontend.routes'));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const routes = Array.isArray(rawRoutes) ? rawRoutes : [];
|
|
912
|
+
const codes = new Set();
|
|
913
|
+
const paths = new Set();
|
|
914
|
+
routes.forEach((raw, index) => {
|
|
915
|
+
const route = object(raw);
|
|
916
|
+
const code = string(route.code);
|
|
917
|
+
const routePath = string(route.path);
|
|
918
|
+
const path = `frontend.routes[${index}]`;
|
|
919
|
+
const access = object(route.access);
|
|
920
|
+
const allowedKeys = new Set([
|
|
921
|
+
'code',
|
|
922
|
+
'path',
|
|
923
|
+
'label',
|
|
924
|
+
'surface',
|
|
925
|
+
'navigation',
|
|
926
|
+
'parentCode',
|
|
927
|
+
'capability',
|
|
928
|
+
'access',
|
|
929
|
+
'pinned',
|
|
930
|
+
'tabPersistence',
|
|
931
|
+
'keepAlive',
|
|
932
|
+
]);
|
|
933
|
+
const accessKeys = Object.keys(access);
|
|
934
|
+
const allOf = Array.isArray(access.allOf) ? access.allOf : [];
|
|
935
|
+
const anyOf = Array.isArray(access.anyOf) ? access.anyOf : [];
|
|
936
|
+
const accessInvalid = route.access !== undefined &&
|
|
937
|
+
(route.capability !== undefined ||
|
|
938
|
+
!route.access ||
|
|
939
|
+
typeof route.access !== 'object' ||
|
|
940
|
+
Array.isArray(route.access) ||
|
|
941
|
+
accessKeys.some(key => !['allOf', 'anyOf'].includes(key)) ||
|
|
942
|
+
(allOf.length === 0 && anyOf.length === 0) ||
|
|
943
|
+
allOf.length > 50 ||
|
|
944
|
+
anyOf.length > 50 ||
|
|
945
|
+
allOf.some(value => !CAPABILITY_PATTERN.test(string(value))) ||
|
|
946
|
+
anyOf.some(value => !CAPABILITY_PATTERN.test(string(value))) ||
|
|
947
|
+
new Set(allOf.map(string)).size !== allOf.length ||
|
|
948
|
+
new Set(anyOf.map(string)).size !== anyOf.length);
|
|
949
|
+
const dynamicRoute = /[:*]/.test(routePath);
|
|
950
|
+
const routePolicyInvalid = Object.keys(route).some(key => !allowedKeys.has(key)) ||
|
|
951
|
+
(route.pinned !== undefined && typeof route.pinned !== 'boolean') ||
|
|
952
|
+
(route.tabPersistence !== undefined &&
|
|
953
|
+
!['session', 'none'].includes(string(route.tabPersistence))) ||
|
|
954
|
+
(route.keepAlive !== undefined &&
|
|
955
|
+
!['none', 'memory'].includes(string(route.keepAlive))) ||
|
|
956
|
+
(dynamicRoute && route.pinned === true) ||
|
|
957
|
+
(dynamicRoute && route.tabPersistence === 'session') ||
|
|
958
|
+
(dynamicRoute && route.keepAlive === 'memory');
|
|
959
|
+
if (!OPERATION_CODE_PATTERN.test(code) ||
|
|
960
|
+
codes.has(code) ||
|
|
961
|
+
!validAppPath(routePath) ||
|
|
962
|
+
paths.has(routePath) ||
|
|
963
|
+
!string(route.label) ||
|
|
964
|
+
!['admin', 'user'].includes(string(route.surface)) ||
|
|
965
|
+
(route.navigation !== undefined &&
|
|
966
|
+
!['menu', 'hidden'].includes(string(route.navigation))) ||
|
|
967
|
+
(route.capability !== undefined &&
|
|
968
|
+
!CAPABILITY_PATTERN.test(string(route.capability))) ||
|
|
969
|
+
accessInvalid ||
|
|
970
|
+
routePolicyInvalid) {
|
|
971
|
+
diagnostics.push(diagnostic('APP_CONFIG_FRONTEND_ROUTE_INVALID', '前端 route 必须声明唯一 code/path、label、surface、有效访问表达式和有界标签策略;动态 route 不能固定、恢复或保活', path));
|
|
972
|
+
}
|
|
973
|
+
codes.add(code);
|
|
974
|
+
paths.add(routePath);
|
|
975
|
+
});
|
|
976
|
+
const parentByCode = new Map();
|
|
977
|
+
routes.forEach((raw, index) => {
|
|
978
|
+
const route = object(raw);
|
|
979
|
+
const code = string(route.code);
|
|
980
|
+
const parentCode = string(route.parentCode);
|
|
981
|
+
if (parentCode) {
|
|
982
|
+
parentByCode.set(code, parentCode);
|
|
983
|
+
if (!codes.has(parentCode) || parentCode === code) {
|
|
984
|
+
diagnostics.push(diagnostic('APP_CONFIG_FRONTEND_ROUTE_PARENT_INVALID', 'parentCode 必须引用另一个已声明 route', `frontend.routes[${index}].parentCode`));
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
for (const code of codes) {
|
|
989
|
+
const visited = new Set();
|
|
990
|
+
let current = code;
|
|
991
|
+
while (current && parentByCode.has(current)) {
|
|
992
|
+
if (visited.has(current)) {
|
|
993
|
+
diagnostics.push(diagnostic('APP_CONFIG_FRONTEND_ROUTE_CYCLE', `route parentCode 形成循环: ${code}`, 'frontend.routes'));
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
visited.add(current);
|
|
997
|
+
current = parentByCode.get(current);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function validateAuthorizationTransitions(rawTransitions, _roleCodes, diagnostics) {
|
|
1002
|
+
if (rawTransitions !== undefined && !Array.isArray(rawTransitions)) {
|
|
1003
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_TRANSITIONS_INVALID', 'authz.authorizationTransitions 必须是数组', 'authz.authorizationTransitions'));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const digests = new Set();
|
|
1007
|
+
(Array.isArray(rawTransitions) ? rawTransitions : []).forEach((raw, index) => {
|
|
1008
|
+
const transition = object(raw);
|
|
1009
|
+
const digest = string(transition.fromAuthzDigest);
|
|
1010
|
+
const path = `authz.authorizationTransitions[${index}]`;
|
|
1011
|
+
const removeRoles = Array.isArray(transition.removeRoleCodes)
|
|
1012
|
+
? transition.removeRoleCodes.map(string)
|
|
1013
|
+
: [];
|
|
1014
|
+
const removeCapabilities = Array.isArray(transition.removeCapabilityCodes)
|
|
1015
|
+
? transition.removeCapabilityCodes.map(string)
|
|
1016
|
+
: [];
|
|
1017
|
+
if (!/^[0-9a-f]{64}$/.test(digest) ||
|
|
1018
|
+
digests.has(digest) ||
|
|
1019
|
+
!string(transition.reason) ||
|
|
1020
|
+
new Set(removeRoles).size !== removeRoles.length ||
|
|
1021
|
+
new Set(removeCapabilities).size !== removeCapabilities.length) {
|
|
1022
|
+
diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_TRANSITION_INVALID', '授权 transition 必须按来源摘要唯一,并明确不重复的移除项和 reason', path));
|
|
1023
|
+
}
|
|
1024
|
+
digests.add(digest);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
function validateCapabilityClosure(appCode, config, declarations, diagnostics) {
|
|
1028
|
+
const catalog = new Set(platformCapabilityCodes(appCode));
|
|
1029
|
+
for (const raw of declarations)
|
|
1030
|
+
catalog.add(string(object(raw).code));
|
|
1031
|
+
const data = object(config.data);
|
|
1032
|
+
const catalogOwners = new Map();
|
|
1033
|
+
for (const raw of declarations) {
|
|
1034
|
+
const code = string(object(raw).code);
|
|
1035
|
+
if (code)
|
|
1036
|
+
catalogOwners.set(code, 'authz.capabilities');
|
|
1037
|
+
}
|
|
1038
|
+
for (const rawResource of Array.isArray(data.resources) ? data.resources : []) {
|
|
1039
|
+
const resource = object(rawResource);
|
|
1040
|
+
const resourceOwner = `data.resources.${string(resource.code)}`;
|
|
1041
|
+
Object.values(object(resource.capabilities)).forEach(value => {
|
|
1042
|
+
const code = string(value);
|
|
1043
|
+
registerCapabilityOwner(code, resourceOwner, catalogOwners, diagnostics);
|
|
1044
|
+
catalog.add(code);
|
|
1045
|
+
});
|
|
1046
|
+
Object.values(object(resource.fieldPolicies)).forEach(rawPolicy => {
|
|
1047
|
+
const policy = object(rawPolicy);
|
|
1048
|
+
for (const key of ['read', 'create', 'update', 'write']) {
|
|
1049
|
+
for (const value of Array.isArray(policy[key]) ? policy[key] : []) {
|
|
1050
|
+
const code = string(value);
|
|
1051
|
+
registerCapabilityOwner(code, resourceOwner, catalogOwners, diagnostics);
|
|
1052
|
+
catalog.add(code);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
const references = [];
|
|
1058
|
+
const authz = object(config.authz);
|
|
1059
|
+
(Array.isArray(authz.roles) ? authz.roles : []).forEach((rawRole, index) => {
|
|
1060
|
+
const role = object(rawRole);
|
|
1061
|
+
(Array.isArray(role.capabilities) ? role.capabilities : []).forEach((value, capabilityIndex) => references.push({
|
|
1062
|
+
capability: string(value),
|
|
1063
|
+
path: `authz.roles[${index}].capabilities[${capabilityIndex}]`,
|
|
1064
|
+
}));
|
|
1065
|
+
});
|
|
1066
|
+
const backend = object(config.backend);
|
|
1067
|
+
(Array.isArray(backend.operations) ? backend.operations : []).forEach((rawOperation, index) => references.push({
|
|
1068
|
+
capability: string(object(rawOperation).capability),
|
|
1069
|
+
path: `backend.operations[${index}].capability`,
|
|
1070
|
+
}));
|
|
1071
|
+
const frontend = object(config.frontend);
|
|
1072
|
+
(Array.isArray(frontend.routes) ? frontend.routes : []).forEach((rawRoute, index) => {
|
|
1073
|
+
const route = object(rawRoute);
|
|
1074
|
+
const capability = string(route.capability);
|
|
1075
|
+
if (capability) {
|
|
1076
|
+
references.push({
|
|
1077
|
+
capability,
|
|
1078
|
+
path: `frontend.routes[${index}].capability`,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
const access = object(route.access);
|
|
1082
|
+
for (const key of ['allOf', 'anyOf']) {
|
|
1083
|
+
const values = Array.isArray(access[key]) ? access[key] : [];
|
|
1084
|
+
values.forEach((value, capabilityIndex) => references.push({
|
|
1085
|
+
capability: string(value),
|
|
1086
|
+
path: `frontend.routes[${index}].access.${key}[${capabilityIndex}]`,
|
|
1087
|
+
}));
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
for (const reference of references) {
|
|
1091
|
+
if (!catalog.has(reference.capability) ||
|
|
1092
|
+
(!reference.capability.startsWith(`app:${appCode}:`) &&
|
|
1093
|
+
!platformCapabilityCodes(appCode).has(reference.capability))) {
|
|
1094
|
+
diagnostics.push(diagnostic('APP_CONFIG_CAPABILITY_CLOSURE_FAILED', `引用了未声明、跨应用或非平台保留 capability: ${reference.capability}`, reference.path));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
function registerCapabilityOwner(code, owner, owners, diagnostics) {
|
|
1099
|
+
if (!code)
|
|
1100
|
+
return;
|
|
1101
|
+
const existing = owners.get(code);
|
|
1102
|
+
if (existing && existing !== owner) {
|
|
1103
|
+
diagnostics.push(diagnostic('APP_CONFIG_CAPABILITY_OWNER_CONFLICT', `capability 只能由一个 catalog owner 定义: ${code}`, owner));
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
owners.set(code, owner);
|
|
1107
|
+
}
|
|
1108
|
+
function platformCapabilityCodes(appCode) {
|
|
1109
|
+
return new Set(nativePlatformCapabilityCatalog(appCode).map(item => item.code));
|
|
1110
|
+
}
|
|
1111
|
+
function validAppPath(value) {
|
|
1112
|
+
return (value.startsWith('/') &&
|
|
1113
|
+
!value.startsWith('//') &&
|
|
1114
|
+
!value.includes('..') &&
|
|
1115
|
+
!value.includes('?') &&
|
|
1116
|
+
!value.includes('#') &&
|
|
1117
|
+
value.length <= 512);
|
|
1118
|
+
}
|
|
1119
|
+
function isRecord(value) {
|
|
1120
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
1121
|
+
}
|
|
1122
|
+
export class AppConfigValidationError extends Error {
|
|
1123
|
+
diagnostics;
|
|
1124
|
+
code = 'OPENXIANGDA_APP_CONFIG_INVALID';
|
|
1125
|
+
constructor(diagnostics) {
|
|
1126
|
+
super(diagnostics.map(item => `${item.path}: ${item.message}`).join('; '));
|
|
1127
|
+
this.diagnostics = diagnostics;
|
|
1128
|
+
this.name = 'AppConfigValidationError';
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
export function defineOpenXiangdaApp(config) {
|
|
1132
|
+
const diagnostics = validateAppConfig(config);
|
|
1133
|
+
if (diagnostics.length > 0)
|
|
1134
|
+
throw new AppConfigValidationError(diagnostics);
|
|
1135
|
+
return Object.freeze(config);
|
|
1136
|
+
}
|
|
1137
|
+
//# sourceMappingURL=config.js.map
|