openxiangda-devkit-core 2.19.0 → 2.20.0

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.
@@ -22,6 +22,7 @@ const DATE_TRIGGER_RESERVED_DATA_FIELDS = new Set([
22
22
  'dueAt',
23
23
  ]);
24
24
  const APP_DATA_FIELD_TYPES = new Set(DATA_FIELD_TYPES);
25
+ const WORKFLOW_RUNTIME_CODE_PATTERN = /^[a-z][a-z0-9-]{2,63}$/;
25
26
  function adminNavigationItem(page, options = {}) {
26
27
  return Object.freeze({ page: Object.freeze(page), ...options });
27
28
  }
@@ -448,6 +449,11 @@ export const openXiangdaAppConfigSchema = {
448
449
  required: ['resources'],
449
450
  properties: {
450
451
  resources: { type: 'array', maxItems: 100, items: { type: 'object' } },
452
+ subjectReadSurfaces: {
453
+ type: 'array',
454
+ maxItems: 50,
455
+ items: { type: 'object' },
456
+ },
451
457
  },
452
458
  },
453
459
  authz: {
@@ -878,11 +884,36 @@ export function validateAppConfig(value) {
878
884
  })),
879
885
  ];
880
886
  }));
887
+ const declaredResourceFields = new Map((Array.isArray(object(config.data).resources)
888
+ ? object(config.data).resources
889
+ : []).map(resource => {
890
+ const item = object(resource);
891
+ const schema = object(item.schema);
892
+ const fields = Array.isArray(schema.fields) ? schema.fields : [];
893
+ return [
894
+ string(item.code),
895
+ new Map(fields.map(field => {
896
+ const declaration = object(field);
897
+ return [string(declaration.code), declaration];
898
+ })),
899
+ ];
900
+ }));
881
901
  const declaredResourceCodes = new Set(declaredResources.keys());
902
+ validateSubjectReadSurfaces(string(object(config.app).code), object(config.data).subjectReadSurfaces, object(config.data).resources, new Set((Array.isArray(object(config.authz).capabilities)
903
+ ? object(config.authz).capabilities
904
+ : []).map(item => string(object(item).code))), diagnostics);
882
905
  const declaredWorkflowCodes = new Set((Array.isArray(object(config.workflows).activations)
883
906
  ? object(config.workflows).activations
884
907
  : []).map(item => string(object(item).workflowCode)));
885
- validateBackendOperations(backend.operations, declaredResources, declaredWorkflowCodes, new Set((Array.isArray(object(config.authz).roles) ? object(config.authz).roles : []).map(role => string(object(role).code))), diagnostics);
908
+ validateBackendOperations(backend.operations, declaredResources, declaredResourceFields, declaredWorkflowCodes, new Set((Array.isArray(object(config.authz).roles) ? object(config.authz).roles : []).map(role => string(object(role).code))), new Map((Array.isArray(object(config.data).subjectReadSurfaces)
909
+ ? object(config.data).subjectReadSurfaces
910
+ : []).map(surface => {
911
+ const declaration = object(surface);
912
+ return [
913
+ string(declaration.code),
914
+ string(object(declaration.subject).resourceCode),
915
+ ];
916
+ })), diagnostics);
886
917
  const eventConfig = object(config.events);
887
918
  const workflowConfig = object(config.workflows);
