create-windy 0.3.19 → 0.4.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.
Files changed (213) hide show
  1. package/README.md +28 -13
  2. package/dist/cli.js +783 -280
  3. package/package.json +1 -1
  4. package/template/.agents/skills/windy-business-module/SKILL.md +34 -10
  5. package/template/.windy-template.json +2 -2
  6. package/template/AGENTS.md +18 -8
  7. package/template/README.md +32 -22
  8. package/template/apps/server/Dockerfile +1 -0
  9. package/template/apps/server/package.json +1 -0
  10. package/template/apps/server/scripts/validate-module-composition-once.ts +4 -0
  11. package/template/apps/server/scripts/validate-module-composition.ts +28 -0
  12. package/template/apps/server/src/agent/data-scope-routes.test.ts +20 -5
  13. package/template/apps/server/src/agent/routes.test.ts +20 -7
  14. package/template/apps/server/src/agent/routes.ts +14 -0
  15. package/template/apps/server/src/agent/runtime-bootstrap.ts +22 -6
  16. package/template/apps/server/src/agent/sandbox-worker.ts +2 -2
  17. package/template/apps/server/src/agent/unavailable-routes.ts +33 -0
  18. package/template/apps/server/src/app/server-composition.ts +56 -0
  19. package/template/apps/server/src/application-services.ts +2 -0
  20. package/template/apps/server/src/bulk-data/service.ts +14 -0
  21. package/template/apps/server/src/data-governance/export/authorization-port.ts +1 -1
  22. package/template/apps/server/src/data-governance/export/postgres-api-fixture.ts +18 -2
  23. package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +10 -9
  24. package/template/apps/server/src/data-governance/export/routes-test-fixture.ts +6 -8
  25. package/template/apps/server/src/data-governance/user-plaintext-routes.test.ts +4 -2
  26. package/template/apps/server/src/feature/routes.authorization.test.ts +26 -1
  27. package/template/apps/server/src/feature/routes.ts +33 -15
  28. package/template/apps/server/src/foundation-activation.test.ts +60 -0
  29. package/template/apps/server/src/foundation.ts +64 -5
  30. package/template/apps/server/src/guards.test.ts +23 -0
  31. package/template/apps/server/src/guards.ts +12 -0
  32. package/template/apps/server/src/index.ts +28 -34
  33. package/template/apps/server/src/installed-business-modules.ts +13 -24
  34. package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +1 -0
  35. package/template/apps/server/src/jobs/legacy-adapter.test.ts +106 -0
  36. package/template/apps/server/src/jobs/legacy-service-runtime.ts +40 -0
  37. package/template/apps/server/src/license/activation.test.ts +30 -0
  38. package/template/apps/server/src/license/activation.ts +26 -0
  39. package/template/apps/server/src/license/restriction.test.ts +16 -0
  40. package/template/apps/server/src/license/routes-offline-code.test.ts +6 -2
  41. package/template/apps/server/src/license/routes.ts +20 -4
  42. package/template/apps/server/src/license/runtime-service.test.ts +10 -2
  43. package/template/apps/server/src/module-bootstrap.ts +14 -23
  44. package/template/apps/server/src/module-composition.test.ts +43 -2
  45. package/template/apps/server/src/module-composition.ts +29 -0
  46. package/template/apps/server/src/module-host/durable-context-guard.test.ts +172 -0
  47. package/template/apps/server/src/module-host/durable-context-unavailable.test.ts +50 -1
  48. package/template/apps/server/src/module-host/durable-context.test.ts +42 -25
  49. package/template/apps/server/src/module-host/durable-context.ts +42 -2
  50. package/template/apps/server/src/module-host/durable-handler-registration.ts +96 -5
  51. package/template/apps/server/src/module-host/implementation-completeness.test.ts +185 -0
  52. package/template/apps/server/src/module-host/implementation-completeness.ts +132 -0
  53. package/template/apps/server/src/module-host/initialize-contracts.ts +14 -0
  54. package/template/apps/server/src/module-host/initialize.test.ts +78 -2
  55. package/template/apps/server/src/module-host/initialize.ts +15 -4
  56. package/template/apps/server/src/module-host/project-attachments.test.ts +92 -0
  57. package/template/apps/server/src/module-host/project-attachments.ts +58 -0
  58. package/template/apps/server/src/module-host/project-composition-package.test.ts +186 -0
  59. package/template/apps/server/src/module-host/project-composition.test.ts +260 -0
  60. package/template/apps/server/src/module-host/project-composition.ts +311 -0
  61. package/template/apps/server/src/module-host/request-context.ts +3 -0
  62. package/template/apps/server/src/module-host/route-declaration.ts +15 -6
  63. package/template/apps/server/src/module-host/route-installer.test.ts +15 -2
  64. package/template/apps/server/src/module-host/route-installer.ts +12 -2
  65. package/template/apps/server/src/module-host/route-registration.test.ts +13 -4
  66. package/template/apps/server/src/module-host/runtime-composition-compatibility.test.ts +63 -0
  67. package/template/apps/server/src/module-host/runtime-composition-compatibility.ts +46 -0
  68. package/template/apps/server/src/module-host/runtime-composition.ts +34 -0
  69. package/template/apps/server/src/module-host/search-provider-registration.test.ts +64 -0
  70. package/template/apps/server/src/module-host/search-provider-registration.ts +35 -0
  71. package/template/apps/server/src/module-host/static-route-authorization.test.ts +99 -0
  72. package/template/apps/server/src/module-host/static-route-authorization.ts +90 -0
  73. package/template/apps/server/src/module-host/task-definitions.ts +1 -0
  74. package/template/apps/server/src/module-host/user-directory-composition.test.ts +4 -1
  75. package/template/apps/server/src/module-host.test.ts +2 -0
  76. package/template/apps/server/src/module-preflight.test.ts +289 -0
  77. package/template/apps/server/src/module-preflight.ts +96 -0
  78. package/template/apps/server/src/module-runtime-validation.ts +1 -0
  79. package/template/apps/server/src/platform-app-bootstrap.ts +50 -0
  80. package/template/apps/server/src/platform-http-runtime.ts +32 -7
  81. package/template/apps/server/src/route-guards.test.ts +22 -0
  82. package/template/apps/server/src/route-guards.ts +10 -3
  83. package/template/apps/server/src/runtime-audit-query.ts +50 -0
  84. package/template/apps/server/src/runtime-feature.test.ts +46 -0
  85. package/template/apps/server/src/runtime-feature.ts +5 -0
  86. package/template/apps/server/src/runtime.ts +10 -49
  87. package/template/apps/server/src/scheduler/audit-action.test.ts +49 -0
  88. package/template/apps/server/src/scheduler/audit.ts +1 -1
  89. package/template/apps/server/src/scheduler/execution-recorder.ts +10 -5
  90. package/template/apps/server/src/scheduler/recovery-service.test.ts +4 -3
  91. package/template/apps/server/src/scheduler/service-idle.test.ts +4 -3
  92. package/template/apps/server/src/scheduler/service.test.ts +1 -0
  93. package/template/apps/server/src/scheduler/service.ts +18 -21
  94. package/template/apps/server/src/scheduler/types.ts +5 -1
  95. package/template/apps/server/src/startup-composition-validation.test.ts +35 -0
  96. package/template/apps/server/src/startup-composition-validation.ts +54 -0
  97. package/template/apps/server/src/startup-preflight.test.ts +36 -0
  98. package/template/apps/server/src/startup-preflight.ts +37 -0
  99. package/template/apps/server/src/system/audit-routes.test.ts +9 -3
  100. package/template/apps/server/src/system/department-routes.test.ts +5 -3
  101. package/template/apps/server/src/system/feature-routes.test.ts +4 -3
  102. package/template/apps/server/src/system/interface-catalog.test.ts +5 -3
  103. package/template/apps/server/src/system/interface-catalog.ts +3 -0
  104. package/template/apps/server/src/system/menu-routes.test.ts +5 -3
  105. package/template/apps/server/src/system/route-test-runtime.ts +24 -0
  106. package/template/apps/server/src/system/routes-validation.test.ts +7 -3
  107. package/template/apps/server/src/system/routes.test.ts +21 -11
  108. package/template/apps/server/src/system/routes.ts +11 -2
  109. package/template/apps/server/src/work-order/foundation-modules.test.ts +6 -4
  110. package/template/apps/server/src/work-order/routes.test.ts +8 -0
  111. package/template/apps/server/src/work-order/task.test.ts +6 -1
  112. package/template/apps/web/Dockerfile +5 -0
  113. package/template/apps/web/package.json +1 -0
  114. package/template/apps/web/scripts/validate-admin-ui-composition.ts +106 -0
  115. package/template/apps/web/src/app/web-composition.ts +24 -0
  116. package/template/apps/web/src/app/web-composition.webtest.ts +15 -0
  117. package/template/apps/web/src/layout/AppSidebar.vue +8 -1
  118. package/template/apps/web/src/layout/NavigationSearchDialog.vue +8 -1
  119. package/template/apps/web/src/layout/navigation.ts +29 -4
  120. package/template/apps/web/src/layout/navigation.webtest.ts +51 -0
  121. package/template/apps/web/src/pages/dashboard/dashboard-presenter.ts +6 -1
  122. package/template/apps/web/src/pages/work-orders/WorkOrderListPage.vue +202 -0
  123. package/template/apps/web/src/pages/work-orders/WorkOrderListPage.webtest.ts +51 -0
  124. package/template/apps/web/src/router/admin-extension-loader.ts +44 -0
  125. package/template/apps/web/src/router/admin-extension-loader.webtest.ts +49 -0
  126. package/template/apps/web/src/router/admin-ui-composition-plugin.ts +33 -0
  127. package/template/apps/web/src/router/admin-ui-composition-plugin.webtest.ts +20 -0
  128. package/template/apps/web/src/router/admin-ui-composition.ts +313 -0
  129. package/template/apps/web/src/router/admin-ui-composition.webtest.ts +225 -0
  130. package/template/apps/web/src/router/admin-ui-module-registry-plugin.ts +55 -0
  131. package/template/apps/web/src/router/admin-ui-module-registry-plugin.webtest.ts +46 -0
  132. package/template/apps/web/src/router/business-routes.ts +32 -45
  133. package/template/apps/web/src/router/business-routes.webtest.ts +40 -44
  134. package/template/apps/web/src/router/index.ts +64 -54
  135. package/template/apps/web/src/router/manifest-routes.ts +27 -7
  136. package/template/apps/web/src/router/manifest-routes.webtest.ts +10 -2
  137. package/template/apps/web/src/router/platform-admin-route-bindings.ts +58 -0
  138. package/template/apps/web/src/router/reserved-route-paths.ts +14 -0
  139. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +10 -4
  140. package/template/apps/web/src/router/web-composition-host.ts +20 -0
  141. package/template/apps/web/src/services/work-order-api.ts +16 -0
  142. package/template/apps/web/src/services/work-order-api.webtest.ts +21 -0
  143. package/template/apps/web/src/virtual-admin-ui-modules.d.ts +5 -0
  144. package/template/apps/web/vite.config.ts +22 -1
  145. package/template/docs/architecture/migration-bundle.md +10 -7
  146. package/template/docs/architecture/module-manifest-composition.md +85 -3
  147. package/template/docs/architecture/search-provider-protocol.md +6 -1
  148. package/template/docs/architecture/server-module-interface.md +70 -7
  149. package/template/docs/development/business-web-routes.md +53 -50
  150. package/template/docs/development/module-lifecycle.md +48 -54
  151. package/template/docs/platform/navigation-registry.md +5 -4
  152. package/template/package.json +1 -1
  153. package/template/packages/config/package.json +1 -1
  154. package/template/packages/database/package.json +1 -1
  155. package/template/packages/example-work-order/package.json +6 -0
  156. package/template/packages/example-work-order/src/admin-page.ts +78 -0
  157. package/template/packages/example-work-order/src/admin.ts +35 -0
  158. package/template/packages/example-work-order/src/manifest.test.ts +62 -5
  159. package/template/packages/example-work-order/src/manifest.ts +54 -7
  160. package/template/packages/example-work-order/src/migration-bundle.ts +8 -0
  161. package/template/packages/example-work-order/src/server-module.ts +2 -2
  162. package/template/packages/jobs/package.json +1 -1
  163. package/template/packages/modules/index.ts +4 -0
  164. package/template/packages/modules/package.json +3 -2
  165. package/template/packages/modules/src/admin-ui-entry.ts +8 -0
  166. package/template/packages/modules/src/admin-ui-manifest.test.ts +52 -0
  167. package/template/packages/modules/src/admin-ui-manifest.ts +53 -0
  168. package/template/packages/modules/src/capability-audit-validation.test.ts +127 -0
  169. package/template/packages/modules/src/capability-audit-validation.ts +38 -0
  170. package/template/packages/modules/src/capability-guard-validation.ts +148 -0
  171. package/template/packages/modules/src/capability-key-validation.test.ts +150 -0
  172. package/template/packages/modules/src/composition-fixtures.test-support.ts +7 -5
  173. package/template/packages/modules/src/composition.test.ts +74 -9
  174. package/template/packages/modules/src/composition.ts +8 -0
  175. package/template/packages/modules/src/manifest.ts +1 -0
  176. package/template/packages/modules/src/migration-bundle-validation.test.ts +23 -0
  177. package/template/packages/modules/src/module-host-range.test.ts +57 -0
  178. package/template/packages/modules/src/module-host-range.ts +125 -0
  179. package/template/packages/modules/src/project-module-assembly.test.ts +133 -0
  180. package/template/packages/modules/src/project-module-assembly.ts +126 -0
  181. package/template/packages/modules/src/system-admin-routes.ts +6 -5
  182. package/template/packages/modules/src/system-admin-ui-catalog.ts +72 -0
  183. package/template/packages/modules/src/system-agent-tools.ts +2 -2
  184. package/template/packages/modules/src/system-api-binding.ts +43 -9
  185. package/template/packages/modules/src/system-api-control-plane.ts +202 -0
  186. package/template/packages/modules/src/system-api-data-governance.ts +4 -2
  187. package/template/packages/modules/src/system-api-operational.ts +57 -8
  188. package/template/packages/modules/src/system-api-permissions.ts +115 -21
  189. package/template/packages/modules/src/system-api-watermark.ts +3 -0
  190. package/template/packages/modules/src/system-audit-actions.ts +7 -0
  191. package/template/packages/modules/src/system-data-governance.ts +5 -2
  192. package/template/packages/modules/src/system-features.ts +31 -0
  193. package/template/packages/modules/src/system-governed-export-bindings.ts +8 -4
  194. package/template/packages/modules/src/system-module-catalog.test.ts +39 -4
  195. package/template/packages/modules/src/system-module-catalog.ts +68 -7
  196. package/template/packages/modules/src/system-module-tasks.ts +8 -1
  197. package/template/packages/modules/src/system-modules-access.test.ts +4 -0
  198. package/template/packages/modules/src/system-modules.test.ts +10 -2
  199. package/template/packages/modules/src/system-modules.ts +6 -5
  200. package/template/packages/modules/src/system-permissions.ts +4 -0
  201. package/template/packages/modules/user/module.ts +19 -2
  202. package/template/packages/server-sdk/index.ts +8 -0
  203. package/template/packages/server-sdk/package.json +2 -1
  204. package/template/packages/server-sdk/src/durable-handler.ts +16 -1
  205. package/template/packages/server-sdk/src/module-adapter.test.ts +44 -0
  206. package/template/packages/server-sdk/src/module-adapter.ts +59 -0
  207. package/template/packages/server-sdk/src/route-definition.ts +3 -2
  208. package/template/packages/shared/package.json +1 -1
  209. package/template/packages/shared/src/audit.test.ts +7 -0
  210. package/template/packages/shared/src/audit.ts +16 -0
  211. package/template/packages/shared/src/migration-bundle-validation.ts +9 -11
  212. package/template/packages/shared/src/rbac.ts +2 -0
  213. package/template/apps/web/src/app/business-routes.ts +0 -22
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ModuleManifest } from "@southwind-ai/modules";
3
+ import { featureKey } from "@southwind-ai/shared";
3
4
  import { Elysia } from "elysia";
