create-windy 0.2.19 → 0.2.21

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 (127) hide show
  1. package/README.md +5 -1
  2. package/dist/cli.js +392 -160
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/README.md +1 -0
  6. package/template/apps/agent-server/Dockerfile +20 -0
  7. package/template/apps/agent-server/README.md +46 -0
  8. package/template/apps/agent-server/pyproject.toml +44 -0
  9. package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
  11. package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
  18. package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
  23. package/template/apps/agent-server/tests/test_api.py +207 -0
  24. package/template/apps/agent-server/tests/test_config.py +56 -0
  25. package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
  26. package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
  27. package/template/apps/agent-server/uv.lock +925 -0
  28. package/template/apps/server/src/agent/execution-grants.ts +89 -0
  29. package/template/apps/server/src/agent/remote-runtime.ts +128 -0
  30. package/template/apps/server/src/agent/run-contracts.ts +43 -0
  31. package/template/apps/server/src/agent/run-facade.test.ts +257 -0
  32. package/template/apps/server/src/agent/run-facade.ts +190 -0
  33. package/template/apps/server/src/agent/run-routes.ts +222 -0
  34. package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
  35. package/template/apps/server/src/agent/tool-host.test.ts +242 -0
  36. package/template/apps/server/src/agent/tool-host.ts +153 -0
  37. package/template/apps/server/src/application-services.ts +2 -2
  38. package/template/apps/server/src/audit/search-summary.ts +15 -8
  39. package/template/apps/server/src/example-modules.ts +58 -70
  40. package/template/apps/server/src/foundation.ts +8 -4
  41. package/template/apps/server/src/guards.test.ts +13 -0
  42. package/template/apps/server/src/guards.ts +12 -2
  43. package/template/apps/server/src/index.ts +48 -95
  44. package/template/apps/server/src/installed-business-modules.ts +23 -4
  45. package/template/apps/server/src/module-bootstrap.ts +83 -0
  46. package/template/apps/server/src/module-composition.test.ts +54 -1
  47. package/template/apps/server/src/module-composition.ts +58 -0
  48. package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
  49. package/template/apps/server/src/module-host/initialize.test.ts +192 -0
  50. package/template/apps/server/src/module-host/initialize.ts +328 -0
  51. package/template/apps/server/src/module-host/request-context.test.ts +66 -0
  52. package/template/apps/server/src/module-host/request-context.ts +156 -0
  53. package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
  54. package/template/apps/server/src/module-host/role-presets.ts +76 -0
  55. package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
  56. package/template/apps/server/src/module-host/route-installer.ts +97 -0
  57. package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
  58. package/template/apps/server/src/module-host/search-providers.ts +31 -0
  59. package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
  60. package/template/apps/server/src/module-host/storage-port.ts +59 -0
  61. package/template/apps/server/src/module-host/task-definitions.ts +41 -0
  62. package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
  63. package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
  64. package/template/apps/server/src/module-host.test.ts +202 -58
  65. package/template/apps/server/src/module-host.ts +35 -113
  66. package/template/apps/server/src/module-runtime-validation.ts +40 -0
  67. package/template/apps/server/src/route-guards.test.ts +112 -1
  68. package/template/apps/server/src/runtime-feature.ts +66 -31
  69. package/template/apps/server/src/runtime.test.ts +1 -0
  70. package/template/apps/server/src/runtime.ts +3 -1
  71. package/template/apps/server/src/system/built-in-roles.ts +1 -1
  72. package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
  73. package/template/apps/server/src/work-order/routes.test.ts +30 -18
  74. package/template/apps/server/src/work-order/routes.ts +104 -135
  75. package/template/apps/server/src/work-order/search-api.test.ts +14 -3
  76. package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
  77. package/template/apps/server/src/work-order/search-provider.ts +17 -12
  78. package/template/apps/server/src/work-order/task.test.ts +16 -11
  79. package/template/apps/web/Dockerfile +4 -1
  80. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
  81. package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
  82. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
  83. package/template/docker-compose.yml +25 -0
  84. package/template/docs/architecture/ai-runtime.md +744 -0
  85. package/template/docs/architecture/object-storage.md +12 -0
  86. package/template/docs/platform/agent-runtime.md +128 -0
  87. package/template/docs/platform/agent-tools.md +8 -1
  88. package/template/package.json +1 -0
  89. package/template/packages/config/index.test.ts +100 -0
  90. package/template/packages/config/index.ts +1 -0
  91. package/template/packages/config/package.json +2 -1
  92. package/template/packages/config/src/ai-openai-compatible.ts +131 -0
  93. package/template/packages/config/src/platform.ts +28 -1
  94. package/template/packages/database/package.json +1 -1
  95. package/template/packages/database/src/runner.ts +7 -9
  96. package/template/packages/example-work-order/index.ts +1 -0
  97. package/template/packages/example-work-order/package.json +2 -0
  98. package/template/packages/example-work-order/src/server-module.ts +61 -0
  99. package/template/packages/jobs/package.json +1 -1
  100. package/template/packages/jobs/src/types.ts +7 -1
  101. package/template/packages/modules/package.json +1 -1
  102. package/template/packages/modules/src/catalog-validation.test.ts +212 -0
  103. package/template/packages/modules/src/catalog-validation.ts +19 -2
  104. package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
  105. package/template/packages/server-sdk/index.ts +53 -0
  106. package/template/packages/server-sdk/package.json +18 -3
  107. package/template/packages/server-sdk/src/actor.ts +15 -0
  108. package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
  109. package/template/packages/server-sdk/src/audit-port.ts +22 -0
  110. package/template/packages/server-sdk/src/jobs-port.ts +24 -0
  111. package/template/packages/server-sdk/src/module-host.ts +41 -1
  112. package/template/packages/server-sdk/src/namespace.test.ts +26 -0
  113. package/template/packages/server-sdk/src/namespace.ts +13 -0
  114. package/template/packages/server-sdk/src/request-context.ts +21 -0
  115. package/template/packages/server-sdk/src/role-preset.ts +18 -0
  116. package/template/packages/server-sdk/src/route-definition.ts +26 -0
  117. package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
  118. package/template/packages/server-sdk/src/storage-port.ts +25 -0
  119. package/template/packages/server-sdk/src/task-registration.ts +18 -0
  120. package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
  121. package/template/packages/server-sdk/src/tool-registration.ts +29 -0
  122. package/template/packages/shared/index.ts +1 -0
  123. package/template/packages/shared/package.json +1 -1
  124. package/template/packages/shared/src/license-catalog.test.ts +73 -1
  125. package/template/packages/shared/src/license-catalog.ts +95 -0
  126. package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
  127. package/template/apps/server/src/work-order/task.ts +0 -30
