@southwind-ai/modules 0.1.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.
- package/index.ts +8 -0
- package/package.json +23 -0
- package/src/catalog-validation.ts +123 -0
- package/src/composition.ts +344 -0
- package/src/manifest.ts +92 -0
- package/src/menu-composition.ts +54 -0
- package/src/registry.ts +53 -0
- package/src/system-admin-routes.ts +136 -0
- package/src/system-agent-tools.ts +28 -0
- package/src/system-api-binding.ts +37 -0
- package/src/system-api-data-governance.ts +21 -0
- package/src/system-api-permissions.ts +344 -0
- package/src/system-audit-actions.ts +70 -0
- package/src/system-data-governance.ts +30 -0
- package/src/system-features.ts +145 -0
- package/src/system-governed-export-bindings.ts +31 -0
- package/src/system-module-catalog.ts +250 -0
- package/src/system-modules.ts +335 -0
- package/src/system-permissions.ts +142 -0
package/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./src/manifest.js";
|
|
2
|
+
export * from "./src/composition.js";
|
|
3
|
+
export * from "./src/catalog-validation.js";
|
|
4
|
+
export * from "./src/menu-composition.js";
|
|
5
|
+
export * from "./src/registry.js";
|
|
6
|
+
export * from "./src/system-modules.js";
|
|
7
|
+
export * from "./src/system-module-catalog.js";
|
|
8
|
+
export * from "./src/system-api-permissions.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@southwind-ai/modules",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"bun": ">=1.3.0"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"src/**/*.ts",
|
|
12
|
+
"!src/**/*.test.ts"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@southwind-ai/shared": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.ts"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUDIT_ACTION_DEFINITIONS,
|
|
3
|
+
isAuditActionDefinition,
|
|
4
|
+
LICENSE_CATALOG,
|
|
5
|
+
type AuditActionDefinition,
|
|
6
|
+
type FeatureFlagDefinition,
|
|
7
|
+
type LicenseCatalogDefinition,
|
|
8
|
+
} from "@southwind-ai/shared";
|
|
9
|
+
import type { ModuleManifest } from "./manifest.js";
|
|
10
|
+
|
|
11
|
+
export interface PlatformCapabilityCatalogs {
|
|
12
|
+
auditActions: readonly AuditActionDefinition[];
|
|
13
|
+
licenseVersions: readonly LicenseCatalogDefinition[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validateCapabilityCatalogs(
|
|
17
|
+
modules: readonly ModuleManifest[],
|
|
18
|
+
): PlatformCapabilityCatalogs {
|
|
19
|
+
const auditActions = composeAuditActionCatalog(modules);
|
|
20
|
+
validateFeatureCatalog(modules, LICENSE_CATALOG);
|
|
21
|
+
return {
|
|
22
|
+
auditActions,
|
|
23
|
+
licenseVersions: LICENSE_CATALOG,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function composeAuditActionCatalog(
|
|
28
|
+
modules: readonly ModuleManifest[],
|
|
29
|
+
): AuditActionDefinition[] {
|
|
30
|
+
const definitions = new Map<string, AuditActionDefinition>(
|
|
31
|
+
AUDIT_ACTION_DEFINITIONS.map((definition) => [
|
|
32
|
+
definition.key,
|
|
33
|
+
{ ...definition },
|
|
34
|
+
]),
|
|
35
|
+
);
|
|
36
|
+
const registrations = modules.flatMap((module) => module.auditActions);
|
|
37
|
+
|
|
38
|
+
for (const registration of registrations) {
|
|
39
|
+
if (!isAuditActionDefinition(registration)) continue;
|
|
40
|
+
validateAuditDefinition(registration);
|
|
41
|
+
if (definitions.has(registration.key)) {
|
|
42
|
+
throw new Error(`审计动作定义重复:${registration.key}`);
|
|
43
|
+
}
|
|
44
|
+
definitions.set(registration.key, { ...registration });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const registration of registrations) {
|
|
48
|
+
const key =
|
|
49
|
+
typeof registration === "string" ? registration : registration.key;
|
|
50
|
+
if (!definitions.has(key)) {
|
|
51
|
+
throw new Error(`引用了未定义审计动作:${key}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return [...definitions.values()].sort((left, right) =>
|
|
56
|
+
left.key.localeCompare(right.key),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateFeatureCatalog(
|
|
61
|
+
modules: readonly ModuleManifest[],
|
|
62
|
+
licenseCatalog: readonly LicenseCatalogDefinition[],
|
|
63
|
+
): void {
|
|
64
|
+
const features = modules.flatMap((module) => module.features);
|
|
65
|
+
const byKey = new Map(features.map((feature) => [feature.key, feature]));
|
|
66
|
+
const licenseKeys = new Set(licenseCatalog.map(({ key }) => key));
|
|
67
|
+
|
|
68
|
+
for (const feature of features) {
|
|
69
|
+
for (const dependency of feature.dependencies || []) {
|
|
70
|
+
if (dependency === feature.key) {
|
|
71
|
+
throw new Error(`Feature ${feature.key} 不能依赖自身`);
|
|
72
|
+
}
|
|
73
|
+
if (!byKey.has(dependency)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Feature ${feature.key} 引用了未注册依赖:${dependency}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const version of feature.allowedLicenseVersions || []) {
|
|
80
|
+
if (!licenseKeys.has(version)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Feature ${feature.key} 引用了未知 License 版本:${version}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
validateFeatureCycles(features, byKey);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateAuditDefinition(definition: AuditActionDefinition): void {
|
|
92
|
+
if (
|
|
93
|
+
!definition.key.trim() ||
|
|
94
|
+
!definition.label.trim() ||
|
|
95
|
+
!definition.description.trim()
|
|
96
|
+
) {
|
|
97
|
+
throw new Error("审计动作 Key、名称和说明不能为空");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateFeatureCycles(
|
|
102
|
+
features: readonly FeatureFlagDefinition[],
|
|
103
|
+
byKey: ReadonlyMap<string, FeatureFlagDefinition>,
|
|
104
|
+
): void {
|
|
105
|
+
const visited = new Set<string>();
|
|
106
|
+
const visiting = new Set<string>();
|
|
107
|
+
|
|
108
|
+
const visit = (feature: FeatureFlagDefinition): void => {
|
|
109
|
+
if (visited.has(feature.key)) return;
|
|
110
|
+
if (visiting.has(feature.key)) {
|
|
111
|
+
throw new Error(`Feature 依赖存在循环:${feature.key}`);
|
|
112
|
+
}
|
|
113
|
+
visiting.add(feature.key);
|
|
114
|
+
for (const dependency of feature.dependencies || []) {
|
|
115
|
+
const target = byKey.get(dependency);
|
|
116
|
+
if (target) visit(target);
|
|
117
|
+
}
|
|
118
|
+
visiting.delete(feature.key);
|
|
119
|
+
visited.add(feature.key);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const feature of features) visit(feature);
|
|
123
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { MenuNode } from "@southwind-ai/shared";
|
|
2
|
+
import type {
|
|
3
|
+
ModuleImplementationBindings,
|
|
4
|
+
ModuleManifest,
|
|
5
|
+
} from "./manifest.js";
|
|
6
|
+
import { composeModuleMenus } from "./menu-composition.js";
|
|
7
|
+
import { validateCapabilityCatalogs } from "./catalog-validation.js";
|
|
8
|
+
|
|
9
|
+
export interface ValidatedModuleComposition {
|
|
10
|
+
modules: readonly ModuleManifest[];
|
|
11
|
+
moduleOrder: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function apiRouteKey(input: { method: string; path: string }): string {
|
|
15
|
+
return `${input.method.toUpperCase()} ${input.path}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateModuleComposition(
|
|
19
|
+
modules: readonly ModuleManifest[],
|
|
20
|
+
implementations: ModuleImplementationBindings = {},
|
|
21
|
+
): ValidatedModuleComposition {
|
|
22
|
+
const moduleNames = unique(
|
|
23
|
+
"模块",
|
|
24
|
+
modules.map(({ name }) => name),
|
|
25
|
+
);
|
|
26
|
+
const moduleOrder = validateDependencies(modules, moduleNames);
|
|
27
|
+
const permissions = unique(
|
|
28
|
+
"权限",
|
|
29
|
+
modules.flatMap((module) => module.permissions.map(({ key }) => key)),
|
|
30
|
+
);
|
|
31
|
+
const features = unique(
|
|
32
|
+
"Feature",
|
|
33
|
+
modules.flatMap((module) => module.features.map(({ key }) => key)),
|
|
34
|
+
);
|
|
35
|
+
const catalogs = validateCapabilityCatalogs(modules);
|
|
36
|
+
const audits = new Set(catalogs.auditActions.map(({ key }) => key));
|
|
37
|
+
const menus = composeModuleMenus(modules).flatMap((menu) =>
|
|
38
|
+
flattenMenus([menu]),
|
|
39
|
+
);
|
|
40
|
+
unique(
|
|
41
|
+
"菜单",
|
|
42
|
+
menus.map(({ key }) => key),
|
|
43
|
+
);
|
|
44
|
+
const adminRoutes = modules.flatMap((module) => module.adminRoutes);
|
|
45
|
+
unique(
|
|
46
|
+
"Admin Route",
|
|
47
|
+
adminRoutes.map(({ key }) => key),
|
|
48
|
+
);
|
|
49
|
+
unique(
|
|
50
|
+
"Admin Route 路径",
|
|
51
|
+
adminRoutes.map(({ path }) => path),
|
|
52
|
+
);
|
|
53
|
+
const apiRoutes = modules.flatMap((module) => module.apiPermissions);
|
|
54
|
+
unique("API Route", apiRoutes.map(apiRouteKey));
|
|
55
|
+
const tasks = modules.flatMap((module) => module.tasks);
|
|
56
|
+
unique(
|
|
57
|
+
"定时任务",
|
|
58
|
+
tasks.map(({ key }) => key),
|
|
59
|
+
);
|
|
60
|
+
const tools = modules.flatMap((module) => module.tools);
|
|
61
|
+
unique(
|
|
62
|
+
"Agent Tool",
|
|
63
|
+
tools.map(({ key }) => key),
|
|
64
|
+
);
|
|
65
|
+
const providers = modules.flatMap((module) => module.providers);
|
|
66
|
+
unique(
|
|
67
|
+
"Provider",
|
|
68
|
+
providers.map(({ key }) => key),
|
|
69
|
+
);
|
|
70
|
+
const dataPolicies = modules.flatMap((module) => module.dataPolicies || []);
|
|
71
|
+
unique(
|
|
72
|
+
"数据治理策略",
|
|
73
|
+
dataPolicies.map(({ key }) => key),
|
|
74
|
+
);
|
|
75
|
+
unique(
|
|
76
|
+
"数据治理资源",
|
|
77
|
+
dataPolicies.map(({ resourceType }) => resourceType),
|
|
78
|
+
);
|
|
79
|
+
const schemas = modules.flatMap((module) => module.schemaExports);
|
|
80
|
+
unique(
|
|
81
|
+
"Schema Export",
|
|
82
|
+
schemas.map(({ key }) => key),
|
|
83
|
+
);
|
|
84
|
+
unique(
|
|
85
|
+
"Migration",
|
|
86
|
+
modules.flatMap((module) => module.migrations.map(({ id }) => id)),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
for (const module of modules) {
|
|
90
|
+
validateReferences(module, permissions, features, audits);
|
|
91
|
+
validateDataPolicies(module, modules);
|
|
92
|
+
}
|
|
93
|
+
validateMenuRoutes(menus, adminRoutes);
|
|
94
|
+
validateSchemaMigrations(schemas);
|
|
95
|
+
validateImplementations(
|
|
96
|
+
"Admin Route",
|
|
97
|
+
adminRoutes.map(({ key }) => key),
|
|
98
|
+
implementations.adminRoutes,
|
|
99
|
+
);
|
|
100
|
+
validateImplementations(
|
|
101
|
+
"API Route",
|
|
102
|
+
apiRoutes.map(apiRouteKey),
|
|
103
|
+
implementations.apiRoutes,
|
|
104
|
+
);
|
|
105
|
+
validateImplementations(
|
|
106
|
+
"定时任务",
|
|
107
|
+
tasks.map(({ key }) => key),
|
|
108
|
+
implementations.tasks,
|
|
109
|
+
);
|
|
110
|
+
validateImplementations(
|
|
111
|
+
"Agent Tool",
|
|
112
|
+
tools.map(({ key }) => key),
|
|
113
|
+
implementations.tools,
|
|
114
|
+
);
|
|
115
|
+
validateImplementations(
|
|
116
|
+
"Provider",
|
|
117
|
+
providers.map(({ key }) => key),
|
|
118
|
+
implementations.providers,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return { modules, moduleOrder };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateDataPolicies(
|
|
125
|
+
module: ModuleManifest,
|
|
126
|
+
modules: readonly ModuleManifest[],
|
|
127
|
+
): void {
|
|
128
|
+
const permissionByKey = new Map(
|
|
129
|
+
modules.flatMap((item) =>
|
|
130
|
+
item.permissions.map(
|
|
131
|
+
(permission) => [permission.key, permission] as const,
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
const featureKeys = new Set(
|
|
136
|
+
modules.flatMap((item) => item.features.map(({ key }) => key)),
|
|
137
|
+
);
|
|
138
|
+
for (const policy of module.dataPolicies || []) {
|
|
139
|
+
if (
|
|
140
|
+
!policy.key.trim() ||
|
|
141
|
+
!policy.resourceType.trim() ||
|
|
142
|
+
!policy.version.trim()
|
|
143
|
+
) {
|
|
144
|
+
throw new Error("数据治理策略 Key、资源和版本不能为空");
|
|
145
|
+
}
|
|
146
|
+
if (!featureKeys.has(policy.featureKey)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${policy.key} 引用了未注册 Feature:${policy.featureKey}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const read = requiredPermission(
|
|
152
|
+
policy.key,
|
|
153
|
+
policy.readPermissionKey,
|
|
154
|
+
permissionByKey,
|
|
155
|
+
);
|
|
156
|
+
const reveal = requiredPermission(
|
|
157
|
+
policy.key,
|
|
158
|
+
policy.revealPermissionKey,
|
|
159
|
+
permissionByKey,
|
|
160
|
+
);
|
|
161
|
+
const exportPermission = requiredPermission(
|
|
162
|
+
policy.key,
|
|
163
|
+
policy.exportPermissionKey,
|
|
164
|
+
permissionByKey,
|
|
165
|
+
);
|
|
166
|
+
if (
|
|
167
|
+
read.action !== "read" ||
|
|
168
|
+
(read.accessRisk && read.accessRisk !== "standard")
|
|
169
|
+
) {
|
|
170
|
+
throw new Error(`${policy.key} 普通读取权限必须是 standard read`);
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
reveal.action !== "reveal" ||
|
|
174
|
+
reveal.accessRisk !== "sensitive-disclosure"
|
|
175
|
+
) {
|
|
176
|
+
throw new Error(`${policy.key} 明文权限必须标记 sensitive-disclosure`);
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
exportPermission.action !== "export" ||
|
|
180
|
+
exportPermission.accessRisk !== "governed-export"
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(`${policy.key} 导出权限必须标记 governed-export`);
|
|
183
|
+
}
|
|
184
|
+
if (!policy.fields.length)
|
|
185
|
+
throw new Error(`${policy.key} 至少治理一个字段`);
|
|
186
|
+
unique(
|
|
187
|
+
`${policy.key} 字段`,
|
|
188
|
+
policy.fields.map(({ fieldKey }) => fieldKey),
|
|
189
|
+
);
|
|
190
|
+
for (const field of policy.fields) validateGovernedField(policy.key, field);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function requiredPermission(
|
|
195
|
+
policyKey: string,
|
|
196
|
+
permissionKey: string,
|
|
197
|
+
definitions: ReadonlyMap<string, ModuleManifest["permissions"][number]>,
|
|
198
|
+
) {
|
|
199
|
+
const permission = definitions.get(permissionKey);
|
|
200
|
+
if (!permission) {
|
|
201
|
+
throw new Error(`${policyKey} 引用了未注册权限:${permissionKey}`);
|
|
202
|
+
}
|
|
203
|
+
return permission;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateGovernedField(
|
|
207
|
+
policyKey: string,
|
|
208
|
+
field: NonNullable<ModuleManifest["dataPolicies"]>[number]["fields"][number],
|
|
209
|
+
) {
|
|
210
|
+
if (!field.fieldKey.trim()) throw new Error(`${policyKey} 字段 Key 不能为空`);
|
|
211
|
+
if (field.mask.kind !== "partial") return;
|
|
212
|
+
if (
|
|
213
|
+
!Number.isSafeInteger(field.mask.prefix) ||
|
|
214
|
+
!Number.isSafeInteger(field.mask.suffix) ||
|
|
215
|
+
field.mask.prefix < 0 ||
|
|
216
|
+
field.mask.suffix < 0
|
|
217
|
+
) {
|
|
218
|
+
throw new Error(`${policyKey}.${field.fieldKey} 掩码位数必须是非负整数`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateDependencies(
|
|
223
|
+
modules: readonly ModuleManifest[],
|
|
224
|
+
moduleNames: ReadonlySet<string>,
|
|
225
|
+
): string[] {
|
|
226
|
+
for (const module of modules) {
|
|
227
|
+
for (const dependency of module.dependencies || []) {
|
|
228
|
+
if (!moduleNames.has(dependency)) {
|
|
229
|
+
throw new Error(`模块 ${module.name} 缺少依赖:${dependency}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const pending = new Map(modules.map((module) => [module.name, module]));
|
|
234
|
+
const order: string[] = [];
|
|
235
|
+
while (pending.size) {
|
|
236
|
+
const ready = [...pending.values()].find((module) =>
|
|
237
|
+
(module.dependencies || []).every(
|
|
238
|
+
(dependency) => !pending.has(dependency),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
if (!ready) throw new Error("模块依赖存在循环");
|
|
242
|
+
order.push(ready.name);
|
|
243
|
+
pending.delete(ready.name);
|
|
244
|
+
}
|
|
245
|
+
return order;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validateReferences(
|
|
249
|
+
module: ModuleManifest,
|
|
250
|
+
permissions: ReadonlySet<string>,
|
|
251
|
+
features: ReadonlySet<string>,
|
|
252
|
+
audits: ReadonlySet<string>,
|
|
253
|
+
): void {
|
|
254
|
+
const guarded = [
|
|
255
|
+
...flattenMenus(module.menus),
|
|
256
|
+
...module.adminRoutes,
|
|
257
|
+
...module.apiPermissions,
|
|
258
|
+
...module.tasks,
|
|
259
|
+
...module.tools,
|
|
260
|
+
...module.providers,
|
|
261
|
+
];
|
|
262
|
+
for (const item of guarded) {
|
|
263
|
+
const reference = item as {
|
|
264
|
+
key?: string;
|
|
265
|
+
path?: string;
|
|
266
|
+
permissionKey?: string;
|
|
267
|
+
featureKey?: string;
|
|
268
|
+
};
|
|
269
|
+
const label = reference.key || reference.path || module.name;
|
|
270
|
+
if (reference.permissionKey && !permissions.has(reference.permissionKey)) {
|
|
271
|
+
throw new Error(`${label} 引用了未注册权限:${reference.permissionKey}`);
|
|
272
|
+
}
|
|
273
|
+
if (reference.featureKey && !features.has(reference.featureKey)) {
|
|
274
|
+
throw new Error(`${label} 引用了未注册 Feature:${reference.featureKey}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const tool of module.tools) {
|
|
278
|
+
if (!audits.has(tool.auditAction)) {
|
|
279
|
+
throw new Error(`${tool.key} 引用了未注册审计动作:${tool.auditAction}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const migration of module.migrations) {
|
|
283
|
+
if (migration.module !== module.name) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Migration ${migration.id} 的模块归属应为 ${module.name}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function validateMenuRoutes(
|
|
292
|
+
menus: readonly MenuNode[],
|
|
293
|
+
routes: readonly ModuleManifest["adminRoutes"][number][],
|
|
294
|
+
): void {
|
|
295
|
+
const byPath = new Map(routes.map((route) => [route.path, route]));
|
|
296
|
+
for (const menu of menus) {
|
|
297
|
+
if (!menu.path) continue;
|
|
298
|
+
const route = byPath.get(menu.path);
|
|
299
|
+
if (!route)
|
|
300
|
+
throw new Error(`菜单 ${menu.key} 缺少 Admin Route:${menu.path}`);
|
|
301
|
+
if (
|
|
302
|
+
route.directAccess !== "admin-admission" &&
|
|
303
|
+
(route.permissionKey !== menu.permissionKey ||
|
|
304
|
+
route.featureKey !== menu.featureKey)
|
|
305
|
+
) {
|
|
306
|
+
throw new Error(`菜单 ${menu.key} 与 Admin Route Guard 不一致`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function validateSchemaMigrations(
|
|
312
|
+
schemas: readonly ModuleManifest["schemaExports"][number][],
|
|
313
|
+
): void {
|
|
314
|
+
for (const schema of schemas) {
|
|
315
|
+
if (!schema.migrationIds.length) {
|
|
316
|
+
throw new Error(`Schema Export ${schema.key} 缺少 Migration`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateImplementations(
|
|
322
|
+
label: string,
|
|
323
|
+
declarations: readonly string[],
|
|
324
|
+
implementations: readonly string[] | undefined,
|
|
325
|
+
): void {
|
|
326
|
+
if (!implementations) return;
|
|
327
|
+
const registered = unique(`${label} Handler`, implementations);
|
|
328
|
+
for (const key of declarations) {
|
|
329
|
+
if (!registered.has(key)) throw new Error(`${label} ${key} 缺少 Handler`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function unique(label: string, values: readonly string[]): Set<string> {
|
|
334
|
+
const result = new Set<string>();
|
|
335
|
+
for (const value of values) {
|
|
336
|
+
if (result.has(value)) throw new Error(`${label} Key 重复:${value}`);
|
|
337
|
+
result.add(value);
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function flattenMenus(menus: readonly MenuNode[]): MenuNode[] {
|
|
343
|
+
return menus.flatMap((menu) => [menu, ...flattenMenus(menu.children || [])]);
|
|
344
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiPermissionBinding,
|
|
3
|
+
AuditActionRegistration,
|
|
4
|
+
AuditActionType,
|
|
5
|
+
DataGovernancePolicyDefinition,
|
|
6
|
+
FeatureFlagDefinition,
|
|
7
|
+
MigrationBundle,
|
|
8
|
+
MigrationDefinition,
|
|
9
|
+
MenuNode,
|
|
10
|
+
PermissionDefinition,
|
|
11
|
+
SearchProviderManifestDefinition,
|
|
12
|
+
} from "@southwind-ai/shared";
|
|
13
|
+
|
|
14
|
+
export type { MigrationDefinition, MigrationDirection } from "@southwind-ai/shared";
|
|
15
|
+
|
|
16
|
+
export interface ModuleToolDefinition {
|
|
17
|
+
key: string;
|
|
18
|
+
label: string;
|
|
19
|
+
description: string;
|
|
20
|
+
permissionKey: string;
|
|
21
|
+
featureKey?: string;
|
|
22
|
+
auditAction: AuditActionType;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AdminRouteSurface = "sidebar" | "account" | "hidden";
|
|
26
|
+
|
|
27
|
+
export interface ModuleAdminRouteDefinition {
|
|
28
|
+
key: string;
|
|
29
|
+
path: string;
|
|
30
|
+
title: string;
|
|
31
|
+
surface: AdminRouteSurface;
|
|
32
|
+
directAccess?: "guarded" | "admin-admission" | "authenticated";
|
|
33
|
+
permissionKey?: string;
|
|
34
|
+
featureKey?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ModuleProviderDefinition {
|
|
38
|
+
key: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
contractVersion: string;
|
|
41
|
+
permissionKey?: string;
|
|
42
|
+
featureKey?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ModuleSchemaExportDefinition {
|
|
46
|
+
key: string;
|
|
47
|
+
exportName: string;
|
|
48
|
+
migrationIds: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ModuleScheduledTaskDefinition {
|
|
52
|
+
key: string;
|
|
53
|
+
label: string;
|
|
54
|
+
description: string;
|
|
55
|
+
permissionKey: string;
|
|
56
|
+
featureKey: string;
|
|
57
|
+
intervalSeconds: number;
|
|
58
|
+
maxAttempts: number;
|
|
59
|
+
retryDelaySeconds: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ModuleManifest {
|
|
63
|
+
name: string;
|
|
64
|
+
version: string;
|
|
65
|
+
label: string;
|
|
66
|
+
description: string;
|
|
67
|
+
dependencies?: string[];
|
|
68
|
+
menus: MenuNode[];
|
|
69
|
+
adminRoutes: ModuleAdminRouteDefinition[];
|
|
70
|
+
permissions: PermissionDefinition[];
|
|
71
|
+
apiPermissions: ApiPermissionBinding[];
|
|
72
|
+
features: FeatureFlagDefinition[];
|
|
73
|
+
migrations: MigrationDefinition[];
|
|
74
|
+
migrationBundle?: MigrationBundle;
|
|
75
|
+
schemaExports: ModuleSchemaExportDefinition[];
|
|
76
|
+
tasks: ModuleScheduledTaskDefinition[];
|
|
77
|
+
tools: ModuleToolDefinition[];
|
|
78
|
+
providers: Array<ModuleProviderDefinition | SearchProviderManifestDefinition>;
|
|
79
|
+
dataPolicies?: DataGovernancePolicyDefinition[];
|
|
80
|
+
auditActions: AuditActionRegistration[];
|
|
81
|
+
metadata?: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ModuleFactory = () => ModuleManifest;
|
|
85
|
+
|
|
86
|
+
export interface ModuleImplementationBindings {
|
|
87
|
+
adminRoutes?: readonly string[];
|
|
88
|
+
apiRoutes?: readonly string[];
|
|
89
|
+
tasks?: readonly string[];
|
|
90
|
+
tools?: readonly string[];
|
|
91
|
+
providers?: readonly string[];
|
|
92
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { MenuNode } from "@southwind-ai/shared";
|
|
2
|
+
import type { ModuleManifest } from "./manifest.js";
|
|
3
|
+
|
|
4
|
+
export function composeModuleMenus(
|
|
5
|
+
modules: readonly ModuleManifest[],
|
|
6
|
+
): MenuNode[] {
|
|
7
|
+
return mergeMenuNodes(modules.flatMap((module) => module.menus));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function mergeMenuNodes(nodes: readonly MenuNode[]): MenuNode[] {
|
|
11
|
+
const merged = new Map<string, MenuNode>();
|
|
12
|
+
for (const node of nodes) {
|
|
13
|
+
const current = merged.get(node.key);
|
|
14
|
+
if (!current) {
|
|
15
|
+
merged.set(node.key, cloneMenu(node));
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
assertSameMenuShell(current, node);
|
|
19
|
+
current.children = mergeMenuNodes([
|
|
20
|
+
...(current.children || []),
|
|
21
|
+
...(node.children || []),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
return [...merged.values()].sort(
|
|
25
|
+
(left, right) =>
|
|
26
|
+
left.order - right.order || left.key.localeCompare(right.key),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cloneMenu(node: MenuNode): MenuNode {
|
|
31
|
+
return {
|
|
32
|
+
...node,
|
|
33
|
+
children: node.children ? mergeMenuNodes(node.children) : undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertSameMenuShell(left: MenuNode, right: MenuNode): void {
|
|
38
|
+
const fields = [
|
|
39
|
+
"title",
|
|
40
|
+
"type",
|
|
41
|
+
"order",
|
|
42
|
+
"visible",
|
|
43
|
+
"path",
|
|
44
|
+
"component",
|
|
45
|
+
"icon",
|
|
46
|
+
"permissionKey",
|
|
47
|
+
"featureKey",
|
|
48
|
+
] as const;
|
|
49
|
+
for (const field of fields) {
|
|
50
|
+
if (left[field] !== right[field]) {
|
|
51
|
+
throw new Error(`菜单目录 ${left.key} 的 ${field} 声明不一致`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ModuleFactory, ModuleManifest } from "./manifest.js";
|
|
2
|
+
|
|
3
|
+
export interface ModuleRegistry {
|
|
4
|
+
register(factory: ModuleFactory): ModuleManifest;
|
|
5
|
+
list(): ModuleManifest[];
|
|
6
|
+
find(name: string): ModuleManifest | undefined;
|
|
7
|
+
clear(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createModuleRegistry(): ModuleRegistry {
|
|
11
|
+
const modules = new Map<string, ModuleManifest>();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
register(factory) {
|
|
15
|
+
const manifest = factory();
|
|
16
|
+
if (modules.has(manifest.name)) {
|
|
17
|
+
throw new Error(`模块已注册:${manifest.name}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
modules.set(manifest.name, manifest);
|
|
21
|
+
return manifest;
|
|
22
|
+
},
|
|
23
|
+
list() {
|
|
24
|
+
return Array.from(modules.values()).sort((a, b) =>
|
|
25
|
+
a.name.localeCompare(b.name),
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
find(name) {
|
|
29
|
+
return modules.get(name);
|
|
30
|
+
},
|
|
31
|
+
clear() {
|
|
32
|
+
modules.clear();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultRegistry = createModuleRegistry();
|
|
38
|
+
|
|
39
|
+
export function registerModule(factory: ModuleFactory): ModuleManifest {
|
|
40
|
+
return defaultRegistry.register(factory);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getModules(): ModuleManifest[] {
|
|
44
|
+
return defaultRegistry.list();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findModule(name: string): ModuleManifest | undefined {
|
|
48
|
+
return defaultRegistry.find(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clearModules(): void {
|
|
52
|
+
defaultRegistry.clear();
|
|
53
|
+
}
|