create-windy 0.6.21 → 0.8.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 (129) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +19 -7
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +1 -1
  6. package/template/apps/server/src/audit/policy.ts +6 -1
  7. package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
  8. package/template/apps/server/src/auth/composition.ts +5 -0
  9. package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
  10. package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
  11. package/template/apps/server/src/auth/credential-service.ts +47 -12
  12. package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
  13. package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
  14. package/template/apps/server/src/auth/repository.ts +9 -6
  15. package/template/apps/server/src/auth/routes.test-support.ts +1 -0
  16. package/template/apps/server/src/auth/runtime.ts +4 -0
  17. package/template/apps/server/src/auth/service-options.ts +2 -0
  18. package/template/apps/server/src/auth/service.ts +2 -0
  19. package/template/apps/server/src/auth/user-credential-access.ts +37 -0
  20. package/template/apps/server/src/foundation-activation.test.ts +24 -1
  21. package/template/apps/server/src/foundation.ts +0 -2
  22. package/template/apps/server/src/index.ts +1 -0
  23. package/template/apps/server/src/runtime-development.ts +2 -0
  24. package/template/apps/server/src/runtime.test.ts +13 -4
  25. package/template/apps/server/src/system/department-organization.ts +58 -59
  26. package/template/apps/server/src/system/department-read-routes.ts +54 -0
  27. package/template/apps/server/src/system/department-routes.test.ts +27 -19
  28. package/template/apps/server/src/system/department-routes.ts +95 -33
  29. package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
  30. package/template/apps/server/src/system/department-validation.ts +79 -0
  31. package/template/apps/server/src/system/drizzle-repository.ts +14 -0
  32. package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
  33. package/template/apps/server/src/system/identity-read-model.ts +105 -0
  34. package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
  35. package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
  36. package/template/apps/server/src/system/identity-route-config.ts +29 -7
  37. package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
  38. package/template/apps/server/src/system/identity-write-validation.ts +170 -0
  39. package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
  40. package/template/apps/server/src/system/redis-health.test.ts +1 -1
  41. package/template/apps/server/src/system/repository.ts +29 -2
  42. package/template/apps/server/src/system/resource-routes.ts +116 -72
  43. package/template/apps/server/src/system/route-audit.test.ts +117 -0
  44. package/template/apps/server/src/system/route-helpers.ts +14 -0
  45. package/template/apps/server/src/system/route-types.ts +11 -2
  46. package/template/apps/server/src/system/routes.test.ts +0 -68
  47. package/template/apps/server/src/system/routes.ts +29 -2
  48. package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
  49. package/template/apps/server/src/system/user-account-policy.ts +26 -9
  50. package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
  51. package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
  52. package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
  53. package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
  54. package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
  55. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
  56. package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
  57. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
  58. package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
  59. package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
  60. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
  61. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
  62. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
  63. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
  64. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
  65. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
  66. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
  67. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
  68. package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
  69. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
  70. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
  71. package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
  72. package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
  73. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
  74. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
  75. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
  76. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
  77. package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
  78. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
  79. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
  80. package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
  81. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
  82. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
  83. package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
  84. package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
  85. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
  86. package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
  87. package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
  88. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
  89. package/template/apps/web/src/pages/system/resource-config.ts +2 -0
  90. package/template/apps/web/src/services/system-api.ts +25 -0
  91. package/template/docker-compose.yml +3 -3
  92. package/template/docs/architecture/foundation-infrastructure.md +12 -25
  93. package/template/docs/development/crud-generator.md +1 -1
  94. package/template/docs/operations/legacy-data-migration.md +73 -0
  95. package/template/docs/platform/bulk-data-jobs.md +1 -1
  96. package/template/docs/platform/module-configuration.md +3 -3
  97. package/template/docs/platform/organization-membership.md +38 -7
  98. package/template/docs/platform/platform-authentication.md +1 -1
  99. package/template/docs/platform/scheduled-task-executor.md +1 -1
  100. package/template/package.json +2 -1
  101. package/template/packages/config/index.test.ts +1 -5
  102. package/template/packages/config/package.json +2 -2
  103. package/template/packages/config/src/platform-boundary.test.ts +39 -0
  104. package/template/packages/config/src/platform.ts +0 -28
  105. package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
  106. package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
  107. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  108. package/template/packages/database/package.json +2 -2
  109. package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
  110. package/template/packages/database/src/schema/index.ts +1 -0
  111. package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
  112. package/template/packages/database/src/schema/license-v1.ts +5 -0
  113. package/template/packages/jobs/package.json +2 -2
  114. package/template/packages/modules/package.json +1 -1
  115. package/template/packages/modules/src/system-api-permissions.ts +14 -0
  116. package/template/packages/modules/src/system-features.ts +0 -11
  117. package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
  118. package/template/packages/modules/src/system-module-catalog.ts +0 -1
  119. package/template/packages/server-sdk/index.ts +1 -49
  120. package/template/packages/server-sdk/package.json +3 -2
  121. package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
  122. package/template/packages/shared/index.ts +2 -1
  123. package/template/packages/shared/package.json +1 -1
  124. package/template/packages/shared/src/license-console-capabilities.ts +163 -0
  125. package/template/packages/shared/src/license-console.ts +94 -0
  126. package/template/packages/shared/src/license.ts +5 -0
  127. package/template/scripts/lib/postgres-regression.ts +1 -1
  128. package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
  129. package/template/packages/shared/src/ruoyi.ts +0 -98