4
5
  import { validateServerModuleComposition } from "./module-composition.js";
5
6
 
@@ -83,6 +84,35 @@ describe("Server Module Composition", () => {
83
84
  "AI Operation Handler已注册但未在 Manifest 声明:system.unknown-operation",
84
85
  );
85
86
  });
87
+
88
+ test("平台静态 HTTP Route 也必须在 Manifest 声明", () => {
89
+ expect(() =>
90
+ validateServerModuleComposition({
91
+ modules: [fixture()],
92
+ routes: [
93
+ {
94
+ method: "POST",
95
+ path: "/api/auth/users/:id/password",
96
+ },
97
+ { method: "GET", path: "/api/demo/ghost" },
98
+ ],
99
+ }),
100
+ ).toThrow("HTTP Route 已注册但未在 Manifest 声明:GET /api/demo/ghost");
101
+
102
+ expect(() =>
103
+ validateServerModuleComposition({
104
+ modules: [fixture()],
105
+ routes: [
106
+ {
107
+ method: "POST",
108
+ path: "/api/auth/users/:id/password",
109
+ },
110
+ { method: "GET", path: "/api/openapi.json" },
111
+ ],
112
+ frameworkRoutes: ["GET /api/openapi.json"],
113
+ }),
114
+ ).not.toThrow();
115
+ });
86
116
  });
