create-windy 0.2.19 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +312 -154
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/apps/server/src/application-services.ts +2 -2
  6. package/template/apps/server/src/audit/search-summary.ts +15 -8
  7. package/template/apps/server/src/example-modules.ts +58 -70
  8. package/template/apps/server/src/foundation.ts +8 -4
  9. package/template/apps/server/src/guards.test.ts +13 -0
  10. package/template/apps/server/src/guards.ts +12 -2
  11. package/template/apps/server/src/index.ts +48 -91
  12. package/template/apps/server/src/installed-business-modules.ts +23 -4
  13. package/template/apps/server/src/module-bootstrap.ts +83 -0
  14. package/template/apps/server/src/module-composition.test.ts +45 -1
  15. package/template/apps/server/src/module-composition.ts +45 -0
  16. package/template/apps/server/src/module-host/initialize.test.ts +123 -0
  17. package/template/apps/server/src/module-host/initialize.ts +331 -0
  18. package/template/apps/server/src/module-host/request-context.test.ts +66 -0
  19. package/template/apps/server/src/module-host/request-context.ts +156 -0
  20. package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
  21. package/template/apps/server/src/module-host/role-presets.ts +76 -0
  22. package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
  23. package/template/apps/server/src/module-host/route-installer.ts +97 -0
  24. package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
  25. package/template/apps/server/src/module-host/search-providers.ts +31 -0
  26. package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
  27. package/template/apps/server/src/module-host/storage-port.ts +59 -0
  28. package/template/apps/server/src/module-host/task-definitions.ts +41 -0
  29. package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
  30. package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
  31. package/template/apps/server/src/module-host.test.ts +202 -58
  32. package/template/apps/server/src/module-host.ts +34 -113
  33. package/template/apps/server/src/route-guards.test.ts +112 -1
  34. package/template/apps/server/src/runtime-feature.ts +66 -31
  35. package/template/apps/server/src/runtime.test.ts +1 -0
  36. package/template/apps/server/src/runtime.ts +3 -1
  37. package/template/apps/server/src/system/built-in-roles.ts +1 -1
  38. package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
  39. package/template/apps/server/src/work-order/routes.test.ts +30 -18
  40. package/template/apps/server/src/work-order/routes.ts +104 -135
  41. package/template/apps/server/src/work-order/search-api.test.ts +14 -3
  42. package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
  43. package/template/apps/server/src/work-order/search-provider.ts +17 -12
  44. package/template/apps/server/src/work-order/task.test.ts +16 -11
  45. package/template/apps/web/Dockerfile +4 -1
  46. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
  47. package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
  48. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
  49. package/template/docs/architecture/object-storage.md +12 -0
  50. package/template/packages/config/index.test.ts +57 -0
  51. package/template/packages/config/package.json +1 -1
  52. package/template/packages/config/src/platform.ts +28 -1
  53. package/template/packages/database/package.json +1 -1
  54. package/template/packages/database/src/runner.ts +7 -9
  55. package/template/packages/example-work-order/index.ts +1 -0
  56. package/template/packages/example-work-order/package.json +2 -0
  57. package/template/packages/example-work-order/src/server-module.ts +61 -0
  58. package/template/packages/jobs/package.json +1 -1
  59. package/template/packages/jobs/src/types.ts +7 -1
  60. package/template/packages/modules/package.json +1 -1
  61. package/template/packages/modules/src/catalog-validation.test.ts +212 -0
  62. package/template/packages/modules/src/catalog-validation.ts +19 -2
  63. package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
  64. package/template/packages/server-sdk/index.ts +42 -0
  65. package/template/packages/server-sdk/package.json +16 -3
  66. package/template/packages/server-sdk/src/actor.ts +15 -0
  67. package/template/packages/server-sdk/src/audit-port.ts +22 -0
  68. package/template/packages/server-sdk/src/jobs-port.ts +24 -0
  69. package/template/packages/server-sdk/src/module-host.ts +36 -1
  70. package/template/packages/server-sdk/src/namespace.test.ts +26 -0
  71. package/template/packages/server-sdk/src/namespace.ts +13 -0
  72. package/template/packages/server-sdk/src/request-context.ts +21 -0
  73. package/template/packages/server-sdk/src/role-preset.ts +18 -0
  74. package/template/packages/server-sdk/src/route-definition.ts +26 -0
  75. package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
  76. package/template/packages/server-sdk/src/storage-port.ts +25 -0
  77. package/template/packages/server-sdk/src/task-registration.ts +18 -0
  78. package/template/packages/server-sdk/src/tool-registration.ts +29 -0
  79. package/template/packages/shared/index.ts +1 -0
  80. package/template/packages/shared/package.json +1 -1
  81. package/template/packages/shared/src/license-catalog.test.ts +73 -1
  82. package/template/packages/shared/src/license-catalog.ts +95 -0
  83. package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
  84. package/template/apps/server/src/work-order/task.ts +0 -30
