create-windy 0.2.19 → 0.2.20

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.
Files changed (84) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +312 -154
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/apps/server/src/application-services.ts +2 -2
  6. package/template/apps/server/src/audit/search-summary.ts +15 -8
  7. package/template/apps/server/src/example-modules.ts +58 -70
  8. package/template/apps/server/src/foundation.ts +8 -4
  9. package/template/apps/server/src/guards.test.ts +13 -0
  10. package/template/apps/server/src/guards.ts +12 -2
  11. package/template/apps/server/src/index.ts +48 -91
  12. package/template/apps/server/src/installed-business-modules.ts +23 -4
  13. package/template/apps/server/src/module-bootstrap.ts +83 -0
  14. package/template/apps/server/src/module-composition.test.ts +45 -1
  15. package/template/apps/server/src/module-composition.ts +45 -0
  16. package/template/apps/server/src/module-host/initialize.test.ts +123 -0
  17. package/template/apps/server/src/module-host/initialize.ts +331 -0
  18. package/template/apps/server/src/module-host/request-context.test.ts +66 -0
  19. package/template/apps/server/src/module-host/request-context.ts +156 -0
  20. package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
  21. package/template/apps/server/src/module-host/role-presets.ts +76 -0
  22. package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
  23. package/template/apps/server/src/module-host/route-installer.ts +97 -0
  24. package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
  25. package/template/apps/server/src/module-host/search-providers.ts +31 -0
  26. package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
  27. package/template/apps/server/src/module-host/storage-port.ts +59 -0
  28. package/template/apps/server/src/module-host/task-definitions.ts +41 -0
  29. package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
  30. package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
  31. package/template/apps/server/src/module-host.test.ts +202 -58
  32. package/template/apps/server/src/module-host.ts +34 -113
  33. package/template/apps/server/src/route-guards.test.ts +112 -1
  34. package/template/apps/server/src/runtime-feature.ts +66 -31
  35. package/template/apps/server/src/runtime.test.ts +1 -0
  36. package/template/apps/server/src/runtime.ts +3 -1
  37. package/template/apps/server/src/system/built-in-roles.ts +1 -1
  38. package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
  39. package/template/apps/server/src/work-order/routes.test.ts +30 -18
  40. package/template/apps/server/src/work-order/routes.ts +104 -135
  41. package/template/apps/server/src/work-order/search-api.test.ts +14 -3
  42. package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
  43. package/template/apps/server/src/work-order/search-provider.ts +17 -12
  44. package/template/apps/server/src/work-order/task.test.ts +16 -11
  45. package/template/apps/web/Dockerfile +4 -1
  46. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
  47. package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
  48. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
  49. package/template/docs/architecture/object-storage.md +12 -0
  50. package/template/packages/config/index.test.ts +57 -0
  51. package/template/packages/config/package.json +1 -1
  52. package/template/packages/config/src/platform.ts +28 -1
  53. package/template/packages/database/package.json +1 -1
  54. package/template/packages/database/src/runner.ts +7 -9
  55. package/template/packages/example-work-order/index.ts +1 -0
  56. package/template/packages/example-work-order/package.json +2 -0
  57. package/template/packages/example-work-order/src/server-module.ts +61 -0
  58. package/template/packages/jobs/package.json +1 -1
  59. package/template/packages/jobs/src/types.ts +7 -1
  60. package/template/packages/modules/package.json +1 -1
  61. package/template/packages/modules/src/catalog-validation.test.ts +212 -0
  62. package/template/packages/modules/src/catalog-validation.ts +19 -2
  63. package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
  64. package/template/packages/server-sdk/index.ts +42 -0
  65. package/template/packages/server-sdk/package.json +16 -3
  66. package/template/packages/server-sdk/src/actor.ts +15 -0
  67. package/template/packages/server-sdk/src/audit-port.ts +22 -0
  68. package/template/packages/server-sdk/src/jobs-port.ts +24 -0
  69. package/template/packages/server-sdk/src/module-host.ts +36 -1
  70. package/template/packages/server-sdk/src/namespace.test.ts +26 -0
  71. package/template/packages/server-sdk/src/namespace.ts +13 -0
  72. package/template/packages/server-sdk/src/request-context.ts +21 -0
  73. package/template/packages/server-sdk/src/role-preset.ts +18 -0
  74. package/template/packages/server-sdk/src/route-definition.ts +26 -0
  75. package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
  76. package/template/packages/server-sdk/src/storage-port.ts +25 -0
  77. package/template/packages/server-sdk/src/task-registration.ts +18 -0
  78. package/template/packages/server-sdk/src/tool-registration.ts +29 -0
  79. package/template/packages/shared/index.ts +1 -0
  80. package/template/packages/shared/package.json +1 -1
  81. package/template/packages/shared/src/license-catalog.test.ts +73 -1
  82. package/template/packages/shared/src/license-catalog.ts +95 -0
  83. package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
  84. package/template/apps/server/src/work-order/task.ts +0 -30
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { DurableHandlerRegistry } from "@southwind-ai/jobs";
2
+ import type { ModuleManifest } from "@southwind-ai/modules";
3
3
  import type { ModuleInitializer } from "@southwind-ai/server-sdk";