888
919
  if (backend.enabled === false &&
@@ -2244,7 +2275,13 @@ export function validateAppConfig(value) {
2244
2275
  string(object(declaration.definition).code)) {
2245
2276
  diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_USER_TITLE_REQUIRED', 'Workflow title 必须是用户可读本地化名称,不能等于内部 code', `${path}.definition.title`));
2246
2277
  }
2278
+ if (!WORKFLOW_RUNTIME_CODE_PATTERN.test(string(definition.code))) {
2279
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_CODE_INVALID', 'Workflow code 必须匹配 /^[a-z][a-z0-9-]{2,63}$/,不能包含下划线或大写字母', `${path}.definition.code`));
2280
+ }
2247
2281
  const subject = object(definition.subject);
2282
+ if (!WORKFLOW_RUNTIME_CODE_PATTERN.test(string(subject.resourceCode))) {
2283
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_SUBJECT_RESOURCE_CODE_INVALID', 'Workflow subject.resourceCode 必须匹配 /^[a-z][a-z0-9-]{2,63}$/', `${path}.definition.subject.resourceCode`));
2284
+ }
2248
2285
  const factProjection = object(subject.factProjection);
2249
2286
  const factEntries = Object.entries(factProjection);
2250
2287
  const subjectFields = eventResourceFields.get(string(subject.resourceCode));
@@ -2283,9 +2320,8 @@ export function validateAppConfig(value) {
2283
2320
  if (summaryFieldsInvalid) {
2284
2321
  diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_SUMMARY_FIELDS_INVALID', `Workflow summaryFields 最多 ${WORKFLOW_SUMMARY_MAX_FIELDS} 个,必须是不重复的已声明 scalar/reference 字段;禁止 system/rich/json/subtable/file/image/signature,且 text.long 不超过 ${WORKFLOW_SUMMARY_TEXT_LONG_MAX_BYTES} bytes`, `${path}.definition.subject.summaryFields`));
2285
2322
  }
2286
- // 快照字段(option/user/department/resource-ref/cascade)投影出 { label, value }
2287
- // 对象;标量 inputSchema 属性会在运行时 INPUT_SCHEMA_MISMATCH 且命令无限重试。
2288
- // 编译期强制形状一致,把错误从"部署后静默重试"提前到"编写时"。
2323
+ // 快照字段的投影形状必须与 Native 存储一致;否则命令会在运行时
2324
+ // INPUT_SCHEMA_MISMATCH 并无限重试。编译期把错误提前到编写时。
2289
2325
  const SNAPSHOT_FIELD_PROJECTION_TYPES = new Set([
2290
2326
  'option.single',
2291
2327
  'option.multiple',
@@ -2310,11 +2346,30 @@ export function validateAppConfig(value) {
2310
2346
  continue;
2311
2347
  }
2312
2348
  const property = workflowInputProperties[factKey];
2313
- const propertyType = isRecord(property)
2314
- ? string(property.type)
2315
- : '';
2316
- if (propertyType !== 'object') {
2317
- diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_FACT_PROJECTION_SHAPE_INVALID', `fact "${factKey}" 投影的字段 ${fieldCode} 是 ${fieldType} 快照字段,运行时投影为 { label, value } 对象;inputSchema.properties.${factKey} 必须声明 type: "object"(multiple 字段用 array + object items),条件表达式用 path "${factKey}.value" 比较`, `${path}.definition.inputSchema.properties.${factKey}`));
2349
+ const propertyRecord = isRecord(property) ? property : {};
2350
+ const propertyType = string(propertyRecord.type);
2351
+ const itemRecord = isRecord(propertyRecord.items)
2352
+ ? propertyRecord.items
2353
+ : {};
2354
+ const itemType = string(itemRecord.type);
2355
+ const nestedItemRecord = isRecord(itemRecord.items)
2356
+ ? itemRecord.items
2357
+ : {};
2358
+ const nestedItemType = string(nestedItemRecord.type);
2359
+ const expected = fieldType === 'cascade.multiple'
2360
+ ? 'array(items.type=array,items.items.type=object)'
2361
+ : fieldType.endsWith('.multiple')
2362
+ ? 'array(items.type=object)'
2363
+ : 'object';
2364
+ const shapeValid = fieldType === 'cascade.multiple'
2365
+ ? propertyType === 'array' &&
2366
+ itemType === 'array' &&
2367
+ nestedItemType === 'object'
2368
+ : fieldType.endsWith('.multiple')
2369
+ ? propertyType === 'array' && itemType === 'object'
2370
+ : propertyType === 'object';
2371
+ if (!shapeValid) {
2372
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_FACT_PROJECTION_SHAPE_INVALID', `fact "${factKey}" 投影的字段 ${fieldCode} 是 ${fieldType} 快照字段;inputSchema.properties.${factKey} 必须声明 ${expected}`, `${path}.definition.inputSchema.properties.${factKey}`));
2318
2373
  }
2319
2374
  }
2320
2375
  }
@@ -2514,7 +2569,126 @@ function validateCapabilityDeclarations(appCode, declarations, diagnostics) {
2514
2569
  seen.add(code);
2515
2570
  });
2516
2571
  }
2517
- function validateBackendOperations(rawOperations, declaredResources, declaredWorkflowCodes, declaredRoleCodes, diagnostics) {
2572
+ function validateSubjectReadSurfaces(appCode, rawSurfaces, rawResources, declaredCapabilityCodes, diagnostics) {
2573
+ if (rawSurfaces !== undefined && !Array.isArray(rawSurfaces)) {
2574
+ diagnostics.push(diagnostic('APP_CONFIG_SUBJECT_READ_SURFACES_INVALID', 'data.subjectReadSurfaces 必须是数组', 'data.subjectReadSurfaces'));
2575
+ return;
2576
+ }
2577
+ const resources = new Map((Array.isArray(rawResources) ? rawResources : []).map(raw => {
2578
+ const resource = object(raw);
2579
+ const schema = object(resource.schema);
2580
+ const fields = Array.isArray(schema.fields) ? schema.fields : [];
2581
+ return [
2582
+ string(resource.code),
2583
+ new Map(fields.map(field => [string(object(field).code), object(field)])),
2584
+ ];
2585
+ }));
2586
+ const systemFields = new Set([
2587
+ 'id',
2588
+ 'revision',
2589
+ 'created_at',
2590
+ 'updated_at',
2591
+ 'created_by',
2592
+ 'updated_by',
2593
+ ]);
2594
+ const surfaceCodes = new Set();
2595
+ (Array.isArray(rawSurfaces) ? rawSurfaces : []).forEach((raw, index) => {
2596
+ const surface = object(raw);
2597
+ const path = `data.subjectReadSurfaces[${index}]`;
2598
+ const code = string(surface.code);
2599
+ const capability = string(surface.capability);
2600
+ const subject = object(surface.subject);
2601
+ const subjectResourceCode = string(subject.resourceCode);
2602
+ const subjectFields = Array.isArray(subject.fields)
2603
+ ? subject.fields.map(string)
2604
+ : [];
2605
+ const subjectResource = resources.get(subjectResourceCode);
2606
+ let invalid = Object.keys(surface).some(key => !['code', 'name', 'capability', 'subject', 'relations'].includes(key)) ||
2607
+ !OPERATION_CODE_PATTERN.test(code) ||
2608
+ surfaceCodes.has(code) ||
2609
+ !string(surface.name) ||
2610
+ !CAPABILITY_PATTERN.test(capability) ||
2611
+ !capability.startsWith(`app:${appCode}:`) ||
2612
+ !declaredCapabilityCodes.has(capability) ||
2613
+ Object.keys(subject).some(key => !['resourceCode', 'fields'].includes(key)) ||
2614
+ !subjectResource ||
2615
+ subjectFields.length < 1 ||
2616
+ subjectFields.length > 100 ||
2617
+ new Set(subjectFields).size !== subjectFields.length ||
2618
+ subjectFields.some(field => {
2619
+ const declaration = subjectResource?.get(field);
2620
+ return !declaration || string(declaration.type) === 'subtable';
2621
+ });
2622
+ const relations = Array.isArray(surface.relations)
2623
+ ? surface.relations
2624
+ : [];
2625
+ invalid ||= relations.length < 1 || relations.length > 8;
2626
+ const relationCodes = new Set();
2627
+ let totalRows = 0;
2628
+ relations.forEach(rawRelation => {
2629
+ const relation = object(rawRelation);
2630
+ const relationCode = string(relation.code);
2631
+ const resourceCode = string(relation.resourceCode);
2632
+ const foreignKeyField = string(relation.foreignKeyField);
2633
+ const relationFields = Array.isArray(relation.fields)
2634
+ ? relation.fields.map(string)
2635
+ : [];
2636
+ const resource = resources.get(resourceCode);
2637
+ const foreignKey = resource?.get(foreignKeyField);
2638
+ const source = object(foreignKey?.source);
2639
+ const limit = Number(relation.limit);
2640
+ const order = Array.isArray(relation.order) ? relation.order : [];
2641
+ invalid ||=
2642
+ Object.keys(relation).some(key => ![
2643
+ 'code',
2644
+ 'resourceCode',
2645
+ 'foreignKeyField',
2646
+ 'fields',
2647
+ 'order',
2648
+ 'limit',
2649
+ ].includes(key)) ||
2650
+ !OPERATION_CODE_PATTERN.test(relationCode) ||
2651
+ relationCodes.has(relationCode) ||
2652
+ !resource ||
2653
+ !foreignKey ||
2654
+ !['uuid', 'resource-ref.single'].includes(string(foreignKey.type)) ||
2655
+ (string(foreignKey.type) === 'resource-ref.single' &&
2656
+ (string(source.kind) !== 'resource' ||
2657
+ string(source.resourceCode) !== subjectResourceCode)) ||
2658
+ relationFields.length < 1 ||
2659
+ relationFields.length > 100 ||
2660
+ new Set(relationFields).size !== relationFields.length ||
2661
+ relationFields.some(field => {
2662
+ const declaration = resource?.get(field);
2663
+ return !declaration || string(declaration.type) === 'subtable';
2664
+ }) ||
2665
+ !Number.isSafeInteger(limit) ||
2666
+ limit < 1 ||
2667
+ limit > 100 ||
2668
+ order.length > 10 ||
2669
+ order.some(rawOrder => {
2670
+ const item = object(rawOrder);
2671
+ const field = string(item.field);
2672
+ const declaration = resource?.get(field);
2673
+ return (Object.keys(item).some(key => !['field', 'direction'].includes(key)) ||
2674
+ (!systemFields.has(field) && !declaration) ||
2675
+ string(declaration?.type) === 'subtable' ||
2676
+ (!systemFields.has(field) &&
2677
+ !supportsSort(string(declaration?.type))) ||
2678
+ !['asc', 'desc'].includes(string(item.direction)));
2679
+ });
2680
+ relationCodes.add(relationCode);
2681
+ if (Number.isSafeInteger(limit))
2682
+ totalRows += limit;
2683
+ });
2684
+ invalid ||= totalRows > 400;
2685
+ if (invalid) {
2686
+ diagnostics.push(diagnostic('APP_CONFIG_SUBJECT_READ_SURFACE_INVALID', 'subject read surface 必须声明唯一 code、显式 capability、一个可读父资源,以及最多 8 个直接等值且总计不超过 400 行的有界关系', path, '关系外键仅支持 uuid 或指向父资源的 resource-ref.single;字段、排序和行数必须全部由声明固定,不能由浏览器传入。'));
2687
+ }
2688
+ surfaceCodes.add(code);
2689
+ });
2690
+ }
2691
+ function validateBackendOperations(rawOperations, declaredResources, declaredResourceFields, declaredWorkflowCodes, declaredRoleCodes, declaredSubjectSurfaces, diagnostics) {
2518
2692
  const declaredResourceCodes = new Set(declaredResources.keys());
2519
2693
  if (rawOperations !== undefined && !Array.isArray(rawOperations)) {
2520
2694
  diagnostics.push(diagnostic('APP_CONFIG_BACKEND_OPERATIONS_INVALID', 'backend.operations 必须是数组', 'backend.operations'));
@@ -2678,6 +2852,123 @@ function validateBackendOperations(rawOperations, declaredResources, declaredWor
2678
2852
  diagnostics.push(diagnostic('APP_CONFIG_BACKEND_OPERATION_PLATFORM_ACCESS_INVALID', 'operation.platformAccess 必须只声明有界的发起人目录、托管文件、托管文件复制、标准通知、现有工作流或已声明角色条件依赖', `${path}.platformAccess`));
2679
2853
  }
2680
2854
  }
2855
+ if (operation.browser !== undefined) {
2856
+ const browser = object(operation.browser);
2857
+ const subject = object(browser.subject);
2858
+ const subjectResourceCode = string(subject.resourceCode);
2859
+ const inputField = string(subject.inputField);
2860
+ const requestSchema = object(operation.requestSchema);
2861
+ const responseSchema = object(operation.responseSchema);
2862
+ const requestProperties = object(requestSchema.properties);
2863
+ const subjectInputSchema = object(requestProperties[inputField]);
2864
+ const requiredFields = Array.isArray(requestSchema.required)
2865
+ ? requestSchema.required.map(string)
2866
+ : [];
2867
+ const refreshTargets = Array.isArray(browser.refreshTargets)
2868
+ ? browser.refreshTargets
2869
+ : [];
2870
+ const refreshKeys = refreshTargets.map(rawTarget => {
2871
+ const target = object(rawTarget);
2872
+ return `${string(target.kind)}:${string(target.code)}`;
2873
+ });
2874
+ let invalid = !isRecord(operation.browser) ||
2875
+ Object.keys(browser).some(key => ![
2876
+ 'exposure',
2877
+ 'behavior',
2878
+ 'idempotency',
2879
+ 'subject',
2880
+ 'refreshTargets',
2881
+ 'fileIntent',
2882
+ ].includes(key)) ||
2883
+ browser.exposure !== 'authenticated' ||
2884
+ !['read', 'controlled'].includes(string(browser.behavior)) ||
2885
+ !['none', 'required'].includes(string(browser.idempotency)) ||
2886
+ (browser.behavior === 'controlled' &&
2887
+ browser.idempotency !== 'required') ||
2888
+ (browser.behavior === 'read' && browser.idempotency !== 'none') ||
2889
+ !isRecord(browser.subject) ||
2890
+ Object.keys(subject).some(key => !['resourceCode', 'inputField'].includes(key)) ||
2891
+ !declaredResources.has(subjectResourceCode) ||
2892
+ requestSchema.type !== 'object' ||
2893
+ requestSchema.additionalProperties !== false ||
2894
+ responseSchema.type !== 'object' ||
2895
+ responseSchema.additionalProperties !== false ||
2896
+ !/^[A-Za-z][A-Za-z0-9_]{0,127}$/.test(inputField) ||
2897
+ !(inputField in requestProperties) ||
2898
+ !requiredFields.includes(inputField) ||
2899
+ subjectInputSchema.type !== 'string' ||
2900
+ subjectInputSchema.format !== 'uuid' ||
2901
+ refreshTargets.length > 16 ||
2902
+ new Set(refreshKeys).size !== refreshKeys.length;
2903
+ for (const rawTarget of refreshTargets) {
2904
+ const target = object(rawTarget);
2905
+ const targetCode = string(target.code);
2906
+ invalid ||=
2907
+ Object.keys(target).some(key => !['kind', 'code'].includes(key)) ||
2908
+ target.kind !== 'subject-surface' ||
2909
+ !declaredSubjectSurfaces.has(targetCode) ||
2910
+ declaredSubjectSurfaces.get(targetCode) !== subjectResourceCode;
2911
+ }
2912
+ if (browser.fileIntent !== undefined) {
2913
+ const fileIntent = object(browser.fileIntent);
2914
+ const recordResourceCode = string(fileIntent.recordResourceCode);
2915
+ const fields = declaredResourceFields.get(recordResourceCode);
2916
+ const recordIdInputField = string(fileIntent.recordIdInputField);
2917
+ const recordInputSchema = object(requestProperties[recordIdInputField]);
2918
+ const relationField = string(fileIntent.relationField);
2919
+ const relation = fields?.get(relationField);
2920
+ const relationSource = object(relation?.source);
2921
+ const fileNameField = string(fileIntent.fileNameField);
2922
+ const contentTypeField = string(fileIntent.contentTypeField);
2923
+ const sizeField = string(fileIntent.sizeField);
2924
+ const purposes = Array.isArray(fileIntent.purposes)
2925
+ ? fileIntent.purposes.map(string)
2926
+ : [];
2927
+ const allowedKeys = new Set([
2928
+ 'recordResourceCode',
2929
+ 'recordIdInputField',
2930
+ 'relationField',
2931
+ 'fileNameField',
2932
+ 'contentTypeField',
2933
+ 'sizeField',
2934
+ 'purposes',
2935
+ 'maxTtlSeconds',
2936
+ 'maxBytes',
2937
+ ]);
2938
+ invalid ||=
2939
+ !isRecord(browser.fileIntent) ||
2940
+ Object.keys(fileIntent).some(key => !allowedKeys.has(key)) ||
2941
+ Object.keys(fileIntent).some(key => fileIntent[key] === undefined) ||
2942
+ method !== 'GET' ||
2943
+ browser.behavior !== 'read' ||
2944
+ browser.idempotency !== 'none' ||
2945
+ !fields ||
2946
+ !requiredFields.includes(recordIdInputField) ||
2947
+ recordInputSchema.type !== 'string' ||
2948
+ recordInputSchema.format !== 'uuid' ||
2949
+ !relation ||
2950
+ !['uuid', 'resource-ref.single'].includes(string(relation.type)) ||
2951
+ (relation.type === 'resource-ref.single' &&
2952
+ (relationSource.kind !== 'resource' ||
2953
+ relationSource.resourceCode !== subjectResourceCode)) ||
2954
+ !['text.short', 'text.long'].includes(string(fields?.get(fileNameField)?.type)) ||
2955
+ !['text.short', 'text.long'].includes(string(fields?.get(contentTypeField)?.type)) ||
2956
+ string(fields?.get(sizeField)?.type) !== 'number.integer' ||
2957
+ purposes.length < 1 ||
2958
+ purposes.length > 2 ||
2959
+ new Set(purposes).size !== purposes.length ||
2960
+ purposes.some(purpose => !['preview', 'download'].includes(purpose)) ||
2961
+ !Number.isSafeInteger(Number(fileIntent.maxTtlSeconds)) ||
2962
+ Number(fileIntent.maxTtlSeconds) < 1 ||
2963
+ Number(fileIntent.maxTtlSeconds) > 300 ||
2964
+ !Number.isSafeInteger(Number(fileIntent.maxBytes)) ||
2965
+ Number(fileIntent.maxBytes) < 1 ||
2966
+ Number(fileIntent.maxBytes) > 104857600;
2967
+ }
2968
+ if (invalid) {
2969
+ diagnostics.push(diagnostic('APP_CONFIG_BACKEND_OPERATION_BROWSER_INVALID', 'browser operation 必须绑定已声明父资源和必填请求字段;controlled 必须强制幂等,刷新目标必须引用同一父资源的 subject surface;fileIntent 必须是只读、绑定关系记录与元数据字段,并限制为 5 分钟/100 MiB 内', `${path}.browser`));
2970
+ }
2971
+ }
2681
2972
  if (operation.ai !== undefined) {
2682
2973
  const ai = object(operation.ai);
2683
2974
  const aiResources = Array.isArray(ai.resources) ? ai.resources : [];
@@ -4460,7 +4751,12 @@ export function defineOpenXiangdaApp(declaration) {
4460
4751
  },
4461
4752
  platform: { root: 'platform', ...declaration.platform },
4462
4753
  ...(modules || declaration.data ? {
4463
- data: { resources: [...(declaration.data?.resources || []), ...projected.resources] },
4754
+ data: {
4755
+ resources: [...(declaration.data?.resources || []), ...projected.resources],
4756
+ ...(declaration.data?.subjectReadSurfaces
4757
+ ? { subjectReadSurfaces: declaration.data.subjectReadSurfaces }
4758
+ : {}),
4759
+ },
4464
4760
  } : {}),
4465
4761
  });
4466
4762
  // 生成式用户标准面:登录默认路由仍是模板占位时,指向首页资源的标准页。
@@ -4491,6 +4787,9 @@ export function defineOpenXiangdaApp(declaration) {
4491
4787
  ? {
4492
4788
  data: {
4493
4789
  resources: data.resources.map(resource => materializeDataResource(normalizedDeclaration.app.code, resource)),
4790
+ ...(data.subjectReadSurfaces
4791
+ ? { subjectReadSurfaces: data.subjectReadSurfaces }
4792
+ : {}),
4494
4793
  },
4495
4794
  }
4496
4795
  : {}),