create-windy 0.2.18 → 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 +752 -186
  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,156 @@
1
+ import { randomUUIDv7 } from "bun";
2
+ import type { DurableJobs } from "@southwind-ai/jobs";
3
+ import type {
4
+ ModuleActor,
5
+ ModuleAuditEmitInput,
6
+ ModuleAuditEmitter,
7
+ ModuleJobSubmitter,
8
+ ModuleRequestContext,
9
+ ModuleStoragePort,
10
+ } from "@southwind-ai/server-sdk";
11
+ import { requireModuleNamespace } from "@southwind-ai/server-sdk";
12
+ import type { AuditEvent } from "@southwind-ai/shared";
13
+ import type { ModuleManifest } from "@southwind-ai/modules";
14
+ import type { RequestActor, RequestGuardContext } from "../guards.js";
15
+ import type { createServerRuntime } from "../runtime.js";
16
+
17
+ type ServerRuntime = ReturnType<typeof createServerRuntime>;
18
+
19
+ const requestActorByModuleView = new WeakMap<object, RequestActor>();
20
+
21
+ /**
22
+ * 收窄为模块 Actor 只读视图。只复制 SDK 明确公开的字段,避免凭据状态、
23
+ * 联系方式与数据范围令牌在运行时通过属性枚举泄漏给业务模块。
24
+ */
25
+ export function toModuleActor(actor: RequestActor): ModuleActor {
26
+ const view: ModuleActor = Object.freeze({
27
+ id: actor.id,
28
+ type: actor.type,
29
+ name: actor.name,
30
+ ...(actor.username ? { username: actor.username } : {}),
31
+ ...(actor.displayName ? { displayName: actor.displayName } : {}),
32
+ roleCodes: Object.freeze([...actor.roleCodes]),
33
+ permissionKeys: Object.freeze([...actor.permissionKeys]),
34
+ });
35
+ requestActorByModuleView.set(view, actor);
36
+ return view;
37
+ }
38
+
39
+ /**
40
+ * Host 内部适配器通过对象身份恢复原始 Actor,使平台内置模块仍能复用宿主的数据
41
+ * 范围执行链;原始 Actor 不作为 SDK 字段下发,外部业务包也无法访问该映射。
42
+ */
43
+ export function requestActorFromModule(actor: ModuleActor): RequestActor {
44
+ const requestActor = requestActorByModuleView.get(actor);
45
+ if (!requestActor) {
46
+ throw new Error("模块 Actor 不是由当前宿主颁发");
47
+ }
48
+ return requestActor;
49
+ }
50
+
51
+ export interface ModuleRequestContextInput {
52
+ moduleName: string;
53
+ manifest: ModuleManifest;
54
+ guardContext: RequestGuardContext;
55
+ params: Record<string, string | undefined>;
56
+ query: Record<string, string | undefined>;
57
+ body?: unknown;
58
+ runtime: ServerRuntime;
59
+ jobs?: DurableJobs;
60
+ storage?: ModuleStoragePort;
61
+ }
62
+
63
+ export function buildModuleRequestContext(
64
+ input: ModuleRequestContextInput,
65
+ ): ModuleRequestContext {
66
+ return {
67
+ requestId: input.guardContext.requestId,
68
+ actor: toModuleActor(input.guardContext.actor),
69
+ params: input.params,
70
+ query: input.query,
71
+ body: input.body,
72
+ audit: createModuleAuditEmitter(input),
73
+ jobs: createModuleJobSubmitter(input),
74
+ storage: input.storage ?? unavailableModuleStorage(input.moduleName),
75
+ };
76
+ }
77
+
78
+ function createModuleAuditEmitter(
79
+ input: ModuleRequestContextInput,
80
+ ): ModuleAuditEmitter {
81
+ const allowed = new Set(
82
+ input.manifest.auditActions.map((action) =>
83
+ typeof action === "string" ? action : action.key,
84
+ ),
85
+ );
86
+ return {
87
+ async emit(action: string, event: ModuleAuditEmitInput = {}) {
88
+ if (!allowed.has(action)) {
89
+ throw new Error(
90
+ `审计动作未在模块 ${input.moduleName} Manifest 注册:${action}`,
91
+ );
92
+ }
93
+ const auditEvent = buildAuditEvent(input.guardContext, action, event);
94
+ if (event.critical) {
95
+ await input.runtime.auditSink.recordCriticalEvent(auditEvent);
96
+ return;
97
+ }
98
+ input.runtime.auditSink.recordEvent(auditEvent);
99
+ },
100
+ };
101
+ }
102
+
103
+ function buildAuditEvent(
104
+ context: RequestGuardContext,
105
+ action: string,
106
+ input: ModuleAuditEmitInput,
107
+ ): AuditEvent {
108
+ return {
109
+ id: randomUUIDv7(),
110
+ action,
111
+ outcome: "success",
112
+ actor: {
113
+ id: context.actor.id,
114
+ type: context.actor.type === "anonymous" ? "user" : context.actor.type,
115
+ name: context.actor.name,
116
+ },
117
+ target: input.target,
118
+ requestId: context.requestId,
119
+ metadata: input.metadata,
120
+ occurredAt: new Date().toISOString(),
121
+ };
122
+ }
123
+
124
+ function createModuleJobSubmitter(
125
+ input: ModuleRequestContextInput,
126
+ ): ModuleJobSubmitter {
127
+ return {
128
+ async submit(job) {
129
+ requireModuleNamespace("任务 handler", job.handlerKey, input.moduleName);
130
+ if (!input.jobs) {
131
+ throw new Error("Durable Jobs 运行时未挂载");
132
+ }
133
+ const result = await input.jobs.submit({
134
+ ...job,
135
+ queue: input.moduleName,
136
+ requestedBy: input.guardContext.actor.id,
137
+ });
138
+ return { jobId: result.job.id, created: result.created };
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * 存储 Port 未挂载时的防御性实现:任何访问都以明确错误失败,
145
+ * 而不是让模块悄悄绕过 purpose / owner 约束。
146
+ */
147
+ function unavailableModuleStorage(moduleName: string): ModuleStoragePort {
148
+ const fail = (): never => {
149
+ throw new Error(`模块 ${moduleName} 的存储 Port 未挂载`);
150
+ };
151
+ return {
152
+ createUploadSession: () => fail(),
153
+ openFile: () => fail(),
154
+ openPublicFile: () => fail(),
155
+ };
156
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createFoundationSnapshot } from "../foundation.js";
3
+ import type { ManagedRole } from "../system/entities.js";
4
+ import { InMemoryEntityRepository } from "../system/repository.js";
5
+ import type { RegisteredModuleRolePreset } from "./initialize.js";
6
+ import {
7
+ moduleRolePresetId,
8
+ synchronizeModuleRolePresets,
9
+ } from "./role-presets.js";
10
+
11
+ describe("模块角色预设同步", () => {
12
+ test("预设落库为独立角色且重复同步幂等", async () => {
13
+ const roles = new InMemoryEntityRepository<ManagedRole>([]);
14
+ const snapshot = createFoundationSnapshot();
15
+
16
+ await synchronizeModuleRolePresets({ roles }, snapshot, [preset()]);
17
+ const created = await roles.get(moduleRolePresetId("shop.manager"));
18
+ expect(created).toMatchObject({
19
+ code: "shop.manager",
20
+ name: "店长",
21
+ dataScope: "department-and-children",
22
+ permissionKeys: ["shop.item.manage", "shop.item.read"],
23
+ status: "active",
24
+ });
25
+
26
+ await synchronizeModuleRolePresets({ roles }, snapshot, [preset()]);
27
+ const again = await roles.get(moduleRolePresetId("shop.manager"));
28
+ expect(again?.permissionKeys).toEqual(created?.permissionKeys);
29
+ });
30
+
31
+ test("已存在角色保留管理员手工追加的授权", async () => {
32
+ const roles = new InMemoryEntityRepository<ManagedRole>([]);
33
+ const snapshot = createFoundationSnapshot();
34
+ await synchronizeModuleRolePresets({ roles }, snapshot, [preset()]);
35
+
36
+ const id = moduleRolePresetId("shop.manager");
37
+ const existing = await roles.get(id);
38
+ if (!existing) throw new Error("角色预设未创建");
39
+ await roles.update(
40
+ id,
41
+ { permissionKeys: [...existing.permissionKeys, "system.menu.read"] },
42
+ actor,
43
+ );
44
+
45
+ await synchronizeModuleRolePresets({ roles }, snapshot, [preset()]);
46
+ const merged = await roles.get(id);
47
+ expect(merged?.permissionKeys).toContain("system.menu.read");
48
+ expect(merged?.permissionKeys).toContain("shop.item.manage");
49
+ });
50
+
51
+ test("预设 Code 与既有角色冲突时启动失败", async () => {
52
+ const roles = new InMemoryEntityRepository<ManagedRole>([
53
+ {
54
+ id: "role_custom",
55
+ code: "shop.manager",
56
+ name: "手工角色",
57
+ dataScope: "self",
58
+ permissionKeys: [],
59
+ menuKeys: [],
60
+ status: "active",
61
+ createdAt: "2026-07-16T00:00:00.000Z",
62
+ createdBy: "system",
63
+ updatedAt: "2026-07-16T00:00:00.000Z",
64
+ updatedBy: "system",
65
+ },
66
+ ]);
67
+
68
+ await expect(
69
+ synchronizeModuleRolePresets({ roles }, createFoundationSnapshot(), [
70
+ preset(),
71
+ ]),
72
+ ).rejects.toThrow("角色预设与既有角色冲突:shop.manager");
73
+ });
74
+ });
75
+
76
+ function preset(): RegisteredModuleRolePreset {
77
+ return {
78
+ moduleName: "shop",
79
+ preset: {
80
+ key: "shop.manager",
81
+ label: "店长",
82
+ dataScope: "department-and-children",
83
+ permissionKeys: ["shop.item.read", "shop.item.manage"],
84
+ },
85
+ };
86
+ }
87
+
88
+ const actor = {
89
+ id: "system_bootstrap",
90
+ type: "service" as const,
91
+ name: "平台初始化",
92
+ roleCodes: ["system"],
93
+ permissionKeys: ["system.role.manage"],
94
+ };
@@ -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
+ }