4
4
  // @windy-module work-order begin
5
- import { workOrderModuleInitializer } from "./example-modules.js";
5
+ import { createWorkOrderModule } from "./example-modules.js";
6
6
  // @windy-module work-order end
7
7
  import { initializeModuleHosts } from "./module-host.js";
8
8
  // @windy-module work-order begin
@@ -13,88 +13,177 @@ import {
13
13
  // @windy-module work-order end
14
14
 
15
15
  describe("模块 Host 初始化", () => {
16
- test("按安装顺序完成初始化后再创建运行时注册表", async () => {
17
- const events: string[] = [];
18
- const first = initializer("alpha", async (host) => {
19
- events.push("alpha:start");
20
- await Promise.resolve();
21
- host.registerDurableHandler({
22
- key: "alpha.refresh",
23
- payload: { parse: () => ({}) },
24
- execute: () => undefined,
25
- });
26
- events.push("alpha:end");
27
- });
28
- const second = initializer("beta", () => {
29
- events.push("beta");
30
- });
16
+ test("任务、工具、角色预设与 Provider 注册强制命名空间与 Manifest 声明", async () => {
17
+ await expect(
18
+ initializeModuleHosts({
19
+ modules: [manifest("known")],
20
+ initializers: [
21
+ initializer("known", (host) => {
22
+ host.registerTaskHandlers([
23
+ { taskKey: "other.snapshot", handler: () => undefined },
24
+ ]);
25
+ }),
26
+ ],
27
+ }),
28
+ ).rejects.toThrow("定时任务不属于模块 known:other.snapshot");
31
29
 
32
- const initialized = await initializeModuleHosts({
33
- modules: [{ name: "alpha" }, { name: "beta" }],
34
- initializers: [first, second],
30
+ await expect(
31
+ initializeModuleHosts({
32
+ modules: [manifest("known")],
33
+ initializers: [
34
+ initializer("known", (host) => {
35
+ host.registerTaskHandlers([
36
+ { taskKey: "known.snapshot", handler: () => undefined },
37
+ ]);
38
+ }),
39
+ ],
40
+ }),
41
+ ).rejects.toThrow("定时任务 Handler 未在 Manifest 声明:known.snapshot");
42
+
43
+ const withTask = manifest("known", {
44
+ tasks: [
45
+ {
46
+ key: "known.snapshot",
47
+ label: "快照",
48
+ description: "",
49
+ permissionKey: "known.item.read",
50
+ featureKey: "known.feature",
51
+ intervalSeconds: 60,
52
+ maxAttempts: 3,
53
+ retryDelaySeconds: 10,
54
+ },
55
+ ],
35
56
  });
36
- expect(events).toEqual(["alpha:start", "alpha:end", "beta"]);
57
+ await expect(
58
+ initializeModuleHosts({
59
+ modules: [withTask],
60
+ initializers: [
61
+ initializer("known", (host) => {
62
+ const registration = {
63
+ taskKey: "known.snapshot",
64
+ handler: () => undefined,
65
+ };
66
+ host.registerTaskHandlers([registration, registration]);
67
+ }),
68
+ ],
69
+ }),
70
+ ).rejects.toThrow("定时任务 Handler 重复注册:known.snapshot");
37
71
 
38
- const registry = new DurableHandlerRegistry();
39
- initialized.installDurableHandlers(registry);
40
- expect(registry.keys()).toEqual(["alpha.refresh"]);
41
- });
72
+ await expect(
73
+ initializeModuleHosts({
74
+ modules: [manifest("known")],
75
+ initializers: [
76
+ initializer("known", (host) => {
77
+ host.registerToolHandlers([
78
+ {
79
+ toolKey: "other.list",
80
+ scopeMode: "platform",
81
+ execute: () => ({ items: [], total: 0, page: 1, pageSize: 10 }),
82
+ },
83
+ ]);
84
+ }),
85
+ ],
86
+ }),
87
+ ).rejects.toThrow("Agent Tool不属于模块 known:other.list");
42
88
 
43
- test("未知模块、错误命名空间和重复注册立即失败", async () => {
44
89
  await expect(
45
90
  initializeModuleHosts({
46
- modules: [{ name: "known" }],
47
- initializers: [initializer("missing", () => undefined)],
91
+ modules: [manifest("known")],
92
+ initializers: [
93
+ initializer("known", (host) => {
94
+ host.registerToolHandlers([
95
+ {
96
+ toolKey: "known.list",
97
+ scopeMode: "platform",
98
+ execute: () => ({ items: [], total: 0, page: 1, pageSize: 10 }),
99
+ },
100
+ ]);
101
+ }),
102
+ ],
48
103
  }),
49
- ).rejects.toThrow("模块初始化器与已安装模块不匹配:missing");
104
+ ).rejects.toThrow("Agent Tool Handler 未在 Manifest 声明:known.list");
50
105
 
51
106
  await expect(
52
107
  initializeModuleHosts({
53
- modules: [{ name: "known" }],
108
+ modules: [
109
+ manifest("known", {
110
+ permissions: [permission("known.item.read", "known")],
111
+ }),
112
+ ],
54
113
  initializers: [
55
114
  initializer("known", (host) => {
56
- host.registerUploadPolicy(policy("other.attachment"));
115
+ host.registerRolePresets([
116
+ {
117
+ key: "known.manager",
118
+ label: "管理员",
119
+ permissionKeys: ["other.item.read"],
120
+ },
121
+ ]);
57
122
  }),
58
123
  ],
59
124
  }),
60
- ).rejects.toThrow("上传用途不属于模块 known:other.attachment");
125
+ ).rejects.toThrow("角色预设权限不属于模块 known:other.item.read");
61
126
 
62
127
  await expect(
63
128
  initializeModuleHosts({
64
- modules: [{ name: "known" }],
129
+ modules: [manifest("known")],
65
130
  initializers: [
66
131
  initializer("known", (host) => {
67
- host.registerUploadPolicy(policy("known.attachment"));
68
- host.registerUploadPolicy(policy("known.attachment"));
132
+ host.registerRolePresets([
133
+ {
134
+ key: "known.manager",
135
+ label: "管理员",
136
+ permissionKeys: ["known.item.manage"],
137
+ },
138
+ ]);
69
139
  }),
70
140
  ],
71
141
  }),
72
- ).rejects.toThrow("上传用途重复注册:known.attachment");
142
+ ).rejects.toThrow("角色预设引用了模块 known 未声明权限:known.item.manage");
73
143
 
74
144
  await expect(
75
145
  initializeModuleHosts({
76
- modules: [],
77
- initializers: [],
78
- baseUploadPolicies: [policy("platform.file"), policy("platform.file")],
146
+ modules: [manifest("known")],
147
+ initializers: [
148
+ initializer("known", (host) => {
149
+ host.registerSearchProviders([
150
+ {
151
+ key: "other.search",
152
+ providerVersion: "0.1.0",
153
+ search: () => Promise.resolve({ hits: [] }),
154
+ health: () =>
155
+ Promise.resolve({
156
+ status: "healthy",
157
+ checkedAt: new Date().toISOString(),
158
+ }),
159
+ },
160
+ ]);
161
+ }),
162
+ ],
79
163
  }),
80
- ).rejects.toThrow("上传用途重复注册:platform.file");
164
+ ).rejects.toThrow("搜索 Provider不属于模块 known:other.search");
81
165
 
82
166
  await expect(
83
167
  initializeModuleHosts({
84
- modules: [{ name: "known" }],
168
+ modules: [manifest("known")],
85
169
  initializers: [
86
170
  initializer("known", (host) => {
87
- const definition = {
88
- key: "known.refresh",
89
- payload: { parse: () => ({}) },
90
- execute: () => undefined,
91
- };
92
- host.registerDurableHandler(definition);
93
- host.registerDurableHandler(definition);
171
+ host.registerSearchProviders([
172
+ {
173
+ key: "known.search",
174
+ providerVersion: "0.1.0",
175
+ search: () => Promise.resolve({ hits: [] }),
176
+ health: () =>
177
+ Promise.resolve({
178
+ status: "healthy",
179
+ checkedAt: new Date().toISOString(),
180
+ }),
181
+ },
182
+ ]);
94
183
  }),
95
184
  ],
96
185
  }),
97
- ).rejects.toThrow("任务 handler 重复注册:known.refresh");
186
+ ).rejects.toThrow("搜索 Provider 未在 Manifest 声明:known.search");
98
187
  });
99
188
 
100
189
  // @windy-module work-order begin
@@ -110,9 +199,10 @@ describe("模块 Host 初始化", () => {
110
199
  ),
111
200
  ).toBe(false);
112
201
 
202
+ const workOrder = createWorkOrderModule();
113
203
  const withWorkOrder = await initializeModuleHosts({
114
- modules: [{ name: "work-order" }],
115
- initializers: [workOrderModuleInitializer],
204
+ modules: [workOrder.manifest],
205
+ installations: [workOrder],
116
206
  baseUploadPolicies: platformUploadPolicies,
117
207
  });
118
208
  const storage = createStorageRuntime(
@@ -129,6 +219,35 @@ describe("模块 Host 初始化", () => {
129
219
  });
130
220
  expect(session.purpose).toBe("work-order.ticket.attachment");
131
221
  });
222
+
223
+ test("work-order 初始化器声明式注册路由、任务、Provider 与角色预设", async () => {
224
+ const workOrder = createWorkOrderModule();
225
+ const initialized = await initializeModuleHosts({
226
+ modules: [workOrder.manifest],
227
+ installations: [workOrder],
228
+ });
229
+
230
+ expect(
231
+ initialized.routes.map(
232
+ ({ definition }) => `${definition.method} ${definition.path}`,
233
+ ),
234
+ ).toEqual([
235
+ "GET /api/work-orders",
236
+ "GET /api/work-orders/:id",
237
+ "POST /api/work-orders",
238
+ "PUT /api/work-orders/:id",
239
+ "DELETE /api/work-orders/:id",
240
+ ]);
241
+ expect(
242
+ initialized.taskHandlers.map(({ registration }) => registration.taskKey),
243
+ ).toEqual(["work-order.ticket.snapshot"]);
244
+ expect(
245
+ initialized.searchProviders.map(({ provider }) => provider.key),
246
+ ).toEqual(["work-order.ticket.search"]);
247
+ expect(initialized.rolePresets.map(({ preset }) => preset.key)).toEqual([
248
+ "work-order.ticket-manager",
249
+ ]);
250
+ });
132
251
  // @windy-module work-order end
133
252
  });