@@ -0,0 +1,76 @@
1
+ import type { FoundationSnapshot } from "../foundation.js";
2
+ import type { RequestActor } from "../guards.js";
3
+ import { menuKeysForPermissions } from "../system/built-in-roles.js";
4
+ import type { SystemRepositories } from "../system/seed.js";
5
+ import type { RegisteredModuleRolePreset } from "./initialize.js";
6
+
7
+ export function moduleRolePresetId(key: string): string {
8
+ return `role_${key.replace(/[^a-z0-9]+/gi, "_")}`;
9
+ }
10
+
11
+ /**
12
+ * 把模块角色预设并入内置角色同步:不存在则创建,已存在则按内置角色的
13
+ * 非破坏性语义合并权限与菜单(保留管理员手工追加的授权),
14
+ * 不改变 4 个内置角色本身。
15
+ */
16
+ export async function synchronizeModuleRolePresets(
17
+ repositories: Pick<SystemRepositories, "roles">,
18
+ snapshot: FoundationSnapshot,
19
+ presets: readonly RegisteredModuleRolePreset[],
20
+ ): Promise<void> {
21
+ const existingByCode = new Map(
22
+ (await repositories.roles.all()).map((role) => [role.code, role]),
23
+ );
24
+ for (const { preset } of presets) {
25
+ const id = moduleRolePresetId(preset.key);
26
+ const codeOwner = existingByCode.get(preset.key);
27
+ if (codeOwner && codeOwner.id !== id) {
28
+ throw new Error(`角色预设与既有角色冲突:${preset.key}`);
29
+ }
30
+ const permissionKeys = unique(preset.permissionKeys);
31
+ const menuKeys = menuKeysForPermissions(snapshot, permissionKeys);
32
+ const role = await repositories.roles.get(id);
33
+ if (!role) {
34
+ await repositories.roles.create(
35
+ {
36
+ id,
37
+ code: preset.key,
38
+ name: preset.label,
39
+ dataScope: preset.dataScope ?? "self",
40
+ permissionKeys,
41
+ menuKeys,
42
+ status: "active",
43
+ },
44
+ moduleRoleSyncActor,
45
+ );
46
+ continue;
47
+ }
48
+ const mergedPermissions = unique([
49
+ ...role.permissionKeys,
50
+ ...permissionKeys,
51
+ ]);
52
+ const mergedMenus = unique([...role.menuKeys, ...menuKeys]);
53
+ if (
54
+ mergedPermissions.length !== role.permissionKeys.length ||
55
+ mergedMenus.length !== role.menuKeys.length
56
+ ) {
57
+ await repositories.roles.update(
58
+ role.id,
59
+ { permissionKeys: mergedPermissions, menuKeys: mergedMenus },
60
+ moduleRoleSyncActor,
61
+ );
62
+ }
63
+ }
64
+ }
65
+
66
+ const moduleRoleSyncActor: RequestActor = {
67
+ id: "system_bootstrap",
68
+ type: "service",
69
+ name: "平台初始化",
70
+ roleCodes: ["system"],
71
+ permissionKeys: ["system.role.manage"],
72
+ };
73
+
74
+ function unique(values: readonly string[]): string[] {
75
+ return [...new Set(values)].sort();
76
+ }
@@ -0,0 +1,317 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { DurableJobs, SubmitDurableJob } from "@southwind-ai/jobs";
3
+ import type { ModuleManifest } from "@southwind-ai/modules";
4
+ import type {
5
+ ModuleInitializer,
6
+ ModuleRequestContext,
7
+ } from "@southwind-ai/server-sdk";
8
+ import { featureKey, licenseVersionKey } from "@southwind-ai/shared";
9
+ import type { RequestActor } from "../guards.js";
10
+ import { createServerRuntime } from "../runtime.js";
11
+ import { createStorageRuntime } from "../storage/runtime.js";
12
+ import { FakeRouteApp } from "../system/route-test-app.js";
13
+ import { initializeModuleHosts } from "./initialize.js";
14
+ import { installModuleRoutes } from "./route-installer.js";
15
+
16
+ describe("模块 Guarded Route 安装", () => {
17
+ test("无权限拒绝并写入 security.denied 审计", async () => {
18
+ const fixture = await createFixture({ permissionKeys: [] });
19
+ const response = (await fixture.invoke("GET", "/shop/items")) as Response;
20
+
21
+ expect(response.status).toBe(403);
22
+ await expect(response.json()).resolves.toMatchObject({
23
+ reason: "missing-permission",
24
+ });
25
+ expect(fixture.runtime.auditSink.list()).toContainEqual(
26
+ expect.objectContaining({
27
+ action: "security.denied",
28
+ outcome: "denied",
29
+ }),
30
+ );
31
+ expect(fixture.handled).toBe(false);
32
+ });
33
+
34
+ test("Feature 停用时拒绝且不进入 Handler", async () => {
35
+ const fixture = await createFixture({ featureEnabled: false });
36
+ const response = (await fixture.invoke("GET", "/shop/items")) as Response;
37
+
38
+ expect(response.status).toBe(403);
39
+ await expect(response.json()).resolves.toMatchObject({
40
+ reason: "feature-disabled",
41
+ });
42
+ expect(fixture.handled).toBe(false);
43
+ });
44
+
45
+ test("授权请求进入 Handler 并自动写入路由审计动作", async () => {
46
+ const fixture = await createFixture();
47
+ const result = (await fixture.invoke("POST", "/shop/items", {}, {})) as {
48
+ ok: boolean;
49
+ };
50
+
51
+ expect(result).toEqual({ ok: true });
52
+ expect(fixture.handled).toBe(true);
53
+ expect(fixture.runtime.auditSink.list()).toContainEqual(
54
+ expect.objectContaining({
55
+ action: "crud.create",
56
+ target: expect.objectContaining({
57
+ type: "module-route",
58
+ id: "POST /api/shop/items",
59
+ }),
60
+ }),
61
+ );
62
+ });
63
+
64
+ test("Handler 只能获得受限上下文:审计白名单、Jobs 命名空间与未挂载存储", async () => {
65
+ const fixture = await createFixture({ withJobs: true });
66
+ await fixture.invoke("GET", "/shop/items");
67
+ const context = fixture.context;
68
+ if (!context) throw new Error("缺少请求上下文");
69
+
70
+ await expect(context.audit.emit("data.search")).rejects.toThrow(
71
+ "审计动作未在模块 shop Manifest 注册:data.search",
72
+ );
73
+ await expect(
74
+ context.jobs.submit({
75
+ handlerKey: "other.export",
76
+ idempotencyKey: "x",
77
+ payload: {},
78
+ }),
79
+ ).rejects.toThrow("任务 handler不属于模块 shop:other.export");
80
+
81
+ const submitted = await context.jobs.submit({
82
+ handlerKey: "shop.export",
83
+ idempotencyKey: "idem-1",
84
+ payload: { id: 1 },
85
+ });
86
+ expect(submitted).toEqual({ jobId: "job_1", created: true });
87
+ expect(fixture.submittedJob).toMatchObject({
88
+ handlerKey: "shop.export",
89
+ queue: "shop",
90
+ requestedBy: "user_admin",
91
+ });
92
+
93
+ expect(() => context.storage.createUploadSession({} as never)).toThrow(
94
+ "模块 shop 的存储 Port 未挂载",
95
+ );
96
+ });
97
+
98
+ test("未挂载 Durable Jobs 时提交以明确错误失败", async () => {
99
+ const fixture = await createFixture();
100
+ await fixture.invoke("GET", "/shop/items");
101
+ await expect(
102
+ fixture.context?.jobs.submit({
103
+ handlerKey: "shop.export",
104
+ idempotencyKey: "x",
105
+ payload: {},
106
+ }),
107
+ ).rejects.toThrow("Durable Jobs 运行时未挂载");
108
+ });
109
+
110
+ test("挂载存储后 Handler 经受限 Port 创建会话且用途受限", async () => {
111
+ const fixture = await createFixture({ withStorage: true });
112
+ await fixture.invoke("GET", "/shop/items");
113
+ const context = fixture.context;
114
+ if (!context) throw new Error("缺少请求上下文");
115
+
116
+ const session = await context.storage.createUploadSession({
117
+ purpose: "shop.attachment",
118
+ filename: "a.txt",
119
+ mimeType: "text/plain",
120
+ expectedByteSize: 3,
121
+ });
122
+ expect(session.ownerId).toBe("user_admin");
123
+
124
+ await expect(
125
+ context.storage.createUploadSession({
126
+ purpose: "bulk-data",
127
+ filename: "a.txt",
128
+ mimeType: "text/plain",
129
+ expectedByteSize: 3,
130
+ }),
131
+ ).rejects.toThrow("上传用途不属于模块 shop:bulk-data");
132
+ });
133
+ });
134
+
135
+ interface FixtureOptions {
136
+ permissionKeys?: string[];
137
+ featureEnabled?: boolean;
138
+ withJobs?: boolean;
139
+ withStorage?: boolean;
140
+ }
141
+
142
+ async function createFixture(options: FixtureOptions = {}) {
143
+ const state: {
144
+ handled: boolean;
145
+ context?: ModuleRequestContext;
146
+ submittedJob?: SubmitDurableJob;
147
+ } = { handled: false };
148
+ const initializer: ModuleInitializer = {
149
+ moduleName: "shop",
150
+ initialize(host) {
151
+ host.registerUploadPolicy({
152
+ purpose: "shop.attachment",
153
+ maxByteSize: 1024,
154
+ maxPartByteSize: 512,
155
+ sessionTtlMs: 60_000,
156
+ allowedTypes: { "text/plain": ["txt"] },
157
+ signature: "none",
158
+ });
159
+ host.registerRoutes([
160
+ {
161
+ method: "GET",
162
+ path: "/api/shop/items",
163
+ permissionKey: "shop.item.read",
164
+ handler: (context) => {
165
+ state.handled = true;
166
+ state.context = context;
167
+ return { items: [] };
168
+ },
169
+ },
170
+ {
171
+ method: "POST",
172
+ path: "/api/shop/items",
173
+ permissionKey: "shop.item.manage",
174
+ auditAction: "crud.create",
175
+ handler: () => {
176
+ state.handled = true;
177
+ state.context = undefined;
178
+ return { ok: true };
179
+ },
180
+ },
181
+ ]);
182
+ },
183
+ };
184
+ const hosts = await initializeModuleHosts({
185
+ modules: [shopManifest()],
186
+ initializers: [initializer],
187
+ });
188
+ const runtime = createServerRuntime(
189
+ {},
190
+ {
191
+ actorResolver: { resolve: () => actor(options.permissionKeys) },
192
+ licenseResolver: {
193
+ resolve: () => ({ versionKey: licenseVersionKey("standard") }),
194
+ },
195
+ featureResolver: {
196
+ resolve: (key) => ({
197
+ key,
198
+ enabled: options.featureEnabled ?? true,
199
+ visible: "visible",
200
+ }),
201
+ },
202
+ },
203
+ );
204
+ const jobs = options.withJobs
205
+ ? ({
206
+ submit: (input: SubmitDurableJob) => {
207
+ state.submittedJob = input;
208
+ return Promise.resolve({
209
+ job: { id: "job_1" },
210
+ created: true,
211
+ });
212
+ },
213
+ } as unknown as DurableJobs)
214
+ : undefined;
215
+ const app = new FakeRouteApp();
216
+ const storage = options.withStorage
217
+ ? createStorageRuntime({}, undefined, hosts.uploadPolicies)
218
+ : undefined;
219
+ installModuleRoutes(hosts, app, {
220
+ runtime,
221
+ features: shopManifest().features,
222
+ jobs,
223
+ storage: storage
224
+ ? { uploads: storage.uploads, files: storage.files }
225
+ : undefined,
226
+ });
227
+ const guardContext = await runtime.createGuardContext(
228
+ new Request("http://localhost/api/shop/items"),
229
+ );
230
+ return {
231
+ runtime,
232
+ get handled() {
233
+ return state.handled;
234
+ },
235
+ get context() {
236
+ return state.context;
237
+ },
238
+ get submittedJob() {
239
+ return state.submittedJob;
240
+ },
241
+ invoke(method: string, path: string, params = {}, body?: unknown) {
242
+ return app.invoke(method, path, {
243
+ guardContext,
244
+ params,
245
+ query: {},
246
+ body,
247
+ });
248
+ },
249
+ };
250
+ }
251
+
252
+ function actor(
253
+ permissionKeys = ["shop.item.read", "shop.item.manage"],
254
+ ): RequestActor {
255
+ return {
256
+ id: "user_admin",
257
+ type: "user",
258
+ name: "测试管理员",
259
+ roleCodes: ["developer"],
260
+ permissionKeys,
261
+ };
262
+ }
263
+
264
+ function shopManifest(): ModuleManifest {
265
+ return {
266
+ name: "shop",
267
+ version: "0.1.0",
268
+ label: "商店",
269
+ description: "测试模块",
270
+ menus: [],
271
+ adminRoutes: [],
272
+ permissions: [permission("shop.item.read"), permission("shop.item.manage")],
273
+ apiPermissions: [
274
+ {
275
+ method: "GET",
276
+ path: "/api/shop/items",
277
+ permissionKey: "shop.item.read",
278
+ featureKey: "shop.feature",
279
+ },
280
+ {
281
+ method: "POST",
282
+ path: "/api/shop/items",
283
+ permissionKey: "shop.item.manage",
284
+ featureKey: "shop.feature",
285
+ },
286
+ ],
287
+ features: [
288
+ {
289
+ key: featureKey("shop.feature"),
290
+ label: "商店",
291
+ module: "shop",
292
+ description: "测试 Feature",
293
+ visible: "visible",
294
+ enabled: true,
295
+ stage: "stable",
296
+ },
297
+ ],
298
+ migrations: [],
299
+ schemaExports: [],
300
+ tasks: [],
301
+ tools: [],
302
+ providers: [],
303
+ auditActions: ["crud.create"],
304
+ };
305
+ }
306
+
307
+ function permission(key: string) {
308
+ return {
309
+ key,
310
+ label: key,
311
+ module: "shop",
312
+ moduleLabel: "商店",
313
+ resource: "item",
314
+ resourceLabel: "商品",
315
+ action: key.endsWith("read") ? ("read" as const) : ("manage" as const),
316
+ };
317
+ }
@@ -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
+ }