87
117
 
88
118
  function fixture(): ModuleManifest {
@@ -110,14 +140,25 @@ function fixture(): ModuleManifest {
110
140
  method: "POST",
111
141
  path: "/api/auth/users/:id/password",
112
142
  permissionKey: "system.auth.manage",
143
+ featureKey: "system.auth",
144
+ auditAction: "auth.password.update",
145
+ },
146
+ ],
147
+ features: [
148
+ {
149
+ key: featureKey("system.auth"),
150
+ label: "账号认证",
151
+ module: "system",
152
+ visible: "visible",
153
+ enabled: true,
154
+ stage: "stable",
113
155
  },
114
156
  ],
115
- features: [],
116
157
  migrations: [],
117
158
  schemaExports: [],
118
159
  tasks: [],
119
160
  tools: [],
120
161
  providers: [],
121
- auditActions: [],
162
+ auditActions: ["auth.password.update"],
122
163
  };
123
164
  }
@@ -23,6 +23,11 @@ export interface ModuleImplementationRegistrations {
23
23
  export interface ServerCompositionInput {
24
24
  modules: readonly ModuleManifest[];
25
25
  routes: readonly RegisteredHttpRoute[];
26
+ /**
27
+ * 仅用于框架生成且不属于产品 API 的路由,例如 OpenAPI 文档。
28
+ * 产品、运维、认证与恢复入口仍必须进入 Manifest。
29
+ */
30
+ frameworkRoutes?: readonly string[];
26
31
  tasks?: readonly { key: string }[];
27
32
  tools?: ReadonlyMap<string, unknown>;
28
33
  aiOperations?: readonly string[];
@@ -33,6 +38,11 @@ export interface ServerCompositionInput {
33
38
  export function validateServerModuleComposition(
34
39
  input: ServerCompositionInput,
35
40
  ): void {
41
+ validateHttpRouteRegistration(
42
+ input.modules,
43
+ input.routes.map(apiRouteKey),
44
+ input.frameworkRoutes ?? [],
45
+ );
36
46
  validateModuleComposition(input.modules, {
37
47
  apiRoutes: input.routes.map(apiRouteKey),
38
48
  tasks: (input.tasks || []).map(({ key }) => key),
@@ -45,6 +55,25 @@ export function validateServerModuleComposition(
45
55
  }
46
56
  }
47
57
 
58
+ function validateHttpRouteRegistration(
59
+ modules: readonly ModuleManifest[],
60
+ registeredRoutes: readonly string[],
61
+ frameworkRoutes: readonly string[],
62
+ ): void {
63
+ const declared = new Set(
64
+ modules.flatMap((module) => module.apiPermissions.map(apiRouteKey)),
65
+ );
66
+ const exemptions = new Set(frameworkRoutes);
67
+ const unknown = registeredRoutes.filter(
68
+ (route) => !declared.has(route) && !exemptions.has(route),
69
+ );
70
+ if (unknown.length) {
71
+ throw new Error(
72
+ `HTTP Route 已注册但未在 Manifest 声明:${[...new Set(unknown)].join("、")}`,
73
+ );
74
+ }
75
+ }
76
+
48
77
  function validateRegistrationConsistency(
49
78
  modules: readonly ModuleManifest[],
50
79
  registrations: ModuleImplementationRegistrations,
@@ -0,0 +1,172 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { DurableHandlerContext } from "@southwind-ai/jobs";
3
+ import { featureKey } from "@southwind-ai/shared";
4
+ import { buildActorScopeContexts } from "../data-access/context.js";
5
+ import { captureAuthorizationScope } from "../data-access/scope-snapshot.js";
6
+ import type { RequestActor } from "../guards.js";
7
+ import { createServerRuntime } from "../runtime.js";
8
+ import { createModuleDurableContextualizer } from "./durable-context.js";
9
+ import { initializeModuleHosts } from "./initialize.js";
10
+ import { manifest } from "./test-fixtures.js";
11
+
12
+ const feature = {
13
+ key: featureKey("evidence.catalog"),
14
+ label: "电子物证",
15
+ module: "evidence",
16
+ visible: "visible" as const,
17
+ enabled: true,
18
+ stage: "stable" as const,
19
+ };
20
+ const moduleManifest = manifest("evidence", {
21
+ features: [feature],
22
+ permissions: [
23
+ {
24
+ key: "evidence.read",
25
+ label: "读取物证",
26
+ module: "evidence",
27
+ moduleLabel: "物证",
28
+ resource: "file",
29
+ resourceLabel: "文件",
30
+ action: "read",
31
+ },
32
+ ],
33
+ tasks: [
34
+ {
35
+ key: "evidence.refresh",
36
+ label: "刷新物证",
37
+ description: "刷新物证目录。",
38
+ permissionKey: "evidence.read",
39
+ featureKey: "evidence.catalog",
40
+ auditAction: "evidence.file.read",
41
+ intervalSeconds: 60,
42
+ maxAttempts: 3,
43
+ retryDelaySeconds: 5,
44
+ },
45
+ ],
46
+ auditActions: ["evidence.file.read"],
47
+ });
48
+
49
+ describe("模块 Durable Handler 逐次 Guard", () => {
50
+ test("重试与恢复会重新读取当前 Feature 和 License 状态", async () => {
51
+ let featureEnabled = true;
52
+ const featureChanged = await fixture({
53
+ featureResolver: {
54
+ resolve: (key) => ({
55
+ key,
56
+ enabled: featureEnabled,
57
+ visible: "visible",
58
+ }),
59
+ },
60
+ });
61
+ const persistedJob = job(featureChanged.actor);
62
+ await featureChanged.contextualize({
63
+ claim: { job: persistedJob },
64
+ moduleName: "evidence",
65
+ context: baseContext(1),
66
+ });
67
+ featureEnabled = false;
68
+ await expect(
69
+ featureChanged.contextualize({
70
+ claim: { job: persistedJob },
71
+ moduleName: "evidence",
72
+ context: baseContext(2),
73
+ }),
74
+ ).rejects.toMatchObject({
75
+ code: "MODULE_DURABLE_GUARD_DENIED",
76
+ details: { reason: "feature-disabled" },
77
+ });
78
+
79
+ const restricted = await fixture({
80
+ licenseEnforced: true,
81
+ licenseResolver: {
82
+ resolve: () => ({ restricted: true, status: "invalid" }),
83
+ },
84
+ });
85
+ await expect(
86
+ restricted.contextualize({
87
+ claim: { job: job(restricted.actor) },
88
+ moduleName: "evidence",
89
+ context: baseContext(3),
90
+ }),
91
+ ).rejects.toMatchObject({
92
+ code: "MODULE_DURABLE_GUARD_DENIED",
93
+ details: { reason: "license-restricted" },
94
+ });
95
+ });
96
+ });
97
+
98
+ async function fixture(
99
+ runtimeOptions: NonNullable<Parameters<typeof createServerRuntime>[1]>,
100
+ ) {
101
+ const hosts = await initializeModuleHosts({
102
+ modules: [moduleManifest],
103
+ installations: [
104
+ {
105
+ manifest: moduleManifest,
106
+ setup(host) {
107
+ host.registerDurableHandler({
108
+ key: "evidence.refresh",
109
+ featureKey: "evidence.catalog",
110
+ permissionKey: "evidence.read",
111
+ auditAction: "evidence.file.read",
112
+ payload: { parse: () => ({}) },
113
+ execute: () => undefined,
114
+ });
115
+ },
116
+ },
117
+ ],
118
+ });
119
+ const runtime = createServerRuntime(
120
+ { NODE_ENV: "test" },
121
+ { ...runtimeOptions, featureCatalog: [feature] },
122
+ );
123
+ const actor = currentActor();
124
+ return {
125
+ actor,
126
+ contextualize: createModuleDurableContextualizer({
127
+ hosts,
128
+ runtime,
129
+ features: [feature],
130
+ resolveActor: async () => actor,
131
+ }),
132
+ };
133
+ }
134
+
135
+ function currentActor(): RequestActor {
136
+ return {
137
+ id: "user-reader",
138
+ type: "user",
139
+ name: "物证读取者",
140
+ roleCodes: ["investigator"],
141
+ permissionKeys: ["evidence.read"],
142
+ ...buildActorScopeContexts("user-reader", "dept-case", [
143
+ {
144
+ id: "role-investigator",
145
+ code: "investigator",
146
+ dataScope: "all",
147
+ departmentIds: [],
148
+ },
149
+ ]),
150
+ };
151
+ }
152
+
153
+ function job(actor: RequestActor) {
154
+ return {
155
+ id: "job-evidence",
156
+ handlerKey: "evidence.refresh",
157
+ requestedBy: actor.id,
158
+ authorizationScope: captureAuthorizationScope(actor.scopeContext).snapshot,
159
+ };
160
+ }
161
+
162
+ function baseContext(attemptNumber: number): DurableHandlerContext {
163
+ return {
164
+ jobId: "job-evidence",
165
+ stageKey: "main",
166
+ attemptNumber,
167
+ signal: new AbortController().signal,
168
+ heartbeat: async () => undefined,
169
+ progress: async () => undefined,
170
+ publishArtifact: async () => undefined,
171
+ };
172
+ }
@@ -6,6 +6,8 @@ import { captureAuthorizationScope } from "../data-access/scope-snapshot.js";
6
6
  import type { RequestActor } from "../guards.js";
7
7
  import { createServerRuntime } from "../runtime.js";
8
8
  import { createModuleDurableContextualizer } from "./durable-context.js";
9
+ import { initializeModuleHosts } from "./initialize.js";
10
+ import { manifest } from "./test-fixtures.js";
9
11
 
10
12
  const feature = {
11
13
  key: featureKey("evidence.catalog"),
@@ -19,8 +21,54 @@ const feature = {
19
21
  describe("模块 Durable Handler 可选端口", () => {
20
22
  test("未挂载 Storage 时仍重建 Actor,并对文件读取 fail-closed", async () => {
21
23
  const actor = currentActor();
24
+ const moduleManifest = manifest("evidence", {
25
+ features: [feature],
26
+ permissions: [
27
+ {
28
+ key: "evidence.read",
29
+ label: "读取物证",
30
+ module: "evidence",
31
+ moduleLabel: "物证",
32
+ resource: "file",
33
+ resourceLabel: "文件",
34
+ action: "read",
35
+ },
36
+ ],
37
+ tasks: [
38
+ {
39
+ key: "evidence.refresh",
40
+ label: "刷新物证",
41
+ description: "刷新物证目录。",
42
+ permissionKey: "evidence.read",
43
+ featureKey: "evidence.catalog",
44
+ auditAction: "evidence.file.read",
45
+ intervalSeconds: 60,
46
+ maxAttempts: 3,
47
+ retryDelaySeconds: 5,
48
+ },
49
+ ],
50
+ auditActions: ["evidence.file.read"],
51
+ });
52
+ const hosts = await initializeModuleHosts({
53
+ modules: [moduleManifest],
54
+ installations: [
55
+ {
56
+ manifest: moduleManifest,
57
+ setup(host) {
58
+ host.registerDurableHandler({
59
+ key: "evidence.refresh",
60
+ featureKey: "evidence.catalog",
61
+ permissionKey: "evidence.read",
62
+ auditAction: "evidence.file.read",
63
+ payload: { parse: () => ({}) },
64
+ execute: () => undefined,
65
+ });
66
+ },
67
+ },
68
+ ],
69
+ });
22
70
  const contextualize = createModuleDurableContextualizer({
23
- hosts: {} as never,
71
+ hosts,
24
72
  runtime: createServerRuntime(
25
73
  { NODE_ENV: "test" },
26
74
  { featureCatalog: [feature] },
@@ -66,6 +114,7 @@ function currentActor(): RequestActor {
66
114
  function job(actor: RequestActor) {
67
115
  return {
68
116
  id: "job-evidence",
117
+ handlerKey: "evidence.refresh",
69
118
  requestedBy: actor.id,
70
119
  authorizationScope: captureAuthorizationScope(actor.scopeContext).snapshot,
71
120
  };
@@ -34,6 +34,19 @@ const evidenceManifest = manifest("evidence", {
34
34
  },
35
35
  ],
36
36
  auditActions: ["evidence.file.read"],
37
+ tasks: [
38
+ {
39
+ key: "evidence.refresh",
40
+ label: "刷新物证",
41
+ description: "刷新物证目录。",
42
+ permissionKey: "evidence.read",
43
+ featureKey: "evidence.catalog",
44
+ auditAction: "evidence.file.read",
45
+ intervalSeconds: 60,
46
+ maxAttempts: 3,
47
+ retryDelaySeconds: 5,
48
+ },
49
+ ],
37
50
  });
38
51
 
39
52
  describe("模块 Durable Handler 授权上下文", () => {
@@ -137,7 +150,7 @@ describe("模块 Durable Handler 授权上下文", () => {
137
150
  // @windy-module system.storage end
138
151
 
139
152
  // @windy-module system.storage begin
140
- test("Scope 变化、权限撤销与资源绑定漂移均 fail-closed", async () => {
153
+ test("Scope 变化与权限撤销均在执行前 fail-closed", async () => {
141
154
  const stable = currentActor();
142
155
  const scopeChanged = currentActor("self");
143
156
  const setupScopeChanged = await setup(() => scopeChanged);
@@ -152,37 +165,32 @@ describe("模块 Durable Handler 授权上下文", () => {
152
165
  retryable: false,
153
166
  });
154
167
 
155
- const permissionRevoked = { ...stable, permissionKeys: [] };
156
- const revoked = await setup(() => permissionRevoked);
157
- const file = await seedFile(revoked.storage);
158
- const context = await revoked.contextualize({
159
- claim: { job: job(stable) },
168
+ let permissionGranted = true;
169
+ const revoked = await setup(() => ({
170
+ ...stable,
171
+ permissionKeys: permissionGranted ? ["evidence.read"] : [],
172
+ }));
173
+ const persistedJob = job(stable);
174
+ await revoked.contextualize({
175
+ claim: { job: persistedJob },
160
176
  moduleName: "evidence",
161
177
  context: baseContext(1),
162
178
  });
163
- const storage = (
164
- context as typeof context & {
165
- storage: {
166
- openResourceFile(input: {
167
- fileId: string;
168
- purpose: string;
169
- resourceType: string;
170
- resourceId: string;
171
- }): Promise<unknown>;
172
- };
173
- }
174
- ).storage;
179
+ permissionGranted = false;
175
180
  await expect(
176
- storage.openResourceFile({
177
- fileId: file.id,
178
- purpose: "evidence.raw",
179
- resourceType: "evidence.evidence-file",
180
- resourceId: "evidence-1",
181
+ revoked.contextualize({
182
+ claim: { job: persistedJob },
183
+ moduleName: "evidence",
184
+ context: baseContext(2),
181
185
  }),
182
- ).rejects.toThrow("资源文件访问未授权");
186
+ ).rejects.toMatchObject({
187
+ code: "MODULE_DURABLE_GUARD_DENIED",
188
+ retryable: false,
189
+ details: { reason: "missing-permission" },
190
+ });
183
191
  expect(revoked.runtime.auditSink.list()).toContainEqual(
184
192
  expect.objectContaining({
185
- action: "evidence.file.read",
193
+ action: "security.denied",
186
194
  outcome: "denied",
187
195
  }),
188
196
  );
@@ -222,6 +230,14 @@ async function setup(
222
230
  context.actor.id === "user-reader",
223
231
  },
224
232
  ]);
233
+ host.registerDurableHandler({
234
+ key: "evidence.refresh",
235
+ featureKey: "evidence.catalog",
236
+ permissionKey: "evidence.read",
237
+ auditAction: "evidence.file.read",
238
+ payload: { parse: () => ({}) },
239
+ execute: () => undefined,
240
+ });
225
241
  },
226
242
  },
227
243
  ],
@@ -278,6 +294,7 @@ function currentActor(scope: "all" | "self" = "all"): RequestActor {
278
294
  function job(actor: RequestActor) {
279
295
  return {
280
296
  id: "job-evidence",
297
+ handlerKey: "evidence.refresh",
281
298
  requestedBy: actor.id,
282
299
  authorizationScope: captureAuthorizationScope(actor.scopeContext).snapshot,
283
300
  };
@@ -11,6 +11,7 @@ import type {
11
11
  ModuleDurableHandlerContext,
12
12
  } from "@southwind-ai/server-sdk";
13
13
  import { ModuleBackgroundAiError } from "@southwind-ai/server-sdk";
14
+ import { evaluateAccessGuards } from "../access-guards.js";
14
15
  import {
15
16
  captureAuthorizationScope,
16
17
  hashAuthorizationScope,
@@ -48,6 +49,7 @@ export function createModuleDurableContextualizer(
48
49
  claim: {
49
50
  job: {
50
51
  id: string;
52
+ handlerKey: string;
51
53
  requestedBy: string;
52
54
  authorizationScope?: AuthorizationScopeSnapshot;
53
55
  };
@@ -76,6 +78,39 @@ export function createModuleDurableContextualizer(
76
78
  request,
77
79
  actor,
78
80
  );
81
+ const binding = services.hosts.durableHandlerOf(
82
+ input.moduleName,
83
+ input.claim.job.handlerKey,
84
+ );
85
+ if (!binding) {
86
+ throw denied("MODULE_DURABLE_CAPABILITY_MISSING", {
87
+ handlerKey: input.claim.job.handlerKey,
88
+ moduleName: input.moduleName,
89
+ });
90
+ }
91
+ const feature = services.hosts
92
+ .manifestOf(input.moduleName)
93
+ .features.find(({ key }) => key === binding.featureKey);
94
+ if (!feature) {
95
+ throw denied("MODULE_DURABLE_CAPABILITY_MISSING", {
96
+ featureKey: binding.featureKey,
97
+ handlerKey: binding.key,
98
+ moduleName: input.moduleName,
99
+ });
100
+ }
101
+ const access = await evaluateAccessGuards(services.runtime, guardContext, {
102
+ permission: { permissionKey: binding.permissionKey },
103
+ feature,
104
+ });
105
+ if (!access.decision.allowed) {
106
+ throw denied("MODULE_DURABLE_GUARD_DENIED", {
107
+ auditAction: binding.auditAction,
108
+ featureKey: binding.featureKey,
109
+ handlerKey: binding.key,
110
+ permissionKey: binding.permissionKey,
111
+ reason: access.decision.reason,
112
+ });
113
+ }
79
114
  const storage = services.storage
80
115
  ? createModuleStoragePort(
81
116
  services.storage,
@@ -121,6 +156,11 @@ function unavailableBackgroundAi(moduleName: string): ModuleBackgroundAiPort {
121
156
  };
122
157
  }
123
158
 
124
- function denied(code: string) {
125
- return new DurableHandlerError(code, "模块后台任务授权已失效", false);
159
+ function denied(code: string, details?: Record<string, unknown>) {
160
+ return new DurableHandlerError(
161
+ code,
162
+ "模块后台任务授权已失效",
163
+ false,
164
+ details,
165
+ );
126
166
  }
@@ -4,30 +4,39 @@ import {
4
4
  type DurableHandlerDefinition,
5
5
  type DurableHandlerRegistry,
6
6
  } from "@southwind-ai/jobs";
7
+ import type { ModuleManifest } from "@southwind-ai/modules";
7
8
  import type {
8
9
  ModuleDurableHandlerContext,
9
10
  ModuleDurableHandlerDefinition,
10
11
  } from "@southwind-ai/server-sdk";
11
12
  import { ModuleBackgroundAiError } from "@southwind-ai/server-sdk";
13
+ import type { RegisteredModuleDurableHandler } from "./initialize-contracts.js";
12
14
 
13
15
  export interface DurableHandlerRegistrationState {
14
16
  readonly handlerKeys: Set<string>;
15
17
  readonly handlerInstallers: Array<(registry: DurableHandlerRegistry) => void>;
18
+ readonly durableHandlers: RegisteredModuleDurableHandler[];
16
19
  }
17
20
 
18
21
  export function registerModuleDurableHandler<T>(
19
- moduleName: string,
22
+ manifest: ModuleManifest,
20
23
  state: DurableHandlerRegistrationState,
21
24
  definition: ModuleDurableHandlerDefinition<T>,
22
25
  ) {
23
- if (state.handlerKeys.has(definition.key)) {
24
- throw new Error(`任务 handler 重复注册:${definition.key}`);
26
+ const moduleName = manifest.name;
27
+ const binding = durableHandlerBinding(definition);
28
+ validateDurableHandlerCapability(manifest, binding);
29
+ if (state.handlerKeys.has(binding.key)) {
30
+ throw new Error(`任务 handler 重复注册:${binding.key}`);
25
31
  }
26
- state.handlerKeys.add(definition.key);
32
+ state.handlerKeys.add(binding.key);
33
+ state.durableHandlers.push(
34
+ Object.freeze({ moduleName, definition: binding }),
35
+ );
27
36
  state.handlerInstallers.push((registry) => {
28
37
  registry.register(
29
38
  {
30
- key: definition.key,
39
+ key: binding.key,
31
40
  payload: definition.payload,
32
41
  async execute(payload, context) {
33
42
  try {
@@ -52,6 +61,88 @@ export function registerModuleDurableHandler<T>(
52
61
  });
53
62
  }
54
63
 
64
+ function validateDurableHandlerCapability(
65
+ manifest: ModuleManifest,
66
+ definition: DurableHandlerBinding,
67
+ ): void {
68
+ if (!manifest.features.some(({ key }) => key === definition.featureKey)) {
69
+ throw new Error(
70
+ `任务 handler Feature 未在 Manifest 注册:${definition.featureKey}`,
71
+ );
72
+ }
73
+ if (
74
+ !manifest.permissions.some(({ key }) => key === definition.permissionKey)
75
+ ) {
76
+ throw new Error(
77
+ `任务 handler Permission 未在 Manifest 注册:${definition.permissionKey}`,
78
+ );
79
+ }
80
+ if (
81
+ !manifest.auditActions.some((action) =>
82
+ typeof action === "string"
83
+ ? action === definition.auditAction
84
+ : action.key === definition.auditAction,
85
+ )
86
+ ) {
87
+ throw new Error(
88
+ `任务 handler Audit Action 未在 Manifest 注册:${definition.auditAction}`,
89
+ );
90
+ }
91
+ if (
92
+ !manifestCapabilities(manifest).some((capability) =>
93
+ sameCapability(capability, definition),
94
+ )
95
+ ) {
96
+ throw new Error(
97
+ `任务 handler 未绑定 Manifest 已声明能力:${definition.key}`,
98
+ );
99
+ }
100
+ }
101
+
102
+ interface ManifestCapability {
103
+ readonly featureKey?: string;
104
+ readonly permissionKey: string;
105
+ readonly auditAction?: string;
106
+ }
107
+
108
+ type DurableHandlerBinding = Pick<
109
+ ModuleDurableHandlerDefinition<unknown>,
110
+ "key" | "featureKey" | "permissionKey" | "auditAction"
111
+ >;
112
+
113
+ function durableHandlerBinding(
114
+ definition: DurableHandlerBinding,
115
+ ): DurableHandlerBinding {
116
+ return Object.freeze({
117
+ key: definition.key,
118
+ featureKey: definition.featureKey,
119
+ permissionKey: definition.permissionKey,
120
+ auditAction: definition.auditAction,
121
+ });
122
+ }
123
+
124
+ function manifestCapabilities(
125
+ manifest: ModuleManifest,
126
+ ): readonly ManifestCapability[] {
127
+ return [
128
+ ...manifest.apiPermissions,
129
+ ...manifest.tasks,
130
+ ...manifest.tools,
131
+ ...(manifest.aiOperations ?? []),
132
+ ];
133
+ }
134
+
135
+ function sameCapability(
136
+ registered: ManifestCapability,
137
+ definition: DurableHandlerBinding,
138
+ ): boolean {
139
+ return (
140
+ registered.featureKey === definition.featureKey &&
141
+ registered.permissionKey === definition.permissionKey &&
142
+ registered.auditAction === definition.auditAction
143
+ );
144
+ }
145
+
55
146
  function requireModuleDurableContext(
56
147
  context: Parameters<DurableHandlerDefinition<unknown>["execute"]>[1],
57
148
  moduleName: string,