134
253
 
@@ -139,13 +258,38 @@ function initializer(
139
258
  return { moduleName, initialize };
140
259
  }
141
260
 
142
- function policy(purpose: string) {
261
+ function manifest(
262
+ name: string,
263
+ overrides: Partial<ModuleManifest> = {},
264
+ ): ModuleManifest {
265
+ return {
266
+ name,
267
+ version: "0.1.0",
268
+ label: name,
269
+ description: `${name} 模块`,
270
+ menus: [],
271
+ adminRoutes: [],
272
+ permissions: [],
273
+ apiPermissions: [],
274
+ features: [],
275
+ migrations: [],
276
+ schemaExports: [],
277
+ tasks: [],
278
+ tools: [],
279
+ providers: [],
280
+ auditActions: [],
281
+ ...overrides,
282
+ };
283
+ }
284
+
285
+ function permission(key: string, module: string) {
143
286
  return {
144
- purpose,
145
- maxByteSize: 1024,
146
- maxPartByteSize: 512,
147
- sessionTtlMs: 60_000,
148
- allowedTypes: { "text/plain": ["txt"] },
149
- signature: "none" as const,
287
+ key,
288
+ label: key,
289
+ module,
290
+ moduleLabel: module,
291
+ resource: "item",
292
+ resourceLabel: "事项",
293
+ action: "read" as const,
150
294
  };
151
295
  }
@@ -1,113 +1,34 @@
1
- import {
2
- DurableHandlerRegistry,
3
- type DurableHandlerDefinition,
4
- } from "@southwind-ai/jobs";
5
- import type {
6
- ModuleHost,
7
- ModuleInitializer,
8
- ModuleUploadPolicy,
9
- } from "@southwind-ai/server-sdk";
10
-
11
- interface InstalledModule {
12
- name: string;
13
- }
14
-
15
- export interface InitializeModuleHostsInput {
16
- modules: readonly InstalledModule[];
17
- initializers: readonly ModuleInitializer[];
18
- baseUploadPolicies?: readonly ModuleUploadPolicy[];
19
- }
20
-
21
- export interface InitializedModuleHosts {
22
- readonly uploadPolicies: readonly ModuleUploadPolicy[];
23
- installDurableHandlers(registry: DurableHandlerRegistry): void;
24
- }
25
-
26
- type HandlerInstaller = (registry: DurableHandlerRegistry) => void;
27
-
28
- export async function initializeModuleHosts(
29
- input: InitializeModuleHostsInput,
30
- ): Promise<InitializedModuleHosts> {
31
- const installedNames = new Set(input.modules.map(({ name }) => name));
32
- const initializedNames = new Set<string>();
33
- const uploadPolicies: ModuleUploadPolicy[] = [];
34
- const uploadPurposes = new Set<string>();
35
- for (const policy of input.baseUploadPolicies ?? []) {
36
- if (uploadPurposes.has(policy.purpose)) {
37
- throw new Error(`上传用途重复注册:${policy.purpose}`);
38
- }
39
- uploadPurposes.add(policy.purpose);
40
- uploadPolicies.push(policy);
41
- }
42
- const handlerKeys = new Set<string>();
43
- const handlerInstallers: HandlerInstaller[] = [];
44
-
45
- for (const initializer of input.initializers) {
46
- const moduleName = initializer.moduleName.trim();
47
- if (!installedNames.has(moduleName)) {
48
- throw new Error(`模块初始化器与已安装模块不匹配:${moduleName}`);
49
- }
50
- if (initializedNames.has(moduleName)) {
51
- throw new Error(`模块初始化器重复注册:${moduleName}`);
52
- }
53
- initializedNames.add(moduleName);
54
- await initializer.initialize(
55
- createScopedHost({
56
- moduleName,
57
- uploadPolicies,
58
- uploadPurposes,
59
- handlerKeys,
60
- handlerInstallers,
61
- }),
62
- );
63
- }
64
-
65
- return {
66
- uploadPolicies: [...uploadPolicies],
67
- installDurableHandlers(registry) {
68
- for (const install of handlerInstallers) install(registry);
69
- },
70
- };
71
- }
72
-
73
- interface ScopedHostState {
74
- moduleName: string;
75
- uploadPolicies: ModuleUploadPolicy[];
76
- uploadPurposes: Set<string>;
77
- handlerKeys: Set<string>;
78
- handlerInstallers: HandlerInstaller[];
79
- }
80
-
81
- function createScopedHost(state: ScopedHostState): ModuleHost {
82
- return {
83
- moduleName: state.moduleName,
84
- registerUploadPolicy(policy) {
85
- requireModuleNamespace("上传用途", policy.purpose, state.moduleName);
86
- if (state.uploadPurposes.has(policy.purpose)) {
87
- throw new Error(`上传用途重复注册:${policy.purpose}`);
88
- }
89
- state.uploadPurposes.add(policy.purpose);
90
- state.uploadPolicies.push(policy);
91
- },
92
- registerDurableHandler<T>(definition: DurableHandlerDefinition<T>) {
93
- requireModuleNamespace("任务 handler", definition.key, state.moduleName);
94
- if (state.handlerKeys.has(definition.key)) {
95
- throw new Error(`任务 handler 重复注册:${definition.key}`);
96
- }
97
- state.handlerKeys.add(definition.key);
98
- state.handlerInstallers.push((registry) => {
99
- registry.register(definition);
100
- });
101
- },
102
- };
103
- }
104
-
105
- function requireModuleNamespace(
106
- kind: string,
107
- value: string,
108
- moduleName: string,
109
- ): void {
110
- if (value !== moduleName && !value.startsWith(`${moduleName}.`)) {
111
- throw new Error(`${kind}不属于模块 ${moduleName}:${value}`);
112
- }
113
- }
1
+ export {
2
+ initializeModuleHosts,
3
+ type InitializedModuleHosts,
4
+ type InitializeModuleHostsInput,
5
+ type RegisteredModuleRolePreset,
6
+ type RegisteredModuleRoute,
7
+ type RegisteredModuleSearchProvider,
8
+ type RegisteredModuleTaskHandler,
9
+ type RegisteredModuleToolHandler,
10
+ } from "./module-host/initialize.js";
11
+ export {
12
+ buildModuleRequestContext,
13
+ requestActorFromModule,
14
+ toModuleActor,
15
+ } from "./module-host/request-context.js";
16
+ export {
17
+ installModuleRoutes,
18
+ type ModuleRouteInstallContext,
19
+ } from "./module-host/route-installer.js";
20
+ export {
21
+ moduleRolePresetId,
22
+ synchronizeModuleRolePresets,
23
+ } from "./module-host/role-presets.js";
24
+ export {
25
+ adaptModuleSearchProvider,
26
+ adaptModuleSearchProviders,
27
+ } from "./module-host/search-providers.js";
28
+ export {
29
+ createModuleStoragePort,
30
+ type ModuleStorageScope,
31
+ type ModuleStorageServices,
32
+ } from "./module-host/storage-port.js";
33
+ export { createModuleTaskDefinitions } from "./module-host/task-definitions.js";
34
+ export { createModuleToolHandlerMap } from "./module-host/tool-handlers.js";
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { entitlementKey, featureKey } from "@southwind-ai/shared";
2
+ import {
3
+ entitlementKey,
4
+ featureKey,
5
+ licenseVersionKey,
6
+ } from "@southwind-ai/shared";
3
7
  import { evaluateAccessGuards } from "./access-guards.js";
4
8
  import { createServerRuntime } from "./runtime.js";
5
9
 
@@ -81,6 +85,113 @@ describe("access guard pipeline", () => {
81
85
  expect("edition" in context.license!).toBe(false);
82
86
  });
83
87
 
88
+ test("Feature 依赖未启用时拒绝并记录 security.denied,依赖启用后放行", async () => {
89
+ const dependency = {
90
+ key: featureKey("business.base"),
91
+ label: "业务基础",
92
+ module: "business",
93
+ visible: "visible" as const,
94
+ enabled: true,
95
+ stage: "stable" as const,
96
+ };
97
+ const capability = {
98
+ ...dependency,
99
+ key: featureKey("business.report"),
100
+ dependencies: [dependency.key],
101
+ };
102
+ let dependencyEnabled = false;
103
+ const runtime = createServerRuntime(
104
+ {},
105
+ {
106
+ actorResolver: { resolve: () => actor(["business.report.read"]) },
107
+ licenseResolver: { resolve: () => ({ entitlements: [] }) },
108
+ featureCatalog: [dependency, capability],
109
+ featureResolver: {
110
+ resolve: (key) => ({
111
+ key,
112
+ enabled: key === dependency.key ? dependencyEnabled : true,
113
+ visible: "visible" as const,
114
+ }),
115
+ },
116
+ },
117
+ );
118
+ const policy = {
119
+ permission: { permissionKey: "business.report.read" },
120
+ feature: capability,
121
+ };
122
+
123
+ const deniedContext = await runtime.createGuardContext(
124
+ new Request("http://local"),
125
+ );
126
+ const denied = await evaluateAccessGuards(runtime, deniedContext, policy);
127
+
128
+ expect(denied.decision.reason).toBe("dependency-missing");
129
+ expect(denied.feature?.auditEvent?.action).toBe("security.denied");
130
+ const auditEvents = await runtime.listAuditEvents({
131
+ page: 1,
132
+ pageSize: 10,
133
+ });
134
+ expect(
135
+ auditEvents.items.some(
136
+ (event) =>
137
+ event.action === "security.denied" &&
138
+ event.target?.id === "business.report",
139
+ ),
140
+ ).toBe(true);
141
+
142
+ dependencyEnabled = true;
143
+ const allowedContext = await runtime.createGuardContext(
144
+ new Request("http://local"),
145
+ );
146
+ const allowed = await evaluateAccessGuards(runtime, allowedContext, policy);
147
+
148
+ expect(allowed.decision.reason).toBe("allowed");
149
+ });
150
+
151
+ test("Feature 依赖不在当前 License 版本内时 fail closed", async () => {
152
+ const dependency = {
153
+ key: featureKey("business.advanced-base"),
154
+ label: "高级业务基础",
155
+ module: "business",
156
+ visible: "visible" as const,
157
+ enabled: true,
158
+ stage: "stable" as const,
159
+ allowedLicenseVersions: [licenseVersionKey("advanced")],
160
+ };
161
+ const capability = {
162
+ ...dependency,
163
+ key: featureKey("business.report"),
164
+ allowedLicenseVersions: [licenseVersionKey("standard")],
165
+ dependencies: [dependency.key],
166
+ };
167
+ const runtime = createServerRuntime(
168
+ {},
169
+ {
170
+ actorResolver: { resolve: () => actor(["business.report.read"]) },
171
+ licenseResolver: {
172
+ resolve: () => ({
173
+ versionKey: licenseVersionKey("standard"),
174
+ entitlements: [],
175
+ }),
176
+ },
177
+ featureCatalog: [dependency, capability],
178
+ featureResolver: {
179
+ resolve: (key) => ({ key, enabled: true, visible: "visible" }),
180
+ },
181
+ },
182
+ );
183
+ const context = await runtime.createGuardContext(
184
+ new Request("http://local"),
185
+ );
186
+
187
+ const result = await evaluateAccessGuards(runtime, context, {
188
+ permission: { permissionKey: "business.report.read" },
189
+ feature: capability,
190
+ });
191
+
192
+ expect(result.decision.reason).toBe("dependency-missing");
193
+ });
194
+
84
195
  test("缺少 Entitlement 时不读取 Feature 状态,开关不能绕过商业授权", async () => {
85
196
  let featureResolveCount = 0;
86
197
  const runtime = createServerRuntime(