@@ -0,0 +1,97 @@
1
+ import type { DurableJobs } from "@southwind-ai/jobs";
2
+ import type { FeatureFlagDefinition } from "@southwind-ai/shared";
3
+ import { evaluateRouteGuards, findFeature } from "../route-guards.js";
4
+ import { forbidden, routeContext } from "../system/route-helpers.js";
5
+ import type { RouteApp } from "../system/route-types.js";
6
+ import type { createServerRuntime } from "../runtime.js";
7
+ import type { InitializedModuleHosts } from "./initialize.js";
8
+ import { buildModuleRequestContext } from "./request-context.js";
9
+ import {
10
+ createModuleStoragePort,
11
+ type ModuleStorageServices,
12
+ } from "./storage-port.js";
13
+
14
+ type ServerRuntime = ReturnType<typeof createServerRuntime>;
15
+
16
+ type RouteMethod = "get" | "post" | "put" | "delete";
17
+
18
+ export interface ModuleRouteInstallContext {
19
+ runtime: ServerRuntime;
20
+ features: readonly FeatureFlagDefinition[];
21
+ jobs?: DurableJobs;
22
+ /** 平台存储服务;提供后模块获得受限 Upload/BlobRead Port。 */
23
+ storage?: ModuleStorageServices;
24
+ }
25
+
26
+ /**
27
+ * 把模块经 Host 注册的路由定义安装为走统一 Guard 的 API 路由。
28
+ * Guard(权限 / Feature / License)与 security.denied 审计由
29
+ * evaluateRouteGuards 统一执行,模块无需也不能绕开。
30
+ */
31
+ export function installModuleRoutes(
32
+ hosts: InitializedModuleHosts,
33
+ app: RouteApp,
34
+ context: ModuleRouteInstallContext,
35
+ ): void {
36
+ for (const { moduleName, definition } of hosts.routes) {
37
+ const manifest = hosts.manifestOf(moduleName);
38
+ const binding = manifest.apiPermissions.find(
39
+ (item) =>
40
+ item.method === definition.method && item.path === definition.path,
41
+ );
42
+ if (!binding) {
43
+ throw new Error(
44
+ `模块路由未在 Manifest 声明:${definition.method} ${definition.path}`,
45
+ );
46
+ }
47
+ const featureKey = definition.featureKey ?? binding.featureKey;
48
+ const feature = featureKey
49
+ ? findFeature([...context.features], featureKey)
50
+ : undefined;
51
+ const path = stripApiPrefix(definition.path);
52
+ const method = definition.method.toLowerCase() as RouteMethod;
53
+ app[method](path, async (raw: unknown) => {
54
+ const route = routeContext(raw);
55
+ const decision = await evaluateRouteGuards(
56
+ context.runtime,
57
+ route.guardContext,
58
+ { permission: binding, feature },
59
+ );
60
+ if (!decision.allowed) return forbidden(decision.reason);
61
+ const requestContext = buildModuleRequestContext({
62
+ moduleName,
63
+ manifest,
64
+ guardContext: route.guardContext,
65
+ params: route.params,
66
+ query: route.query,
67
+ body: route.body,
68
+ runtime: context.runtime,
69
+ jobs: context.jobs,
70
+ storage: context.storage
71
+ ? createModuleStoragePort(context.storage, {
72
+ moduleName,
73
+ ownerId: route.guardContext.actor.id,
74
+ purposes: hosts.uploadPurposesOf(moduleName),
75
+ })
76
+ : undefined,
77
+ });
78
+ const result = await definition.handler(requestContext);
79
+ if (definition.auditAction) {
80
+ await requestContext.audit.emit(definition.auditAction, {
81
+ target: {
82
+ type: "module-route",
83
+ id: `${definition.method} ${definition.path}`,
84
+ },
85
+ });
86
+ }
87
+ return result;
88
+ });
89
+ }
90
+ }
91
+
92
+ function stripApiPrefix(path: string): string {
93
+ if (!path.startsWith("/api/")) {
94
+ throw new Error(`模块路由路径必须以 /api 开头:${path}`);
95
+ }
96
+ return path.slice("/api".length);
97
+ }
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { featureKey } from "@southwind-ai/shared";
3
+ import type {
4
+ ModuleInitializer,
5
+ ModuleRouteDefinition,
6
+ } from "@southwind-ai/server-sdk";
7
+ import { initializeModuleHosts } from "./initialize.js";
8
+ import { manifest } from "./test-fixtures.js";
9
+
10
+ describe("模块路由注册", () => {
11
+ test("Feature 以 Manifest API binding 为唯一真相源", async () => {
12
+ const declaredFeature = featureKey("known.item");
13
+ const declared = manifest("known", {
14
+ apiPermissions: [
15
+ {
16
+ method: "GET",
17
+ path: "/api/known/items",
18
+ permissionKey: "known.item.read",
19
+ featureKey: declaredFeature,
20
+ },
21
+ ],
22
+ features: [
23
+ {
24
+ key: declaredFeature,
25
+ label: "事项",
26
+ module: "known",
27
+ visible: "visible",
28
+ enabled: true,
29
+ stage: "stable",
30
+ },
31
+ ],
32
+ });
33
+
34
+ const initialized = await initializeModuleHosts({
35
+ modules: [declared],
36
+ initializers: [
37
+ initializer((host) => {
38
+ host.registerRoutes([route()]);
39
+ }),
40
+ ],
41
+ });
42
+
43
+ expect(initialized.routes).toHaveLength(1);
44
+ });
45
+
46
+ test("拒绝实现侧额外声明或改写 Route Feature", async () => {
47
+ const withoutFeature = manifest("known", {
48
+ apiPermissions: [
49
+ {
50
+ method: "GET",
51
+ path: "/api/known/items",
52
+ permissionKey: "known.item.read",
53
+ },
54
+ ],
55
+ });
56
+
57
+ await expect(
58
+ initializeModuleHosts({
59
+ modules: [withoutFeature],
60
+ initializers: [
61
+ initializer((host) => {
62
+ host.registerRoutes([
63
+ { ...route(), featureKey: featureKey("known.item") },
64
+ ]);
65
+ }),
66
+ ],
67
+ }),
68
+ ).rejects.toThrow(
69
+ "模块路由 Feature 与 Manifest 声明不一致:GET /api/known/items",
70
+ );
71
+ });
72
+
73
+ test("拒绝 API binding 引用 Manifest 未注册 Feature", async () => {
74
+ await expect(
75
+ initializeModuleHosts({
76
+ modules: [
77
+ manifest("known", {
78
+ apiPermissions: [
79
+ {
80
+ method: "GET",
81
+ path: "/api/known/items",
82
+ permissionKey: "known.item.read",
83
+ featureKey: featureKey("known.item"),
84
+ },
85
+ ],
86
+ }),
87
+ ],
88
+ initializers: [
89
+ initializer((host) => {
90
+ host.registerRoutes([route()]);
91
+ }),
92
+ ],
93
+ }),
94
+ ).rejects.toThrow("模块路由 Feature 未在 Manifest 注册:known.item");
95
+ });
96
+
97
+ test("拒绝未声明、权限漂移、重复或未知审计动作的路由", async () => {
98
+ await expect(registerRoutes(manifest("known"), [route()])).rejects.toThrow(
99
+ "模块路由未在 Manifest 声明:GET /api/known/items",
100
+ );
101
+
102
+ const declared = manifest("known", {
103
+ apiPermissions: [
104
+ {
105
+ method: "GET",
106
+ path: "/api/known/items",
107
+ permissionKey: "known.item.manage",
108
+ },
109
+ ],
110
+ });
111
+ await expect(registerRoutes(declared, [route()])).rejects.toThrow(
112
+ "模块路由权限与 Manifest 声明不一致:GET /api/known/items",
113
+ );
114
+
115
+ const readable = manifest("known", {
116
+ apiPermissions: [
117
+ {
118
+ method: "GET",
119
+ path: "/api/known/items",
120
+ permissionKey: "known.item.read",
121
+ },
122
+ ],
123
+ });
124
+ await expect(registerRoutes(readable, [route(), route()])).rejects.toThrow(
125
+ "模块路由重复注册:GET /api/known/items",
126
+ );
127
+ await expect(
128
+ registerRoutes(readable, [{ ...route(), auditAction: "crud.create" }]),
129
+ ).rejects.toThrow("模块路由审计动作未在 Manifest 注册:crud.create");
130
+ });
131
+ });
132
+
133
+ function initializer(
134
+ initialize: ModuleInitializer["initialize"],
135
+ ): ModuleInitializer {
136
+ return { moduleName: "known", initialize };
137
+ }
138
+
139
+ function route(): ModuleRouteDefinition {
140
+ return {
141
+ method: "GET",
142
+ path: "/api/known/items",
143
+ permissionKey: "known.item.read",
144
+ handler: () => ({}),
145
+ };
146
+ }
147
+
148
+ function registerRoutes(
149
+ declared: ReturnType<typeof manifest>,
150
+ routes: readonly ModuleRouteDefinition[],
151
+ ) {
152
+ return initializeModuleHosts({
153
+ modules: [declared],
154
+ initializers: [
155
+ initializer((host) => {
156
+ host.registerRoutes(routes);
157
+ }),
158
+ ],
159
+ });
160
+ }
@@ -0,0 +1,31 @@
1
+ import type { ModuleSearchProvider } from "@southwind-ai/server-sdk";
2
+ import type { SearchProvider } from "../search/contracts.js";
3
+ import type { InitializedModuleHosts } from "./initialize.js";
4
+ import { toModuleActor } from "./request-context.js";
5
+
6
+ /**
7
+ * 把模块搜索 Provider 适配为搜索 Host 的 Provider 协议。
8
+ * 模块只看到收窄的 Actor 视图与 RequestId;启用、授权与脱敏由搜索 Host 统一执行。
9
+ */
10
+ export function adaptModuleSearchProvider(
11
+ provider: ModuleSearchProvider,
12
+ ): SearchProvider {
13
+ return {
14
+ key: provider.key,
15
+ providerVersion: provider.providerVersion,
16
+ search: (request, context) =>
17
+ provider.search(request, {
18
+ actor: toModuleActor(context.actor),
19
+ requestId: context.requestId,
20
+ }),
21
+ health: (signal) => provider.health(signal),
22
+ };
23
+ }
24
+
25
+ export function adaptModuleSearchProviders(
26
+ hosts: InitializedModuleHosts,
27
+ ): SearchProvider[] {
28
+ return hosts.searchProviders.map(({ provider }) =>
29
+ adaptModuleSearchProvider(provider),
30
+ );
31
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ModuleInitializer } from "@southwind-ai/server-sdk";
3
+ import type { FileMetadata, UploadPolicy } from "@southwind-ai/storage";
4
+ import { createStorageRuntime } from "../storage/runtime.js";
5
+ import { initializeModuleHosts } from "./initialize.js";
6
+ import { createModuleStoragePort } from "./storage-port.js";
7
+ import { manifest } from "./test-fixtures.js";
8
+
9
+ const SHOP_POLICY = policy("shop.attachment");
10
+ const OTHER_POLICY = policy("other.attachment");
11
+
12
+ describe("模块受限 Upload/BlobRead Port", () => {
13
+ test("本模块用途可以创建上传会话,Owner 由宿主补齐", async () => {
14
+ const { port } = setup();
15
+ const session = await port.createUploadSession({
16
+ purpose: "shop.attachment",
17
+ filename: "现场照片.txt",
18
+ mimeType: "text/plain",
19
+ expectedByteSize: 3,
20
+ });
21
+
22
+ expect(session.ownerId).toBe("user_shop");
23
+ expect(session.purpose).toBe("shop.attachment");
24
+ });
25
+
26
+ test("跨模块与平台用途创建会话被拒绝", async () => {
27
+ const { port } = setup();
28
+
29
+ await expect(
30
+ port.createUploadSession({
31
+ purpose: "other.attachment",
32
+ filename: "a.txt",
33
+ mimeType: "text/plain",
34
+ expectedByteSize: 3,
35
+ }),
36
+ ).rejects.toThrow("上传用途不属于模块 shop:other.attachment");
37
+
38
+ await expect(
39
+ port.createUploadSession({
40
+ purpose: "bulk-data",
41
+ filename: "a.txt",
42
+ mimeType: "text/plain",
43
+ expectedByteSize: 3,
44
+ }),
45
+ ).rejects.toThrow("上传用途不属于模块 shop:bulk-data");
46
+ });
47
+
48
+ test("文件读取保持 Owner 约束", async () => {
49
+ const { storage, port } = setup();
50
+ const file = await seedFile(storage, "user_other", "shop.attachment");
51
+
52
+ await expect(port.openFile(file.id)).rejects.toThrow("无权访问该文件");
53
+
54
+ const ownerPort = createModuleStoragePort(
55
+ { uploads: storage.uploads, files: storage.files },
56
+ {
57
+ moduleName: "shop",
58
+ ownerId: "user_other",
59
+ purposes: new Set(["shop.attachment"]),
60
+ },
61
+ );
62
+ const opened = await ownerPort.openFile(file.id);
63
+ expect(opened.id).toBe(file.id);
64
+ });
65
+
66
+ test("Own 文件但用途越界时读取被拒绝", async () => {
67
+ const { storage, port } = setup();
68
+ const file = await seedFile(storage, "user_shop", "other.attachment");
69
+
70
+ await expect(port.openFile(file.id)).rejects.toThrow(
71
+ "上传用途不属于模块 shop:other.attachment",
72
+ );
73
+ });
74
+
75
+ test("公开读取强制用途属于本模块", async () => {
76
+ const { storage, port } = setup();
77
+ const file = await seedFile(storage, "user_shop", "shop.attachment");
78
+
79
+ await expect(port.openPublicFile(file.id, "bulk-data")).rejects.toThrow(
80
+ "上传用途不属于模块 shop:bulk-data",
81
+ );
82
+ const opened = await port.openPublicFile(file.id, "shop.attachment");
83
+ expect(opened.id).toBe(file.id);
84
+ });
85
+
86
+ test("命名空间重叠模块注册同一用途在启动时失败", async () => {
87
+ const initializer = (
88
+ moduleName: string,
89
+ register: ModuleInitializer["initialize"],
90
+ ): ModuleInitializer => ({ moduleName, initialize: register });
91
+
92
+ await expect(
93
+ initializeModuleHosts({
94
+ modules: [manifest("shop"), manifest("shop.sub")],
95
+ initializers: [
96
+ initializer("shop", (host) => {
97
+ host.registerUploadPolicy(policy("shop.sub"));
98
+ }),
99
+ initializer("shop.sub", (host) => {
100
+ host.registerUploadPolicy(policy("shop.sub"));
101
+ }),
102
+ ],
103
+ }),
104
+ ).rejects.toThrow("上传用途重复注册:shop.sub");
105
+ });
106
+ });
107
+
108
+ function setup() {
109
+ const storage = createStorageRuntime({}, undefined, [
110
+ SHOP_POLICY,
111
+ OTHER_POLICY,
112
+ ]);
113
+ const port = createModuleStoragePort(
114
+ { uploads: storage.uploads, files: storage.files },
115
+ {
116
+ moduleName: "shop",
117
+ ownerId: "user_shop",
118
+ purposes: new Set(["shop.attachment"]),
119
+ },
120
+ );
121
+ return { storage, port };
122
+ }
123
+
124
+ function policy(purpose: string): UploadPolicy {
125
+ return {
126
+ purpose,
127
+ maxByteSize: 1024,
128
+ maxPartByteSize: 512,
129
+ sessionTtlMs: 60_000,
130
+ allowedTypes: { "text/plain": ["txt"] },
131
+ signature: "none",
132
+ };
133
+ }
134
+
135
+ async function seedFile(
136
+ storage: ReturnType<typeof createStorageRuntime>,
137
+ ownerId: string,
138
+ purpose: string,
139
+ ): Promise<FileMetadata> {
140
+ const data = new TextEncoder().encode("abc");
141
+ const sha256 = new Bun.CryptoHasher("sha256").update(data).digest("hex");
142
+ const session = await storage.uploads.create({
143
+ ownerId,
144
+ purpose,
145
+ filename: "a.txt",
146
+ mimeType: "text/plain",
147
+ expectedByteSize: data.length,
148
+ expectedSha256: sha256,
149
+ });
150
+ await storage.uploads.appendPart({
151
+ sessionId: session.id,
152
+ ownerId,
153
+ partNumber: 0,
154
+ byteOffset: 0,
155
+ byteSize: data.length,
156
+ sha256,
157
+ body: new ReadableStream<Uint8Array>({
158
+ start(controller) {
159
+ controller.enqueue(data);
160
+ controller.close();
161
+ },
162
+ }),
163
+ });
164
+ return storage.uploads.complete(session.id, ownerId);
165
+ }
@@ -0,0 +1,59 @@
1
+ import type { ModuleStoragePort } from "@southwind-ai/server-sdk";
2
+ import {
3
+ UploadError,
4
+ type FileService,
5
+ type UploadService,
6
+ } from "@southwind-ai/storage";
7
+
8
+ export interface ModuleStorageServices {
9
+ uploads: UploadService;
10
+ files: FileService;
11
+ }
12
+
13
+ export interface ModuleStorageScope {
14
+ moduleName: string;
15
+ ownerId: string;
16
+ /** 本模块经 Host 注册的上传用途集合;跨模块与平台用途一律拒绝。 */
17
+ purposes: ReadonlySet<string>;
18
+ }
19
+
20
+ /**
21
+ * 模块受限 Upload / BlobRead Port。
22
+ *
23
+ * - 上传会话强制 purpose ∈ 本模块已注册 Policy,Owner 由宿主以当前 Actor 补齐;
24
+ * - 文件读取走 FileService 的 Owner 约束,且文件用途必须属于本模块;
25
+ * - 公开读取走 FileService 的 Purpose 约束,用途同样必须属于本模块;
26
+ * - 模块不直接接触 BlobStore 或 Upload Repository。
27
+ */
28
+ export function createModuleStoragePort(
29
+ services: ModuleStorageServices,
30
+ scope: ModuleStorageScope,
31
+ ): ModuleStoragePort {
32
+ return {
33
+ async createUploadSession(input) {
34
+ requireModulePurpose(scope, input.purpose);
35
+ return services.uploads.create({ ...input, ownerId: scope.ownerId });
36
+ },
37
+ async openFile(fileId) {
38
+ const file = await services.files.open(fileId, scope.ownerId);
39
+ requireModulePurpose(scope, file.purpose);
40
+ return file;
41
+ },
42
+ async openPublicFile(fileId, purpose) {
43
+ requireModulePurpose(scope, purpose);
44
+ return services.files.openPublic(fileId, purpose);
45
+ },
46
+ };
47
+ }
48
+
49
+ function requireModulePurpose(
50
+ scope: ModuleStorageScope,
51
+ purpose: string,
52
+ ): void {
53
+ if (!scope.purposes.has(purpose)) {
54
+ throw new UploadError(
55
+ "UPLOAD_FORBIDDEN",
56
+ `上传用途不属于模块 ${scope.moduleName}:${purpose}`,
57
+ );
58
+ }
59
+ }
@@ -0,0 +1,41 @@
1
+ import type { FeatureFlagDefinition } from "@southwind-ai/shared";
2
+ import type { ScheduledTaskDefinition } from "../scheduler/types.js";
3
+ import type { InitializedModuleHosts } from "./initialize.js";
4
+
5
+ /**
6
+ * 把模块经 Host 注册的定时任务 Handler 与 Manifest 声明合并为调度定义。
7
+ * 调度元数据一律以 Manifest 为准;声明了任务但没注册 Handler 的模块
8
+ * 会在最终 Composition 校验时报错。
9
+ */
10
+ export function createModuleTaskDefinitions(
11
+ hosts: InitializedModuleHosts,
12
+ features: readonly FeatureFlagDefinition[],
13
+ ): ScheduledTaskDefinition[] {
14
+ const handlerByKey = new Map(
15
+ hosts.taskHandlers.map(
16
+ ({ registration }) => [registration.taskKey, registration] as const,
17
+ ),
18
+ );
19
+ return hosts.manifests.flatMap((manifest) =>
20
+ manifest.tasks.flatMap((task) => {
21
+ const registration = handlerByKey.get(task.key);
22
+ if (!registration) return [];
23
+ const feature = features.find(({ key }) => key === task.featureKey);
24
+ if (!feature) throw new Error(`缺少 ${task.featureKey} Feature 定义`);
25
+ return [
26
+ {
27
+ key: task.key,
28
+ label: task.label,
29
+ description: task.description,
30
+ intervalSeconds: task.intervalSeconds,
31
+ maxAttempts: task.maxAttempts,
32
+ retryDelaySeconds: task.retryDelaySeconds,
33
+ permissionKey: task.permissionKey,
34
+ feature,
35
+ handler: registration.handler,
36
+ isIdleResult: registration.isIdleResult,
37
+ },
38
+ ];
39
+ }),
40
+ );
41
+ }
@@ -0,0 +1,26 @@
1
+ import type { ModuleManifest } from "@southwind-ai/modules";
2
+
3
+ /** 模块 Host 测试共用的最小 Manifest 夹具。 */
4
+ export function manifest(
5
+ name: string,
6
+ overrides: Partial<ModuleManifest> = {},
7
+ ): ModuleManifest {
8
+ return {
9
+ name,
10
+ version: "0.1.0",
11
+ label: name,
12
+ description: `${name} 模块`,
13
+ menus: [],
14
+ adminRoutes: [],
15
+ permissions: [],
16
+ apiPermissions: [],
17
+ features: [],
18
+ migrations: [],
19
+ schemaExports: [],
20
+ tasks: [],
21
+ tools: [],
22
+ providers: [],
23
+ auditActions: [],
24
+ ...overrides,
25
+ };
26
+ }
@@ -0,0 +1,25 @@
1
+ import type { ModuleToolHandlers } from "../agent/routes.js";
2
+ import type { InitializedModuleHosts } from "./initialize.js";
3
+ import { toModuleActor } from "./request-context.js";
4
+
5
+ /**
6
+ * 把模块经 Host 注册的 Agent Tool Handler 适配为 Agent 路由层的
7
+ * Handler Map;注册与 Manifest 声明的一致性由 validateToolHandlerContract 校验。
8
+ */
9
+ export function createModuleToolHandlerMap(
10
+ hosts: InitializedModuleHosts,
11
+ ): ModuleToolHandlers {
12
+ return new Map(
13
+ hosts.toolHandlers.map(({ registration }) => [
14
+ registration.toolKey,
15
+ {
16
+ scopeMode: registration.scopeMode,
17
+ execute: (query, context) =>
18
+ registration.execute(query, {
19
+ actor: toModuleActor(context.actor),
20
+ requestId: context.requestId,
21
+ }),
22
+ },
23
+ ]),
24
+ );
25
+ }