package/README.md CHANGED
@@ -176,8 +176,13 @@ Apache-2.0 基座而自动开源客户业务代码。
176
176
 
177
177
  ## 模块生命周期
178
178
 
179
+ 新生成项目的数据库镜像为 `pgvector/pgvector:pg18`,使用 PG18 默认数据目录和新的
180
+ `postgres18-data` 卷。镜像预装 pgvector,平台默认不启用 `vector` 扩展,也不创建
181
+ 业务向量表;需要向量检索时由业务模块的迁移声明。PG18 正式发布证据需在新候选上取得,
182
+ 历史 PG17 证据不变。
183
+
179
184
  创建期模块物理组合已达到 H2:默认、纯核心、全模块和无 Oxc 组合持续执行真实安装、
180
- 类型检查、测试和构建,模块组合还在 PostgreSQL 17 上验证首次/重复迁移与 Server
185
+ 类型检查、测试和构建,模块组合还在 PostgreSQL 18 上验证首次/重复迁移与 Server
181
186
  启动。部署期使用同一个项目内 CLI:
182
187
 
183
188
  ```bash
package/dist/cli.js CHANGED
@@ -745,7 +745,7 @@ var platformModules = [
745
745
  {
746
746
  sourceName: "@southwind-ai/license",
747
747
  name: "@southwind-ai/license",
748
- version: "^0.1.9"
748
+ version: "^0.1.10"
749
749
  },
750
750
  {
751
751
  sourceName: "qrcode",
@@ -777,7 +777,8 @@ var platformModules = [
777
777
  ],
778
778
  schemaFiles: [
779
779
  "packages/database/src/schema/license-legacy.ts",
780
- "packages/database/src/schema/license-v1.ts"
780
+ "packages/database/src/schema/license-v1.ts",
781
+ "packages/database/src/schema/license-issuance-requests.ts"
781
782
  ],
782
783
  databaseTables: [
783
784
  "license_operators",
@@ -793,6 +794,7 @@ var platformModules = [
793
794
  "licenses",
794
795
  "license_replacements",
795
796
  "license_events",
797
+ "license_issuance_requests",
796
798
  "legacy_license_projects",
797
799
  "legacy_license_issue_records",
798
800
  "legacy_licenses"
@@ -3273,7 +3275,7 @@ function optionalComposeVolumes(includeAgent, includeSearch, includeStorage) {
3273
3275
  // src/runtime-profile-files.ts
3274
3276
  var starterComposeBase = `services:
3275
3277
  postgres:
3276
- image: postgres:17-alpine
3278
+ image: pgvector/pgvector:pg18
3277
3279
  environment:
3278
3280
  POSTGRES_DB: windy
3279
3281
  POSTGRES_USER: windy
