create-windy 0.2.34 → 0.3.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 (237) hide show
  1. package/dist/cli.js +167 -17
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/AGENTS.md +5 -0
  5. package/template/README.md +3 -1
  6. package/template/apps/agent-server/Dockerfile +1 -0
  7. package/template/apps/agent-server/README.md +18 -5
  8. package/template/apps/agent-server/adk_web_agents/__init__.py +1 -0
  9. package/template/apps/agent-server/adk_web_agents/safe_debug/__init__.py +3 -0
  10. package/template/apps/agent-server/adk_web_agents/safe_debug/agent.py +40 -0
  11. package/template/apps/agent-server/pyproject.toml +1 -1
  12. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +191 -16
  13. package/template/apps/agent-server/src/southwind_agent_server/adk_failure.py +13 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +225 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +76 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/app.py +1 -2
  17. package/template/apps/agent-server/tests/test_adk_adapter.py +305 -0
  18. package/template/apps/agent-server/tests/test_adk_web_agent.py +12 -0
  19. package/template/apps/server/package.json +4 -1
  20. package/template/apps/server/src/agent/run-facade.ts +2 -7
  21. package/template/apps/server/src/agent/tool-registry.ts +2 -6
  22. package/template/apps/server/src/application-services.ts +50 -0
  23. package/template/apps/server/src/audit/drizzle-log-store.ts +25 -0
  24. package/template/apps/server/src/audit/search-summary.ts +2 -6
  25. package/template/apps/server/src/auth/credential-restriction.ts +1 -0
  26. package/template/apps/server/src/auth/password.ts +30 -21
  27. package/template/apps/server/src/auth/routes.test.ts +44 -3
  28. package/template/apps/server/src/auth/routes.ts +6 -1
  29. package/template/apps/server/src/auth/runtime.ts +4 -1
  30. package/template/apps/server/src/auth/service.ts +25 -7
  31. package/template/apps/server/src/configuration/bootstrap.ts +20 -1
  32. package/template/apps/server/src/configuration/definition.test.ts +71 -0
  33. package/template/apps/server/src/configuration/definition.ts +242 -12
  34. package/template/apps/server/src/configuration/drizzle-repository.ts +36 -2
  35. package/template/apps/server/src/configuration/repository.ts +27 -2
  36. package/template/apps/server/src/configuration/routes.ts +4 -1
  37. package/template/apps/server/src/configuration/service.test.ts +100 -0
  38. package/template/apps/server/src/configuration/service.ts +50 -10
  39. package/template/apps/server/src/data-access/scoped-repository.integration.test.ts +3 -2
  40. package/template/apps/server/src/data-governance/export/service.ts +2 -6
  41. package/template/apps/server/src/example-modules.ts +4 -1
  42. package/template/apps/server/src/guards.ts +18 -5
  43. package/template/apps/server/src/http/request-source.test.ts +85 -0
  44. package/template/apps/server/src/http/request-source.ts +90 -0
  45. package/template/apps/server/src/index.ts +23 -6
  46. package/template/apps/server/src/license/routes.ts +2 -0
  47. package/template/apps/server/src/license/runtime-service.ts +10 -11
  48. package/template/apps/server/src/module-bootstrap.ts +2 -2
  49. package/template/apps/server/src/module-host/request-context.ts +6 -6
  50. package/template/apps/server/src/module-host/storage-scope.ts +2 -9
  51. package/template/apps/server/src/module-host/user-directory-port.ts +2 -6
  52. package/template/apps/server/src/notification/channel-adapter.ts +46 -0
  53. package/template/apps/server/src/notification/delivery-policy.test.ts +59 -0
  54. package/template/apps/server/src/notification/delivery-policy.ts +37 -0
  55. package/template/apps/server/src/notification/delivery-repository.ts +198 -0
  56. package/template/apps/server/src/notification/delivery-worker.test.ts +87 -0
  57. package/template/apps/server/src/notification/delivery-worker.ts +88 -0
  58. package/template/apps/server/src/notification/drizzle-management.ts +180 -0
  59. package/template/apps/server/src/notification/drizzle-query.ts +160 -0
  60. package/template/apps/server/src/notification/drizzle-repository.integration.test.ts +301 -0
  61. package/template/apps/server/src/notification/drizzle-repository.ts +156 -90
  62. package/template/apps/server/src/notification/extension-routes.test.ts +119 -0
  63. package/template/apps/server/src/notification/extension-routes.ts +111 -0
  64. package/template/apps/server/src/notification/memory-delivery-repository.ts +137 -0
  65. package/template/apps/server/src/notification/memory-support.ts +72 -0
  66. package/template/apps/server/src/notification/preference-repository.ts +95 -0
  67. package/template/apps/server/src/notification/recipient-directory.ts +104 -0
  68. package/template/apps/server/src/notification/repository.test.ts +171 -0
  69. package/template/apps/server/src/notification/repository.ts +179 -32
  70. package/template/apps/server/src/notification/revision-routes.test.ts +186 -0
  71. package/template/apps/server/src/notification/route-support.ts +225 -0
  72. package/template/apps/server/src/notification/routes.test.ts +182 -0
  73. package/template/apps/server/src/notification/routes.ts +225 -132
  74. package/template/apps/server/src/notification/service.test.ts +104 -0
  75. package/template/apps/server/src/notification/service.ts +140 -0
  76. package/template/apps/server/src/notification/smtp-adapter.test.ts +65 -0
  77. package/template/apps/server/src/notification/smtp-adapter.ts +68 -0
  78. package/template/apps/server/src/persistence.integration.test.ts +41 -2
  79. package/template/apps/server/src/route-guards.ts +1 -0
  80. package/template/apps/server/src/runtime-audit-list.test.ts +39 -0
  81. package/template/apps/server/src/runtime-development.ts +1 -0
  82. package/template/apps/server/src/runtime.test.ts +23 -9
  83. package/template/apps/server/src/runtime.ts +39 -2
  84. package/template/apps/server/src/scheduler/audit.ts +2 -11
  85. package/template/apps/server/src/scheduler/definitions.test.ts +6 -0
  86. package/template/apps/server/src/scheduler/definitions.ts +7 -0
  87. package/template/apps/server/src/scheduler/recovery-service.ts +2 -11
  88. package/template/apps/server/src/scheduler/routes.ts +2 -7
  89. package/template/apps/server/src/search/opensearch-client.test.ts +45 -0
  90. package/template/apps/server/src/search/opensearch-client.ts +145 -0
  91. package/template/apps/server/src/search/service.ts +2 -6
  92. package/template/apps/server/src/settings/routes.ts +15 -3
  93. package/template/apps/server/src/settings/runtime.ts +10 -1
  94. package/template/apps/server/src/storage/runtime.ts +13 -0
  95. package/template/apps/server/src/system/audit-query.ts +33 -4
  96. package/template/apps/server/src/system/audit-routes.test.ts +58 -4
  97. package/template/apps/server/src/system/audit-routes.ts +6 -5
  98. package/template/apps/server/src/system/department-organization.ts +320 -0
  99. package/template/apps/server/src/system/department-routes.test.ts +207 -48
  100. package/template/apps/server/src/system/department-routes.ts +114 -33
  101. package/template/apps/server/src/system/drizzle-repository.ts +15 -7
  102. package/template/apps/server/src/system/drizzle-scoped-repository.ts +5 -3
  103. package/template/apps/server/src/system/drizzle-system.ts +37 -9
  104. package/template/apps/server/src/system/drizzle-transaction-context.ts +14 -0
  105. package/template/apps/server/src/system/identity-route-config.ts +7 -2
  106. package/template/apps/server/src/system/mutation-runner.ts +23 -0
  107. package/template/apps/server/src/system/organization-postgres.integration.test.ts +176 -0
  108. package/template/apps/server/src/system/resource-access-response.ts +12 -6
  109. package/template/apps/server/src/system/role-department-scope-policy.ts +55 -0
  110. package/template/apps/server/src/system/route-helpers.ts +2 -5
  111. package/template/apps/server/src/system/route-types.ts +12 -3
  112. package/template/apps/server/src/system/routes-validation.test.ts +26 -0
  113. package/template/apps/server/src/system/routes.test.ts +10 -2
  114. package/template/apps/server/src/system/routes.ts +70 -36
  115. package/template/apps/server/src/system/seed.ts +7 -0
  116. package/template/apps/server/src/work-order/data-scope.integration.test.ts +9 -2
  117. package/template/apps/server/src/work-order/opensearch-index.test.ts +97 -0
  118. package/template/apps/server/src/work-order/opensearch-index.ts +313 -0
  119. package/template/apps/server/src/work-order/search-provider.ts +60 -1
  120. package/template/apps/server/src/work-order/service.ts +42 -10
  121. package/template/apps/web/src/app/error-pages.ts +9 -0
  122. package/template/apps/web/src/components/auth/PasswordChangeForm.vue +16 -11
  123. package/template/apps/web/src/components/auth/PasswordChangeForm.webtest.ts +14 -0
  124. package/template/apps/web/src/components/common/FormDialog.vue +16 -2
  125. package/template/apps/web/src/components/common/ModalLayout.webtest.ts +20 -1
  126. package/template/apps/web/src/composables/useGlobalSearch.ts +68 -0
  127. package/template/apps/web/src/composables/usePlatformSettings.ts +8 -7
  128. package/template/apps/web/src/layout/GlobalSearchResults.vue +58 -0
  129. package/template/apps/web/src/layout/NavigationSearchDialog.vue +42 -6
  130. package/template/apps/web/src/layout/NavigationSearchDialog.webtest.ts +71 -0
  131. package/template/apps/web/src/layout/NotificationMenu.vue +73 -31
  132. package/template/apps/web/src/layout/NotificationMenu.webtest.ts +44 -1
  133. package/template/apps/web/src/layout/NotificationMenuItem.vue +61 -0
  134. package/template/apps/web/src/layout/NotificationPreferencesDialog.vue +181 -0
  135. package/template/apps/web/src/layout/NotificationPreferencesDialog.webtest.ts +80 -0
  136. package/template/apps/web/src/lib/date-time.ts +22 -10
  137. package/template/apps/web/src/lib/date-time.webtest.ts +9 -1
  138. package/template/apps/web/src/main.ts +14 -1
  139. package/template/apps/web/src/pages/auth/ChangePasswordPage.vue +16 -1
  140. package/template/apps/web/src/pages/configuration/components/ModuleConfigVersionList.vue +1 -0
  141. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationEditor.vue +10 -0
  142. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationWorkbench.vue +37 -6
  143. package/template/apps/web/src/pages/errors/PlatformErrorPage.vue +115 -0
  144. package/template/apps/web/src/pages/errors/PlatformErrorPage.webtest.ts +44 -0
  145. package/template/apps/web/src/pages/errors/error-page-extension.ts +26 -0
  146. package/template/apps/web/src/pages/system/SystemNotificationsPage.vue +136 -68
  147. package/template/apps/web/src/pages/system/SystemNotificationsPage.webtest.ts +224 -12
  148. package/template/apps/web/src/pages/system/audit-log-presenter.ts +86 -0
  149. package/template/apps/web/src/pages/system/audit-time-range.ts +28 -0
  150. package/template/apps/web/src/pages/system/components/AuditActionCell.vue +10 -69
  151. package/template/apps/web/src/pages/system/components/AuditActionCell.webtest.ts +20 -27
  152. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.vue +134 -0
  153. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.webtest.ts +76 -0
  154. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +116 -52
  155. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +52 -15
  156. package/template/apps/web/src/pages/system/components/NotificationHistoryDialog.vue +129 -0
  157. package/template/apps/web/src/pages/system/components/NotificationManagementCard.vue +91 -0
  158. package/template/apps/web/src/pages/system/components/NotificationPublishDialog.vue +121 -0
  159. package/template/apps/web/src/pages/system/components/NotificationPublishForm.vue +162 -1
  160. package/template/apps/web/src/pages/system/components/SystemResourceActions.vue +68 -0
  161. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +74 -0
  162. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +3 -4
  163. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +91 -98
  164. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +68 -0
  165. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +20 -1
  166. package/template/apps/web/src/pages/system/components/SystemResourceTable.webtest.ts +28 -7
  167. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +210 -0
  168. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +112 -0
  169. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +17 -4
  170. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +79 -0
  171. package/template/apps/web/src/pages/system/composables/useNotificationPermissions.ts +14 -0
  172. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +2 -0
  173. package/template/apps/web/src/pages/system/composables/useSystemResource.webtest.ts +15 -2
  174. package/template/apps/web/src/pages/system/resource-config.ts +12 -4
  175. package/template/apps/web/src/router/error-navigation.ts +50 -0
  176. package/template/apps/web/src/router/error-navigation.webtest.ts +106 -0
  177. package/template/apps/web/src/router/index.ts +71 -0
  178. package/template/apps/web/src/router/index.webtest.ts +29 -0
  179. package/template/apps/web/src/router/manifest-routes.webtest.ts +4 -0
  180. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  181. package/template/apps/web/src/services/auth-api.ts +5 -0
  182. package/template/apps/web/src/services/http.ts +43 -6
  183. package/template/apps/web/src/services/http.webtest.ts +70 -0
  184. package/template/apps/web/src/services/notification-api.ts +85 -2
  185. package/template/apps/web/src/services/notification-api.webtest.ts +110 -0
  186. package/template/apps/web/src/services/search-api.ts +39 -0
  187. package/template/apps/web/src/services/system-api.ts +18 -0
  188. package/template/apps/web/vitest.config.ts +1 -0
  189. package/template/docker-compose.yml +66 -0
  190. package/template/docs/architecture/ai-runtime.md +14 -5
  191. package/template/docs/architecture/search-provider-protocol.md +38 -2
  192. package/template/docs/development/custom-error-pages.md +78 -0
  193. package/template/docs/development/custom-login-page.md +3 -3
  194. package/template/docs/platform/agent-runtime.md +32 -6
  195. package/template/docs/platform/audit-reliability.md +46 -2
  196. package/template/docs/platform/module-configuration.md +13 -4
  197. package/template/docs/platform/notifications.md +67 -11
  198. package/template/docs/platform/organization-membership.md +17 -3
  199. package/template/docs/platform/system-crud-api.md +16 -0
  200. package/template/docs/platform/web-system-crud.md +6 -2
  201. package/template/package.json +4 -0
  202. package/template/packages/config/index.test.ts +45 -0
  203. package/template/packages/config/package.json +1 -1
  204. package/template/packages/config/src/platform.ts +169 -48
  205. package/template/packages/database/drizzle/0035_material_nightshade.sql +10 -0
  206. package/template/packages/database/drizzle/0036_hesitant_speed.sql +35 -0
  207. package/template/packages/database/drizzle/0037_absent_nick_fury.sql +2 -0
  208. package/template/packages/database/drizzle/0038_uneven_wasp.sql +38 -0
  209. package/template/packages/database/drizzle/meta/0035_snapshot.json +7331 -0
  210. package/template/packages/database/drizzle/meta/0036_snapshot.json +7436 -0
  211. package/template/packages/database/drizzle/meta/0037_snapshot.json +7457 -0
  212. package/template/packages/database/drizzle/meta/0038_snapshot.json +7749 -0
  213. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  214. package/template/packages/database/package.json +1 -1
  215. package/template/packages/database/src/schema/identity.ts +7 -0
  216. package/template/packages/database/src/schema/notifications.ts +135 -0
  217. package/template/packages/database/src/schema-notification-delivery.test.ts +54 -0
  218. package/template/packages/database/src/schema-workflow-notification.test.ts +40 -1
  219. package/template/packages/modules/package.json +1 -1
  220. package/template/packages/modules/src/system-api-permissions.ts +54 -0
  221. package/template/packages/modules/src/system-audit-actions.ts +10 -0
  222. package/template/packages/modules/src/system-features.ts +1 -1
  223. package/template/packages/modules/src/system-module-catalog.ts +2 -1
  224. package/template/packages/modules/src/system-modules.test.ts +13 -1
  225. package/template/packages/modules/src/system-modules.ts +12 -0
  226. package/template/packages/modules/src/system-permissions.ts +9 -5
  227. package/template/packages/server-sdk/package.json +1 -1
  228. package/template/packages/shared/package.json +1 -1
  229. package/template/packages/shared/src/audit.ts +25 -0
  230. package/template/packages/shared/src/module-configuration.ts +5 -0
  231. package/template/packages/shared/src/notification.ts +92 -1
  232. package/template/packages/shared/src/password.ts +7 -2
  233. package/template/packages/shared/src/platform-settings.ts +2 -0
  234. package/template/packages/storage/package.json +1 -1
  235. package/template/packages/storage/src/upload-service.test.ts +16 -0
  236. package/template/packages/storage/src/upload-service.ts +5 -1
  237. package/template/scripts/notification-postgres-regression.ts +27 -0