@@ -3281,7 +3283,7 @@ var starterComposeBase = `services:
3281
3283
  ports:
3282
3284
  - "15432:5432"
3283
3285
  volumes:
3284
- - postgres-data:/var/lib/postgresql/data
3286
+ - postgres18-data:/var/lib/postgresql
3285
3287
  healthcheck:
3286
3288
  test: ["CMD-SHELL", "pg_isready -U windy -d windy"]
3287
3289
  interval: 10s
@@ -3372,7 +3374,7 @@ __OPENSEARCH_SERVICE__
3372
3374
  - server
3373
3375
 
3374
3376
  volumes:
3375
- postgres-data:
3377
+ postgres18-data:
3376
3378
  server-audit-data:
3377
3379
  __OPTIONAL_VOLUMES__
3378
3380
  `;
@@ -3710,7 +3712,7 @@ ${includeOfflineLicense ? "- License \u6D88\u8D39\u7AEF\uFF1A[\u79BB\u7EBF\u6FC0
3710
3712
  - \u8FD0\u7EF4\uFF1A[\u53EF\u89C2\u6D4B\u6027](./operations/observability.md)${includeOperations ? " \u4E0E [\u8FD0\u7EF4\u603B\u89C8](./platform/operations-overview.md)" : ""}\u3002
3711
3713
  - \u4EA4\u4ED8\uFF1A[\u6309 registry digest \u4E0E OCI blob \u5DEE\u91CF\u53D1\u5E03](./operations/incremental-delivery.md)\u3002
3712
3714
  ${includeAgent ? "- Agent\u3001\u591A\u6A21\u6001 Runtime \u4E0E MCP\uFF1A[Agent Harness](./architecture/agent-harness.md)\u3001[Agent Runtime \u63A5\u5165](./platform/agent-runtime.md)\u3001[Session/Memory \u8FD0\u7EF4](./operations/agent-session-memory.md)\u3001[Session \u6545\u969C\u901F\u67E5](./operations/agent-session-troubleshooting.md)\u3001[Agent Workflow](./architecture/agent-workflow.md)\u3001[Workflow \u6062\u590D\u8FD0\u7EF4](./operations/agent-workflow-recovery.md)\u3001[Document AI Runtime](./platform/document-ai-runtime.md)\u3001[Speech AI Runtime](./platform/speech-ai-runtime.md)\u3001[\u53D7\u63A7\u5DE5\u5177](./platform/agent-tools.md)\u4E0E[MCP \u4E1A\u52A1\u80FD\u529B](./architecture/mcp-business-capabilities.md)\u3002" : ""}
3713
- - \u517C\u5BB9\uFF1A[RuoYi-Vue Adapter](./adapters/ruoyi-vue-compatibility.md)\u3002
3715
+ - \u6570\u636E\u8FC1\u79FB\uFF1A[\u65E7\u7CFB\u7EDF\u6570\u636E\u8FC1\u79FB\u7B56\u7565](./operations/legacy-data-migration.md)\u3002
3714
3716
  `;
3715
3717
  }
3716
3718
  // src/project-files.ts
@@ -4089,7 +4091,7 @@ var templateDirectories = [
4089
4091
  "packages/storage"
4090
4092
  ];
4091
4093
  var templateDocumentationFiles = [
4092
- "docs/adapters/ruoyi-vue-compatibility.md",
4094
+ "docs/operations/legacy-data-migration.md",
4093
4095
  "docs/adr/0003-agent-session-event-source.md",
4094
4096
  "docs/architecture/admin-surface-boundary.md",
4095
4097
  "docs/architecture/agent-harness.md",
@@ -8512,6 +8514,16 @@ var recipes = [
8512
8514
  id: "starter-0.6.20-to-0.6.21",
8513
8515
  from: "0.6.20",
8514
8516
  to: "0.6.21"
8517
+ },
8518
+ {
8519
+ id: "starter-0.6.21-to-0.7.0",
8520
+ from: "0.6.21",
8521
+ to: "0.7.0"
8522
+ },
8523
+ {
8524
+ id: "starter-0.7.0-to-0.8.0",
8525
+ from: "0.7.0",
8526
+ to: "0.8.0"
8515
8527
  }
8516
8528
  ];
8517
8529
  function resolveRecipeChain(sourceVersion, targetVersion) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.6.21",
3
+ "version": "0.8.0",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "9a6e49f36589045d84d770f7522dc9481141ed7c",
4
- "generatorVersion": "0.6.21",
3
+ "templateCommit": "20ddeda7267de315fb8fe115d64ad6b8c892ccb8",
4
+ "generatorVersion": "0.8.0",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -20,7 +20,7 @@
20
20
  ## Server 与数据
21
21
 
22
22
  - Bun + Elysia 提供 HTTP 服务。
23
- - Drizzle ORM 负责数据访问和迁移,默认数据库为 PostgreSQL 17。
23
+ - Drizzle ORM 负责数据访问和迁移,默认数据库为 PostgreSQL 18,镜像预装 pgvector,扩展由需要它的模块迁移按需启用。
24
24
  - 平台能力通过 Module Manifest 组合,并提供 Feature、RBAC、Audit 与可选 License
25
25
  边界。
26
26
 
@@ -3,7 +3,12 @@ import type { createServerRuntime } from "../runtime.js";
3
3
 
4
4
  type ServerRuntime = ReturnType<typeof createServerRuntime>;
5
5
 
6
- const CRITICAL_RESOURCE_PATHS = new Set(["users", "roles", "licenses"]);
6
+ const CRITICAL_RESOURCE_PATHS = new Set([
7
+ "users",
8
+ "roles",
9
+ "departments",
10
+ "licenses",
11
+ ]);
7
12
 
8
13
  export async function recordMutationAudit(
9
14
  runtime: ServerRuntime,
@@ -1,3 +1,4 @@
1
+ import { buildActorScopeContexts } from "../data-access/context.js";
1
2
  import type { AuthRepository } from "./repository.js";
2
3
  import type { AuthService } from "./service.js";
3
4
  import type { SystemRepositories } from "../system/seed.js";
@@ -25,8 +26,16 @@ export async function bootstrapAdminPassword(
25
26
  id: "system_bootstrap",
26
27
  type: "service",
27
28
  name: "平台初始化",
28
- roleCodes: ["system"],
29
+ roleCodes: ["super-admin"],
29
30
  permissionKeys: ["system.user.manage"],
31
+ ...buildActorScopeContexts("system_bootstrap", undefined, [
32
+ {
33
+ id: "bootstrap",
34
+ code: "system",
35
+ dataScope: "all",
36
+ departmentIds: [],
37
+ },
38
+ ]),
30
39
  },
31
40
  );
32
41
  if (!result.ok) throw new Error(`初始化管理员密码失败:${result.code}`);
@@ -1,3 +1,5 @@
1
+ import type { SystemRepositories } from "../system/seed.js";
2
+ import { createUserCredentialAccess } from "./user-credential-access.js";
1
3
  import type { PlatformConfig } from "@southwind-ai/config";
2
4
  import type { ServerPersistence } from "../persistence.js";
3
5
  import type { createServerRuntime } from "../runtime.js";
@@ -5,6 +7,7 @@ import { createAuthRuntime } from "./runtime.js";
5
7
 
6
8
  export function createComposedAuthRuntime(input: {
7
9
  platformConfig: PlatformConfig;
10
+ repositories: SystemRepositories;
8
11
  persistence?: ServerPersistence;
9
12
  runtime: ReturnType<typeof createServerRuntime>;
10
13
  }) {
@@ -12,6 +15,8 @@ export function createComposedAuthRuntime(input: {
12
15
  undefined;
13
16
  const auth = createAuthRuntime({
14
17
  repository: input.persistence?.authRepository,
18
+ canManageUser: createUserCredentialAccess(input.repositories),
19
+ credentialMutationRunner: input.repositories.organizationMutationRunner,
15
20
  sessionTtlSeconds: () => input.platformConfig.security.sessionTtlSeconds,
16
21
  passwordPolicy: () => ({
17
22
  minLength: input.platformConfig.security.passwordMinLength,
@@ -0,0 +1,257 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AuditEvent } from "@southwind-ai/shared";
3
+ import { buildActorScopeContexts } from "../data-access/context.js";
4
+ import type { RequestActor } from "../guards.js";
5
+ import { createFoundationSnapshot } from "../foundation.js";
6
+ import { createSystemRepositories } from "../system/seed.js";
7
+ import { CredentialService } from "./credential-service.js";
8
+ import { InMemoryAuthRepository, type AuthUserRecord } from "./repository.js";
9
+ import { createUserCredentialAccess } from "./user-credential-access.js";
10
+
11
+ const now = new Date("2026-09-10T00:00:00Z");
12
+ const user = (id: string, departmentId: string): AuthUserRecord => ({
13
+ id,
14
+ username: id,
15
+ displayName: id,
16
+ departmentId,
17
+ status: "active",
18
+ credentialState: "active",
19
+ passwordHash: "old-hash",
20
+ roleCodes: [],
21
+ permissionKeys: [],
22
+ });
23
+ const actor: RequestActor = {
24
+ id: "manager",
25
+ type: "user",
26
+ name: "部门管理员",
27
+ roleCodes: ["manager"],
28
+ permissionKeys: ["system.user.manage"],
29
+ ...buildActorScopeContexts("manager", "dept_platform", [
30
+ {
31
+ id: "manager-role",
32
+ code: "manager",
33
+ dataScope: "department",
34
+ departmentIds: [],
35
+ },
36
+ ]),
37
+ };
38
+
39
+ async function fixture(
40
+ beforeHash?: (
41
+ system: ReturnType<typeof createSystemRepositories>,
42
+ ) => Promise<void>,
43
+ ) {
44
+ const system = createSystemRepositories(createFoundationSnapshot());
45
+ await system.roles.create(
46
+ {
47
+ id: "manager-role",
48
+ code: "manager",
49
+ name: "管理员",
50
+ dataScope: "department",
51
+ permissionKeys: ["system.user.manage"],
52
+ menuKeys: [],
53
+ },
54
+ actor,
55
+ );
56
+ const users = [user("local", "dept_platform"), user("outside", "other")];
57
+ for (const item of users)
58
+ await system.users.create(
59
+ {
60
+ id: item.id,
61
+ username: item.username,
62
+ displayName: item.displayName,
63
+ departmentId: item.departmentId,
64
+ status: "active",
65
+ roleIds: ["manager-role"],
66
+ },
67
+ actor,
68
+ );
69
+ const repository = new InMemoryAuthRepository(users);
70
+ await repository.createSession({
71
+ id: "session",
72
+ userId: "outside",
73
+ tokenHash: "outside-token",
74
+ status: "active",
75
+ issuedAt: now,
76
+ expiresAt: new Date(now.getTime() + 10000),
77
+ });
78
+ const events: AuditEvent[] = [];
79
+ const service = new CredentialService(
80
+ repository,
81
+ {
82
+ hash: async () => {
83
+ await beforeHash?.(system);
84
+ return "new-hash";
85
+ },
86
+ verify: async (hash, password) => hash === password,
87
+ },
88
+ {
89
+ write: (event) => {
90
+ events.push(event);
91
+ },
92
+ },
93
+ () => now,
94
+ createUserCredentialAccess(system),
95
+ system.organizationMutationRunner,
96
+ );
97
+ return { repository, service, events, system };
98
+ }
99
+
100
+ describe("credential target authorization", () => {
101
+ test("范围外请求不计算密码hash,hash期间目标迁移或提权后锁内复核拒绝", async () => {
102
+ for (const change of [
103
+ { departmentId: "other" },
104
+ { roleIds: ["role_super_admin"] },
105
+ ]) {
106
+ let hashes = 0;
107
+ const { service, repository, events } = await fixture(async (system) => {
108
+ hashes++;
109
+ await system.organizationMutationRunner.run(async () => {
110
+ await system.users.update("local", change, actor);
111
+ });
112
+ });
113
+ expect(
114
+ await service.resetPassword("outside", "new-password", actor, {}),
115
+ ).toEqual({ ok: false, code: "FORBIDDEN" });
116
+ expect(hashes).toBe(0);
117
+ expect(
118
+ await service.resetPassword("local", "new-password", actor, {}),
119
+ ).toEqual({ ok: false, code: "FORBIDDEN" });
120
+ expect(hashes).toBe(1);
121
+ expect(
122
+ (await repository.findUserByReference("local"))?.passwordHash,
123
+ ).toBe("old-hash");
124
+ expect(events.at(-1)?.outcome).toBe("denied");
125
+ }
126
+ });
127
+
128
+ test("密码写入返回false时管理员重置和本人改密都不误报成功或撤销会话", async () => {
129
+ const { service, repository, events } = await fixture();
130
+ await repository.createSession({
131
+ id: "local-session",
132
+ userId: "local",
133
+ tokenHash: "local-token",
134
+ status: "active",
135
+ issuedAt: now,
136
+ expiresAt: new Date(now.getTime() + 10000),
137
+ });
138
+ repository.updateUserPassword = async () => false;
139
+ expect(
140
+ await service.resetPassword("local", "new-password", actor, {}),
141
+ ).toEqual({ ok: false, code: "USER_NOT_FOUND" });
142
+ expect(
143
+ await service.changeOwnPassword(
144
+ "old-hash",
145
+ "new-password",
146
+ { ...actor, id: "local" },
147
+ {},
148
+ ),
149
+ ).toEqual({ ok: false, code: "USER_NOT_FOUND" });
150
+ expect((await repository.findSessionById("local-session"))?.status).toBe(
151
+ "active",
152
+ );
153
+ expect(events.every((event) => event.outcome !== "success")).toBe(true);
154
+ });
155
+
156
+ test("密码写入持有组织操作锁,管理变更在写入完成后继续", async () => {
157
+ const { service, repository, system } = await fixture();
158
+ let started!: () => void;
159
+ let release!: () => void;
160
+ const writing = new Promise<void>((resolve) => {
161
+ started = resolve;
162
+ });
163
+ const resume = new Promise<void>((resolve) => {
164
+ release = resolve;
165
+ });
166
+ const original = repository.updateUserPassword.bind(repository);
167
+ repository.updateUserPassword = async (...args) => {
168
+ started();
169
+ await resume;
170
+ return original(...args);
171
+ };
172
+ const reset = service.resetPassword("local", "new-password", actor, {});
173
+ await writing;
174
+ let managementStarted = false;
175
+ const management = system.organizationMutationRunner.run(async () => {
176
+ managementStarted = true;
177
+ });
178
+ await Promise.resolve();
179
+ expect(managementStarted).toBe(false);
180
+ release();
181
+ expect(await reset).toEqual({ ok: true, userId: "local" });
182
+ await management;
183
+ expect(managementStarted).toBe(true);
184
+ });
185
+
186
+ test("部门管理员按用户名重置范围外用户被拒绝,密码与会话保持原样", async () => {
187
+ const { repository, service, events } = await fixture();
188
+ expect(
189
+ await service.resetPassword("outside", "new-password", actor, {}),
190
+ ).toEqual({ ok: false, code: "FORBIDDEN" });
191
+ expect(
192
+ (await repository.findUserByReference("outside"))?.passwordHash,
193
+ ).toBe("old-hash");
194
+ expect((await repository.findSessionById("session"))?.status).toBe(
195
+ "active",
196
+ );
197
+ expect(events.at(-1)?.metadata).toEqual({ reason: "data-scope-denied" });
198
+ });
199
+
200
+ test("同部门高权账户不能被委派管理员通过重置密码接管", async () => {
201
+ const { repository, service, system } = await fixture();
202
+ await system.users.update(
203
+ "local",
204
+ { roleIds: ["role_super_admin"] },
205
+ actor,
206
+ );
207
+ expect(
208
+ await service.resetPassword("local", "new-password", actor, {}),
209
+ ).toEqual({ ok: false, code: "FORBIDDEN" });
210
+ expect((await repository.findUserByReference("local"))?.passwordHash).toBe(
211
+ "old-hash",
212
+ );
213
+ });
214
+
215
+ test("同部门授权目标可重置,缺少服务端scope与授权port时拒绝", async () => {
216
+ const { repository, service } = await fixture();
217
+ expect(
218
+ await service.resetPassword("local", "new-password", actor, {}),
219
+ ).toEqual({ ok: true, userId: "local" });
220
+ expect(
221
+ await service.resetPassword(
222
+ "local",
223
+ "new-password",
224
+ { ...actor, scopeContext: undefined },
225
+ {},
226
+ ),
227
+ ).toEqual({ ok: false, code: "FORBIDDEN" });
228
+ const missing = new CredentialService(
229
+ repository,
230
+ { hash: async () => "hash", verify: async () => true },
231
+ { write: () => undefined },
232
+ () => now,
233
+ );
234
+ expect(
235
+ await missing.resetPassword("local", "new-password", actor, {}),
236
+ ).toEqual({ ok: false, code: "FORBIDDEN" });
237
+ });
238
+
239
+ test("已软删除但仍active的身份不能登录查询、重新认证或解析旧会话", async () => {
240
+ const repository = new InMemoryAuthRepository([
241
+ { ...user("deleted", "dept_platform"), deletedAt: now },
242
+ ]);
243
+ await repository.createSession({
244
+ id: "deleted-session",
245
+ userId: "deleted",
246
+ tokenHash: "deleted-token",
247
+ status: "active",
248
+ issuedAt: now,
249
+ expiresAt: new Date(now.getTime() + 10000),
250
+ });
251
+ expect(await repository.findUserByUsername("deleted")).toBeUndefined();
252
+ expect(await repository.findUserByReference("deleted")).toBeUndefined();
253
+ expect(
254
+ await repository.resolveSessionActor("deleted-token", now),
255
+ ).toBeUndefined();
256
+ });
257
+ });
@@ -121,6 +121,7 @@ function createFixture() {
121
121
  const resolver = new RepositoryActorResolver(repository);
122
122
  const runtime = createServerRuntime({}, { actorResolver: resolver });
123
123
  const service = new AuthService(repository, {
124
+ canManageUser: async () => true,
124
125
  auditWriter: { write: () => undefined },
125
126
  });
126
127
  const app = new Elysia({ prefix: "/api" })
@@ -30,6 +30,8 @@ export class CredentialService {
30
30
  private readonly passwordHasher: PasswordHasher,
31
31
  private readonly auditWriter: AuditEventWriter,
32
32
  private readonly now: () => Date,
33
+ private readonly canManageUser?: import("./user-credential-access.js").UserCredentialAccess,
34
+ private readonly mutationRunner?: import("../system/mutation-runner.js").MutationRunner,
33
35
  ) {}
34
36
 
35
37
  async resetPassword(
@@ -47,27 +49,56 @@ export class CredentialService {
47
49
  await this.record(actor, userId, "failure", "user-not-found", request);
48
50
  return { ok: false, code: "USER_NOT_FOUND" };
49
51
  }
52
+ if (!(await this.canManageUser?.(user.id, actor))) {
53
+ await this.record(actor, userId, "denied", "data-scope-denied", request);
54
+ return { ok: false, code: "FORBIDDEN" };
55
+ }
50
56
  const passwordHash = await this.hashPassword(password);
51
57
  if (!passwordHash) {
52
58
  await this.record(actor, userId, "failure", "invalid-password", request);
53
59
  return { ok: false, code: "INVALID_PASSWORD" };
54
60
  }
61
+ // Hash outside the organization lock, then recheck against the locked target.
62
+ const update = async (): Promise<PasswordUpdateResult> => {
63
+ if (!(await this.repository.findUserByReference(user.id))) {
64
+ await this.record(actor, user.id, "failure", "user-not-found", request);
65
+ return { ok: false, code: "USER_NOT_FOUND" };
66
+ }
67
+ if (!(await this.canManageUser?.(user.id, actor))) {
68
+ await this.record(
69
+ actor,
70
+ user.id,
71
+ "denied",
72
+ "data-scope-denied",
73
+ request,
74
+ );
75
+ return { ok: false, code: "FORBIDDEN" };
76
+ }
77
+ return this.applyReset(user.id, passwordHash, actor, request);
78
+ };
79
+ return this.mutationRunner ? this.mutationRunner.run(update) : update();
80
+ }
81
+
82
+ private async applyReset(
83
+ userId: string,
84
+ passwordHash: string,
85
+ actor: RequestActor,
86
+ request: AuthRequestMetadata,
87
+ ): Promise<PasswordUpdateResult> {
55
88
  const changedAt = this.now();
56
- await this.repository.updateUserPassword(
57
- user.id,
89
+ const updated = await this.repository.updateUserPassword(
90
+ userId,
58
91
  { passwordHash, credentialState: "must-change-password" },
59
92
  changedAt,
60
93
  actor.id,
61
94
  );
62
- await this.repository.revokeUserSessions(user.id, changedAt);
63
- await this.record(
64
- actor,
65
- user.id,
66
- "success",
67
- "administrator-reset",
68
- request,
69
- );
70
- return { ok: true, userId: user.id };
95
+ if (!updated) {
96
+ await this.record(actor, userId, "failure", "user-not-found", request);
97
+ return { ok: false, code: "USER_NOT_FOUND" };
98
+ }
99
+ await this.repository.revokeUserSessions(userId, changedAt);
100
+ await this.record(actor, userId, "success", "administrator-reset", request);
101
+ return { ok: true, userId };
71
102
  }
72
103
 
73
104
  async changeOwnPassword(
@@ -111,12 +142,16 @@ export class CredentialService {
111
142
  return { ok: false, code: "INVALID_PASSWORD" };
112
143
  }
113
144
  const changedAt = this.now();
114
- await this.repository.updateUserPassword(
145
+ const updated = await this.repository.updateUserPassword(
115
146
  user.id,
116
147
  { passwordHash, credentialState: "active", passwordChangedAt: changedAt },
117
148
  changedAt,
118
149
  actor.id,
119
150
  );
151
+ if (!updated) {
152
+ await this.record(actor, user.id, "failure", "user-not-found", request);
153
+ return { ok: false, code: "USER_NOT_FOUND" };
154
+ }
120
155
  const revokedSessions = await this.repository.revokeUserSessions(
121
156
  user.id,
122
157
  changedAt,
@@ -38,7 +38,7 @@ export class DrizzleAuthRepository implements AuthRepository {
38
38
  const [user] = await this.db
39
39
  .select()
40
40
  .from(users)
41
- .where(eq(users.username, username))
41
+ .where(and(eq(users.username, username), isNull(users.deletedAt)))
42
42
  .limit(1);
43
43
  if (!user) {
44
44
  return undefined;
@@ -68,7 +68,12 @@ export class DrizzleAuthRepository implements AuthRepository {
68
68
  const [user] = await this.db
69
69
  .select()
70
70
  .from(users)
71
- .where(or(eq(users.id, reference), eq(users.username, reference)))
71
+ .where(
72
+ and(
73
+ or(eq(users.id, reference), eq(users.username, reference)),
74
+ isNull(users.deletedAt),
75
+ ),
76
+ )
72
77
  .limit(1);
73
78
  if (!user) {
74
79
  return undefined;
@@ -142,6 +147,7 @@ export class DrizzleAuthRepository implements AuthRepository {
142
147
  isNull(serverSessions.revokedAt),
143
148
  gt(serverSessions.expiresAt, now),
144
149
  eq(users.status, "active"),
150
+ isNull(users.deletedAt),
145
151
  ),
146
152
  )
147
153
  .limit(1);
@@ -190,7 +196,7 @@ export class DrizzleAuthRepository implements AuthRepository {
190
196
  updatedAt,
191
197
  updatedBy,
192
198
  })
193
- .where(eq(users.id, userId))
199
+ .where(and(eq(users.id, userId), isNull(users.deletedAt)))
194
200
  .returning({ id: users.id });
195
201
  return rows.length > 0;
196
202
  }
@@ -210,7 +216,7 @@ export class DrizzleAuthRepository implements AuthRepository {
210
216
  updatedAt,
211
217
  updatedBy,
212
218
  })
213
- .where(eq(users.id, userId))
219
+ .where(and(eq(users.id, userId), isNull(users.deletedAt)))
214
220
  .returning({ id: users.id });
215
221
  return rows.length > 0 ? this.findUserByReference(userId) : undefined;
216
222
  }
@@ -264,7 +270,9 @@ export class DrizzleAuthRepository implements AuthRepository {
264
270
  .select()
265
271
  .from(roles)
266
272
  .where(inArray(roles.id, roleIds));
267
- const activeRoles = roleRows.filter((role) => role.status === "active");
273
+ const activeRoles = roleRows.filter(
274
+ (role) => role.status === "active" && !role.deletedAt,
275
+ );
268
276
  const compatibilityPermissionRoleIds = activeRoles
269
277
  .filter((role) => !Array.isArray(role.permissionKeys))
270
278
  .map((role) => role.id);
@@ -16,6 +16,7 @@ export async function listActiveSessions(
16
16
  and(
17
17
  ...activeSessionClauses(now),
18
18
  eq(users.status, "active"),
19
+ isNull(users.deletedAt),
19
20
  or(
20
21
  gte(serverSessions.lastSeenAt, activeSince),
21
22
  and(