@@ -53,6 +53,10 @@ export function registerAuthRoutes(
53
53
  registerLoginRoutes(app, authService, options);
54
54
  registerOwnSessionRoutes(app, authService, options);
55
55
 
56
+ app.get("/auth/password/policy", () =>
57
+ json(authService.getPasswordPolicy(), 200, { "cache-control": "no-store" }),
58
+ );
59
+
56
60
  app.post("/auth/logout", async (rawContext) => {
57
61
  const context = rawContext as AuthRouteContext;
58
62
  const token = readRequestSessionToken(context.request, cookieName);
@@ -112,10 +116,11 @@ export function registerAuthRoutes(
112
116
  INVALID_PASSWORD: 400,
113
117
  PASSWORD_UNCHANGED: 400,
114
118
  } as const;
119
+ const policy = authService.getPasswordPolicy();
115
120
  const messages = {
116
121
  USER_NOT_FOUND: "当前账号不存在",
117
122
  INVALID_CURRENT_PASSWORD: "当前密码错误",
118
- INVALID_PASSWORD: "新密码必须为 12-128 个字符",
123
+ INVALID_PASSWORD: `新密码必须为 ${policy.minLength}-${policy.maxLength} 个字符`,
119
124
  PASSWORD_UNCHANGED: "新密码不能与当前密码相同",
120
125
  } as const;
121
126
  return json(
@@ -1,10 +1,12 @@
1
1
  import type { AuditEventReader, AuditEventWriter } from "../runtime.js";
2
+ import type { PasswordPolicy } from "@southwind-ai/shared";
2
3
  import { InMemoryAuthRepository, type AuthRepository } from "./repository.js";
3
4
  import { AuthService } from "./service.js";
4
5
 
5
6
  interface AuthRuntimeInput {
6
7
  repository?: AuthRepository;
7
- sessionTtlSeconds: number;
8
+ sessionTtlSeconds: number | (() => number);
9
+ passwordPolicy?: () => PasswordPolicy;
8
10
  auditWriter: AuditEventWriter["write"];
9
11
  auditReader?: AuditEventReader;
10
12
  }
@@ -13,6 +15,7 @@ export function createAuthRuntime(input: AuthRuntimeInput) {
13
15
  const authRepository = input.repository ?? new InMemoryAuthRepository();
14
16
  const authService = new AuthService(authRepository, {
15
17
  sessionTtlSeconds: input.sessionTtlSeconds,
18
+ passwordPolicy: input.passwordPolicy,
16
19
  auditWriter: { write: input.auditWriter },
17
20
  auditReader: input.auditReader,
18
21
  });
@@ -1,6 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { randomUUIDv7 } from "bun";
3
- import type { AuditEvent } from "@southwind-ai/shared";
3
+ import {
4
+ PASSWORD_POLICY,
5
+ type AuditEvent,
6
+ type PasswordPolicy,
7
+ } from "@southwind-ai/shared";
4
8
  import type { RequestActor } from "../guards.js";
5
9
  import type { AuditEventReader, AuditEventWriter } from "../runtime.js";
6
10
  import { hashSessionToken } from "./actor-resolver.js";
@@ -8,7 +12,7 @@ import {
8
12
  AuthenticationProviderRegistry,
9
13
  LocalArgon2AuthenticationAdapter,
10
14
  } from "./authentication-provider.js";
11
- import { argon2PasswordHasher, type PasswordHasher } from "./password.js";
15
+ import { createArgon2PasswordHasher, type PasswordHasher } from "./password.js";
12
16
  import type { AuthRepository, AuthSessionRecord } from "./repository.js";
13
17
  import {
14
18
  CredentialService,
@@ -42,7 +46,8 @@ export type RevokeResult =
42
46
  | { ok: false; code: "SESSION_NOT_FOUND" | "FORBIDDEN" };
43
47
 
44
48
  export interface AuthServiceOptions {
45
- sessionTtlSeconds?: number;
49
+ sessionTtlSeconds?: number | (() => number);
50
+ passwordPolicy?: () => PasswordPolicy;
46
51
  now?: () => Date;
47
52
  tokenFactory?: () => string;
48
53
  passwordHasher?: PasswordHasher;
@@ -52,7 +57,8 @@ export interface AuthServiceOptions {
52
57
  }
53
58
 
54
59
  export class AuthService {
55
- private readonly sessionTtlSeconds: number;
60
+ private readonly sessionTtlSeconds: () => number;
61
+ private readonly passwordPolicy: () => PasswordPolicy;
56
62
  private readonly now: () => Date;
57
63
  private readonly tokenFactory: () => string;
58
64
  private readonly passwordHasher: PasswordHasher;
@@ -64,11 +70,17 @@ export class AuthService {
64
70
  private readonly repository: AuthRepository,
65
71
  private readonly options: AuthServiceOptions,
66
72
  ) {
67
- this.sessionTtlSeconds = options.sessionTtlSeconds || 60 * 60 * 8;
73
+ const sessionTtlSeconds = options.sessionTtlSeconds;
74
+ this.sessionTtlSeconds =
75
+ typeof sessionTtlSeconds === "function"
76
+ ? sessionTtlSeconds
77
+ : () => sessionTtlSeconds || 60 * 60 * 8;
78
+ this.passwordPolicy = options.passwordPolicy || (() => PASSWORD_POLICY);
68
79
  this.now = options.now || (() => new Date());
69
80
  this.tokenFactory =
70
81
  options.tokenFactory || (() => randomBytes(32).toString("base64url"));
71
- this.passwordHasher = options.passwordHasher || argon2PasswordHasher;
82
+ this.passwordHasher =
83
+ options.passwordHasher || createArgon2PasswordHasher(this.passwordPolicy);
72
84
  this.authenticationProviders =
73
85
  options.authenticationProviders ||
74
86
  new AuthenticationProviderRegistry(
@@ -140,7 +152,9 @@ export class AuthService {
140
152
  tokenHash: hashSessionToken(token),
141
153
  status: "active",
142
154
  issuedAt,
143
- expiresAt: new Date(issuedAt.getTime() + this.sessionTtlSeconds * 1_000),
155
+ expiresAt: new Date(
156
+ issuedAt.getTime() + this.sessionTtlSeconds() * 1_000,
157
+ ),
144
158
  ip: metadata.ip,
145
159
  userAgent: metadata.userAgent,
146
160
  };
@@ -211,6 +225,10 @@ export class AuthService {
211
225
  );
212
226
  }
213
227
 
228
+ getPasswordPolicy(): PasswordPolicy {
229
+ return { ...this.passwordPolicy() };
230
+ }
231
+
214
232
  async revokeSession(
215
233
  sessionId: string,
216
234
  actor: RequestActor,
@@ -3,6 +3,10 @@ import type { FeatureFlagDefinition } from "@southwind-ai/shared";
3
3
  import type { FoundationSnapshot } from "../foundation.js";
4
4
  import type { createServerRuntime } from "../runtime.js";
5
5
  import type { RouteApp } from "../system/route-types.js";
6
+ // @windy-module system.storage begin
7
+ import type { UploadService } from "@southwind-ai/storage";
8
+ import { createPlatformUploadPolicies } from "../storage/runtime.js";
9
+ // @windy-module system.storage end
6
10
  import {
7
11
  createRedisHealthProbe,
8
12
  type RedisHealthProbe,
@@ -21,6 +25,9 @@ export async function bootstrapModuleConfiguration(
21
25
  ): Promise<{
22
26
  service: ModuleConfigurationService;
23
27
  bindFoundation(snapshot: FoundationSnapshot): void;
28
+ // @windy-module system.storage begin
29
+ bindStorage(uploads: Pick<UploadService, "setPolicy">): void;
30
+ // @windy-module system.storage end
24
31
  redisHealthProbe: RedisHealthProbe;
25
32
  register(
26
33
  app: Pick<RouteApp, "get" | "post">,
@@ -37,12 +44,24 @@ export async function bootstrapModuleConfiguration(
37
44
  return {
38
45
  service,
39
46
  bindFoundation(snapshot) {
40
- adapter.setListener({
47
+ snapshot.platform.enableAI = config.enableAI;
48
+ adapter.addListener({
41
49
  applied(current) {
42
50
  snapshot.platform.enableAI = current.enableAI;
43
51
  },
44
52
  });
45
53
  },
54
+ // @windy-module system.storage begin
55
+ bindStorage(uploads: Pick<UploadService, "setPolicy">) {
56
+ adapter.addListener({
57
+ applied(current) {
58
+ for (const policy of createPlatformUploadPolicies(current)) {
59
+ uploads.setPolicy(policy);
60
+ }
61
+ },
62
+ });
63
+ },
64
+ // @windy-module system.storage end
46
65
  redisHealthProbe: createLiveRedisHealthProbe(config),
47
66
  register(app, runtime, features) {
48
67
  registerModuleConfigurationRoutes(app, service, runtime, features);
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { loadPlatformConfig } from "@southwind-ai/config";
3
+ import { PlatformConfigApplyAdapter } from "./definition.js";
4
+
5
+ describe("平台类型化运行配置", () => {
6
+ test("白名单不包含 SMTP Secret 且旧版本缺失字段由环境基线补齐", () => {
7
+ const config = loadPlatformConfig({
8
+ MAIL_SMTP_URL: "smtp://secret-marker@example.invalid",
9
+ });
10
+ const definition = new PlatformConfigApplyAdapter(config).definition();
11
+
12
+ expect(definition.fields.map((field) => field.path)).not.toContain(
13
+ "mail.smtpUrl",
14
+ );
15
+ expect(JSON.stringify(definition.baseValues())).not.toContain(
16
+ "secret-marker",
17
+ );
18
+ expect(
19
+ definition.validate({
20
+ enableAI: true,
21
+ "cache.healthTimeoutMs": 500,
22
+ }),
23
+ ).toMatchObject({
24
+ enableAI: true,
25
+ "cache.healthTimeoutMs": 500,
26
+ "security.passwordMinLength": 12,
27
+ "security.passwordMaxLength": 128,
28
+ "regional.timeZone": "Asia/Shanghai",
29
+ });
30
+ expect(() =>
31
+ definition.validate({
32
+ ...definition.baseValues(),
33
+ "mail.smtpUrl": "smtp://must-not-persist",
34
+ }),
35
+ ).toThrow();
36
+ expect(() =>
37
+ definition.validate({
38
+ ...definition.baseValues(),
39
+ "mail.enabled": true,
40
+ }),
41
+ ).toThrow();
42
+ });
43
+
44
+ test("即时字段应用到共享运行配置对象", async () => {
45
+ const config = loadPlatformConfig({});
46
+ const definition = new PlatformConfigApplyAdapter(config).definition();
47
+
48
+ await definition.apply(
49
+ definition.validate({
50
+ ...definition.baseValues(),
51
+ "security.sessionTtlSeconds": 3_600,
52
+ "security.passwordMinLength": 16,
53
+ "security.passwordMaxLength": 64,
54
+ "storage.bulkDataMaxByteSize": 1_048_576,
55
+ "storage.brandingLogoMaxByteSize": 262_144,
56
+ "regional.timeZone": "UTC",
57
+ }),
58
+ );
59
+
60
+ expect(config.security).toMatchObject({
61
+ sessionTtlSeconds: 3_600,
62
+ passwordMinLength: 16,
63
+ passwordMaxLength: 64,
64
+ });
65
+ expect(config.storage).toEqual({
66
+ bulkDataMaxByteSize: 1_048_576,
67
+ brandingLogoMaxByteSize: 262_144,
68
+ });
69
+ expect(config.regional.timeZone).toBe("UTC");
70
+ });
71
+ });
@@ -1,4 +1,7 @@
1
- import { platformConfigDefinition, type PlatformConfig } from "@southwind-ai/config";
1
+ import {
2
+ platformConfigDefinition,
3
+ type PlatformConfig,
4
+ } from "@southwind-ai/config";
2
5
  import type {
3
6
  ModuleConfigFieldDescriptor,
4
7
  ModuleConfigValues,
@@ -18,21 +21,68 @@ export interface PlatformConfigApplyListener {
18
21
  applied(config: PlatformConfig): void;
19
22
  }
20
23
 
21
- const managedPaths = ["enableAI", "cache.healthTimeoutMs"] as const;
22
- const platformValuesSchema = z.strictObject({
23
- enableAI: z.boolean(),
24
- "cache.healthTimeoutMs": z.number().int().min(50).max(10_000),
25
- });
24
+ const managedPaths = [
25
+ "enableAI",
26
+ "cache.healthTimeoutMs",
27
+ "security.sessionTtlSeconds",
28
+ "security.passwordMinLength",
29
+ "security.passwordMaxLength",
30
+ "storage.bulkDataMaxByteSize",
31
+ "storage.brandingLogoMaxByteSize",
32
+ "mail.enabled",
33
+ "mail.host",
34
+ "mail.port",
35
+ "mail.secure",
36
+ "mail.fromAddress",
37
+ "mail.connectTimeoutMs",
38
+ "regional.timeZone",
39
+ ] as const;
40
+ const platformValuesSchema = z
41
+ .strictObject({
42
+ enableAI: z.boolean(),
43
+ "cache.healthTimeoutMs": z.number().int().min(50).max(10_000),
44
+ "security.sessionTtlSeconds": z.number().int().min(60).max(2_592_000),
45
+ "security.passwordMinLength": z.number().int().min(8).max(128),
46
+ "security.passwordMaxLength": z.number().int().min(12).max(512),
47
+ "storage.bulkDataMaxByteSize": z
48
+ .number()
49
+ .int()
50
+ .min(1024)
51
+ .max(10 * 1024 ** 3),
52
+ "storage.brandingLogoMaxByteSize": z
53
+ .number()
54
+ .int()
55
+ .min(1024)
56
+ .max(50 * 1024 ** 2),
57
+ "mail.enabled": z.boolean(),
58
+ "mail.host": z.string().trim().max(253),
59
+ "mail.port": z.number().int().min(1).max(65_535),
60
+ "mail.secure": z.boolean(),
61
+ "mail.fromAddress": z.string().trim().max(320),
62
+ "mail.connectTimeoutMs": z.number().int().min(100).max(120_000),
63
+ "regional.timeZone": z
64
+ .string()
65
+ .trim()
66
+ .min(1)
67
+ .max(100)
68
+ .refine(isSupportedTimeZone),
69
+ })
70
+ .refine(
71
+ (values) =>
72
+ values["security.passwordMinLength"] <=
73
+ values["security.passwordMaxLength"],
74
+ { message: "密码最小长度不能大于最大长度" },
75
+ );
26
76
 
27
77
  export class PlatformConfigApplyAdapter {
28
- private listener?: PlatformConfigApplyListener;
78
+ private readonly listeners = new Set<PlatformConfigApplyListener>();
29
79
 
30
80
  constructor(private readonly config: PlatformConfig) {
31
81
  assertSafeManagedPaths();
32
82
  }
33
83
 
34
- setListener(listener: PlatformConfigApplyListener): void {
35
- this.listener = listener;
84
+ addListener(listener: PlatformConfigApplyListener): void {
85
+ this.listeners.add(listener);
36
86
  }
37
87
 
38
88
  definition(): ManagedModuleConfigDefinition {
@@ -45,6 +95,7 @@ export class PlatformConfigApplyAdapter {
45
95
  label: "AI 能力总开关",
46
96
  description: "控制 foundation 快照中的平台 AI 能力声明。",
47
97
  input: "boolean",
98
+ applyMode: "live",
48
99
  },
49
100
  {
50
101
  path: "cache.healthTimeoutMs",
@@ -53,29 +104,208 @@ export class PlatformConfigApplyAdapter {
53
104
  input: "integer",
54
105
  minimum: 50,
55
106
  maximum: 10_000,
107
+ applyMode: "live",
108
+ },
109
+ integerField(
110
+ "security.sessionTtlSeconds",
111
+ "会话有效期",
112
+ "控制新创建会话的有效期,单位为秒;已存在会话保持原过期时间。",
113
+ 60,
114
+ 2_592_000,
115
+ ),
116
+ integerField(
117
+ "security.passwordMinLength",
118
+ "密码最小长度",
119
+ "修改或重置密码时立即使用的新最小长度。",
120
+ 8,
121
+ 128,
122
+ ),
123
+ integerField(
124
+ "security.passwordMaxLength",
125
+ "密码最大长度",
126
+ "修改或重置密码时立即使用的新最大长度。",
127
+ 12,
128
+ 512,
129
+ ),
130
+ integerField(
131
+ "storage.bulkDataMaxByteSize",
132
+ "批量数据文件上限",
133
+ "批量导入导出上传的最大字节数。",
134
+ 1024,
135
+ 10 * 1024 ** 3,
136
+ ),
137
+ integerField(
138
+ "storage.brandingLogoMaxByteSize",
139
+ "品牌 Logo 文件上限",
140
+ "品牌 Logo 上传的最大字节数。",
141
+ 1024,
142
+ 50 * 1024 ** 2,
143
+ ),
144
+ restartBooleanField(
145
+ "mail.enabled",
146
+ "启用邮件传输配置",
147
+ "控制邮件传输配置是否启用;仍需安装业务邮件发送 Adapter。",
148
+ ),
149
+ restartTextField("mail.host", "SMTP 主机", "SMTP 服务主机名。"),
150
+ restartIntegerField(
151
+ "mail.port",
152
+ "SMTP 端口",
153
+ "SMTP 服务端口。",
154
+ 1,
155
+ 65_535,
156
+ ),
157
+ restartBooleanField(
158
+ "mail.secure",
159
+ "SMTP TLS 直连",
160
+ "启用后使用 TLS 直连;STARTTLS 由后续邮件 Adapter 处理。",
161
+ ),
162
+ restartTextField(
163
+ "mail.fromAddress",
164
+ "默认发件地址",
165
+ "邮件 Adapter 使用的默认 From 地址。",
166
+ ),
167
+ restartIntegerField(
168
+ "mail.connectTimeoutMs",
169
+ "SMTP 连接超时",
170
+ "邮件 Adapter 建立连接的超时时间,单位为毫秒。",
171
+ 100,
172
+ 120_000,
173
+ ),
174
+ {
175
+ path: "regional.timeZone",
176
+ label: "显示时区",
177
+ description: "平台日期时间展示使用的 IANA 时区,例如 Asia/Shanghai。",
178
+ input: "text",
179
+ applyMode: "live",
56
180
  },
57
181
  ],
58
- validate: (values) => platformValuesSchema.parse(values),
182
+ validate: (values) =>
183
+ this.parse({
184
+ ...this.values(),
185
+ ...(isRecord(values) ? values : {}),
186
+ }),
59
187
  apply: (values) => this.apply(values),
60
188
  baseValues: () => this.values(),
61
189
  };
62
190
  }
63
191
 
64
192
  private apply(values: ModuleConfigValues): void {
65
- const parsed = platformValuesSchema.parse(values);
193
+ const parsed = this.parse(values);
66
194
  this.config.enableAI = parsed.enableAI;
67
195
  this.config.cache.healthTimeoutMs = parsed["cache.healthTimeoutMs"];
68
- this.listener?.applied(this.config);
196
+ this.config.security.sessionTtlSeconds =
197
+ parsed["security.sessionTtlSeconds"];
198
+ this.config.security.passwordMinLength =
199
+ parsed["security.passwordMinLength"];
200
+ this.config.security.passwordMaxLength =
201
+ parsed["security.passwordMaxLength"];
202
+ this.config.storage.bulkDataMaxByteSize =
203
+ parsed["storage.bulkDataMaxByteSize"];
204
+ this.config.storage.brandingLogoMaxByteSize =
205
+ parsed["storage.brandingLogoMaxByteSize"];
206
+ this.config.mail.enabled = parsed["mail.enabled"];
207
+ this.config.mail.host = parsed["mail.host"];
208
+ this.config.mail.port = parsed["mail.port"];
209
+ this.config.mail.secure = parsed["mail.secure"];
210
+ this.config.mail.fromAddress = parsed["mail.fromAddress"];
211
+ this.config.mail.connectTimeoutMs = parsed["mail.connectTimeoutMs"];
212
+ this.config.regional.timeZone = parsed["regional.timeZone"];
213
+ for (const listener of this.listeners) listener.applied(this.config);
214
+ }
215
+
216
+ private parse(values: unknown) {
217
+ const parsed = platformValuesSchema.parse(values);
218
+ if (
219
+ parsed["mail.enabled"] &&
220
+ (!parsed["mail.fromAddress"] ||
221
+ (!parsed["mail.host"] && !this.config.mail.smtpUrl))
222
+ ) {
223
+ throw new Error("邮件传输配置不完整");
224
+ }
225
+ return parsed;
69
226
  }
70
227
 
71
228
  private values(): ModuleConfigValues {
72
229
  return {
73
230
  enableAI: this.config.enableAI,
74
231
  "cache.healthTimeoutMs": this.config.cache.healthTimeoutMs,
232
+ "security.sessionTtlSeconds": this.config.security.sessionTtlSeconds,
233
+ "security.passwordMinLength": this.config.security.passwordMinLength,
234
+ "security.passwordMaxLength": this.config.security.passwordMaxLength,
235
+ "storage.bulkDataMaxByteSize": this.config.storage.bulkDataMaxByteSize,
236
+ "storage.brandingLogoMaxByteSize":
237
+ this.config.storage.brandingLogoMaxByteSize,
238
+ "mail.enabled": this.config.mail.enabled,
239
+ "mail.host": this.config.mail.host,
240
+ "mail.port": this.config.mail.port,
241
+ "mail.secure": this.config.mail.secure,
242
+ "mail.fromAddress": this.config.mail.fromAddress,
243
+ "mail.connectTimeoutMs": this.config.mail.connectTimeoutMs,
244
+ "regional.timeZone": this.config.regional.timeZone,
75
245
  };
76
246
  }
77
247
  }
78
248
 
249
+ function integerField(
250
+ path: string,
251
+ label: string,
252
+ description: string,
253
+ minimum: number,
254
+ maximum: number,
255
+ ): ModuleConfigFieldDescriptor {
256
+ return {
257
+ path,
258
+ label,
259
+ description,
260
+ input: "integer",
261
+ applyMode: "live",
262
+ minimum,
263
+ maximum,
264
+ };
265
+ }
266
+
267
+ function restartIntegerField(
268
+ path: string,
269
+ label: string,
270
+ description: string,
271
+ minimum: number,
272
+ maximum: number,
273
+ ): ModuleConfigFieldDescriptor {
274
+ return {
275
+ ...integerField(path, label, description, minimum, maximum),
276
+ applyMode: "restart",
277
+ };
278
+ }
279
+
280
+ function restartBooleanField(
281
+ path: string,
282
+ label: string,
283
+ description: string,
284
+ ): ModuleConfigFieldDescriptor {
285
+ return { path, label, description, input: "boolean", applyMode: "restart" };
286
+ }
287
+
288
+ function restartTextField(
289
+ path: string,
290
+ label: string,
291
+ description: string,
292
+ ): ModuleConfigFieldDescriptor {
293
+ return { path, label, description, input: "text", applyMode: "restart" };
294
+ }
295
+
296
+ function isSupportedTimeZone(value: string): boolean {
297
+ try {
298
+ new Intl.DateTimeFormat("zh-CN", { timeZone: value });
299
+ return true;
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+
305
+ function isRecord(value: unknown): value is Record<string, unknown> {
306
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
307
+ }
308
+
79
309
  function assertSafeManagedPaths(): void {
80
310
  const fields = new Map(
81
311
  platformConfigDefinition.environment.map((field) => [field.path, field]),
@@ -1,7 +1,7 @@
1
1
  import { randomUUIDv7 } from "bun";
2
2
  import { moduleConfigVersions } from "@southwind-ai/database";
3
3
  import type { ModuleConfigVersion } from "@southwind-ai/shared";
4
- import { and, desc, eq, ne, sql } from "drizzle-orm";
4
+ import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
5
5
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
6
6
  import type {
7
7
  BeginConfigVersionInput,
@@ -58,7 +58,10 @@ export class DrizzleModuleConfigVersionRepository implements ModuleConfigVersion
58
58
  .where(
59
59
  and(
60
60
  eq(moduleConfigVersions.moduleKey, candidate.moduleKey),
61
- eq(moduleConfigVersions.applyStatus, "effective"),
61
+ inArray(moduleConfigVersions.applyStatus, [
62
+ "effective",
63
+ "pending-restart",
64
+ ]),
62
65
  ne(moduleConfigVersions.id, id),
63
66
  ),
64
67
  );
@@ -84,6 +87,37 @@ export class DrizzleModuleConfigVersionRepository implements ModuleConfigVersion
84
87
  return mapRequired(updated);
85
88
  }
86
89
 
90
+ async markPendingRestart(id: string): Promise<ModuleConfigVersion> {
91
+ return this.db.transaction(async (tx) => {
92
+ const [candidate] = await tx
93
+ .select()
94
+ .from(moduleConfigVersions)
95
+ .where(eq(moduleConfigVersions.id, id))
96
+ .limit(1);
97
+ if (!candidate) throw new Error("配置版本不存在");
98
+ await tx
99
+ .update(moduleConfigVersions)
100
+ .set({ applyStatus: "superseded" })
101
+ .where(
102
+ and(
103
+ eq(moduleConfigVersions.moduleKey, candidate.moduleKey),
104
+ eq(moduleConfigVersions.applyStatus, "pending-restart"),
105
+ ne(moduleConfigVersions.id, id),
106
+ ),
107
+ );
108
+ const [updated] = await tx
109
+ .update(moduleConfigVersions)
110
+ .set({
111
+ applyStatus: "pending-restart",
112
+ applyError: null,
113
+ appliedAt: null,
114
+ })
115
+ .where(eq(moduleConfigVersions.id, id))
116
+ .returning();
117
+ return mapRequired(updated);
118
+ });
119
+ }
120
+
87
121
  latest(moduleKey: string): Promise<ModuleConfigVersion | undefined> {
88
122
  return this.first(moduleKey);
89
123
  }
@@ -18,6 +18,7 @@ export interface BeginConfigVersionInput {
18
18
  export interface ModuleConfigVersionRepository {
19
19
  begin(input: BeginConfigVersionInput): Promise<ModuleConfigVersion>;
20
20
  markEffective(id: string, appliedAt: Date): Promise<ModuleConfigVersion>;
21
+ markPendingRestart(id: string): Promise<ModuleConfigVersion>;
21
22
  markFailed(
22
23
  id: string,
23
24
  applyError: string,
@@ -61,7 +62,8 @@ export class InMemoryModuleConfigVersionRepository implements ModuleConfigVersio
61
62
  for (const candidate of this.versions) {
62
63
  if (
63
64
  candidate.moduleKey === version.moduleKey &&
64
- candidate.applyStatus === "effective"
65
+ (candidate.applyStatus === "effective" ||
66
+ candidate.applyStatus === "pending-restart")
65
67
  ) {
66
68
  candidate.applyStatus = "superseded";
67
69
  }
@@ -84,6 +86,23 @@ export class InMemoryModuleConfigVersionRepository implements ModuleConfigVersio
84
86
  return cloneVersion(version);
85
87
  }
86
88
 
89
+ async markPendingRestart(id: string): Promise<ModuleConfigVersion> {
90
+ const version = this.required(id);
91
+ for (const candidate of this.versions) {
92
+ if (
93
+ candidate.id !== id &&
94
+ candidate.moduleKey === version.moduleKey &&
95
+ candidate.applyStatus === "pending-restart"
96
+ ) {
97
+ candidate.applyStatus = "superseded";
98
+ }
99
+ }
100
+ version.applyStatus = "pending-restart";
101
+ version.applyError = undefined;
102
+ version.appliedAt = undefined;
103
+ return cloneVersion(version);
104
+ }
105
+
87
106
  async latest(moduleKey: string): Promise<ModuleConfigVersion | undefined> {
88
107
  return this.sorted(moduleKey)[0];
89
108
  }
@@ -134,5 +153,11 @@ export function cloneVersion(
134
153
  }
135
154
 
136
155
  export function isApplyStatus(value: string): value is ModuleConfigApplyStatus {
137
- return ["applying", "effective", "failed", "superseded"].includes(value);
156
+ return [
157
+ "applying",
158
+ "effective",
159
+ "failed",
160
+ "pending-restart",
161
+ "superseded",
162
+ ].includes(value);
138
163
  }
@@ -176,7 +176,10 @@ function recordResult(
176
176
  version: ModuleConfigVersion,
177
177
  ): void {
178
178
  const outcome: AuditOutcome =
179
- version.applyStatus === "effective" ? "success" : "failure";
179
+ version.applyStatus === "effective" ||
180
+ version.applyStatus === "pending-restart"
181
+ ? "success"
182
+ : "failure";
180
183
  runtime.auditSink.recordEvent({
181
184
  ...resourceEvent(
182
185
  context,