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.
- package/README.md +5 -1
- package/dist/cli.js +392 -160
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/README.md +1 -0
- package/template/apps/agent-server/Dockerfile +20 -0
- package/template/apps/agent-server/README.md +46 -0
- package/template/apps/agent-server/pyproject.toml +44 -0
- package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
- package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
- package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
- package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
- package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
- package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
- package/template/apps/agent-server/tests/test_api.py +207 -0
- package/template/apps/agent-server/tests/test_config.py +56 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
- package/template/apps/agent-server/uv.lock +925 -0
- package/template/apps/server/src/agent/execution-grants.ts +89 -0
- package/template/apps/server/src/agent/remote-runtime.ts +128 -0
- package/template/apps/server/src/agent/run-contracts.ts +43 -0
- package/template/apps/server/src/agent/run-facade.test.ts +257 -0
- package/template/apps/server/src/agent/run-facade.ts +190 -0
- package/template/apps/server/src/agent/run-routes.ts +222 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
- package/template/apps/server/src/agent/tool-host.test.ts +242 -0
- package/template/apps/server/src/agent/tool-host.ts +153 -0
- package/template/apps/server/src/application-services.ts +2 -2
- package/template/apps/server/src/audit/search-summary.ts +15 -8
- package/template/apps/server/src/example-modules.ts +58 -70
- package/template/apps/server/src/foundation.ts +8 -4
- package/template/apps/server/src/guards.test.ts +13 -0
- package/template/apps/server/src/guards.ts +12 -2
- package/template/apps/server/src/index.ts +48 -95
- package/template/apps/server/src/installed-business-modules.ts +23 -4
- package/template/apps/server/src/module-bootstrap.ts +83 -0
- package/template/apps/server/src/module-composition.test.ts +54 -1
- package/template/apps/server/src/module-composition.ts +58 -0
- package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
- package/template/apps/server/src/module-host/initialize.test.ts +192 -0
- package/template/apps/server/src/module-host/initialize.ts +328 -0
- package/template/apps/server/src/module-host/request-context.test.ts +66 -0
- package/template/apps/server/src/module-host/request-context.ts +156 -0
- package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
- package/template/apps/server/src/module-host/role-presets.ts +76 -0
- package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
- package/template/apps/server/src/module-host/route-installer.ts +97 -0
- package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
- package/template/apps/server/src/module-host/search-providers.ts +31 -0
- package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
- package/template/apps/server/src/module-host/storage-port.ts +59 -0
- package/template/apps/server/src/module-host/task-definitions.ts +41 -0
- package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
- package/template/apps/server/src/module-host.test.ts +202 -58
- package/template/apps/server/src/module-host.ts +35 -113
- package/template/apps/server/src/module-runtime-validation.ts +40 -0
- package/template/apps/server/src/route-guards.test.ts +112 -1
- package/template/apps/server/src/runtime-feature.ts +66 -31
- package/template/apps/server/src/runtime.test.ts +1 -0
- package/template/apps/server/src/runtime.ts +3 -1
- package/template/apps/server/src/system/built-in-roles.ts +1 -1
- package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
- package/template/apps/server/src/work-order/routes.test.ts +30 -18
- package/template/apps/server/src/work-order/routes.ts +104 -135
- package/template/apps/server/src/work-order/search-api.test.ts +14 -3
- package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
- package/template/apps/server/src/work-order/search-provider.ts +17 -12
- package/template/apps/server/src/work-order/task.test.ts +16 -11
- package/template/apps/web/Dockerfile +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
- package/template/docker-compose.yml +25 -0
- package/template/docs/architecture/ai-runtime.md +744 -0
- package/template/docs/architecture/object-storage.md +12 -0
- package/template/docs/platform/agent-runtime.md +128 -0
- package/template/docs/platform/agent-tools.md +8 -1
- package/template/package.json +1 -0
- package/template/packages/config/index.test.ts +100 -0
- package/template/packages/config/index.ts +1 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/ai-openai-compatible.ts +131 -0
- package/template/packages/config/src/platform.ts +28 -1
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/runner.ts +7 -9
- package/template/packages/example-work-order/index.ts +1 -0
- package/template/packages/example-work-order/package.json +2 -0
- package/template/packages/example-work-order/src/server-module.ts +61 -0
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/types.ts +7 -1
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/catalog-validation.test.ts +212 -0
- package/template/packages/modules/src/catalog-validation.ts +19 -2
- package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
- package/template/packages/server-sdk/index.ts +53 -0
- package/template/packages/server-sdk/package.json +18 -3
- package/template/packages/server-sdk/src/actor.ts +15 -0
- package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
- package/template/packages/server-sdk/src/audit-port.ts +22 -0
- package/template/packages/server-sdk/src/jobs-port.ts +24 -0
- package/template/packages/server-sdk/src/module-host.ts +41 -1
- package/template/packages/server-sdk/src/namespace.test.ts +26 -0
- package/template/packages/server-sdk/src/namespace.ts +13 -0
- package/template/packages/server-sdk/src/request-context.ts +21 -0
- package/template/packages/server-sdk/src/role-preset.ts +18 -0
- package/template/packages/server-sdk/src/route-definition.ts +26 -0
- package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
- package/template/packages/server-sdk/src/storage-port.ts +25 -0
- package/template/packages/server-sdk/src/task-registration.ts +18 -0
- package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
- package/template/packages/server-sdk/src/tool-registration.ts +29 -0
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/license-catalog.test.ts +73 -1
- package/template/packages/shared/src/license-catalog.ts +95 -0
- package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
- package/template/apps/server/src/work-order/task.ts +0 -30
|
@@ -1,113 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (uploadPurposes.has(policy.purpose)) {
|
|
37
|
-
throw new Error(`上传用途重复注册:${policy.purpose}`);
|
|
38
|
-
}
|
|
39
|
-
uploadPurposes.add(policy.purpose);
|
|
40
|
-
uploadPolicies.push(policy);
|
|
41
|
-
}
|
|
42
|
-
const handlerKeys = new Set<string>();
|
|
43
|
-
const handlerInstallers: HandlerInstaller[] = [];
|
|
44
|
-
|
|
45
|
-
for (const initializer of input.initializers) {
|
|
46
|
-
const moduleName = initializer.moduleName.trim();
|
|
47
|
-
if (!installedNames.has(moduleName)) {
|
|
48
|
-
throw new Error(`模块初始化器与已安装模块不匹配:${moduleName}`);
|
|
49
|
-
}
|
|
50
|
-
if (initializedNames.has(moduleName)) {
|
|
51
|
-
throw new Error(`模块初始化器重复注册:${moduleName}`);
|
|
52
|
-
}
|
|
53
|
-
initializedNames.add(moduleName);
|
|
54
|
-
await initializer.initialize(
|
|
55
|
-
createScopedHost({
|
|
56
|
-
moduleName,
|
|
57
|
-
uploadPolicies,
|
|
58
|
-
uploadPurposes,
|
|
59
|
-
handlerKeys,
|
|
60
|
-
handlerInstallers,
|
|
61
|
-
}),
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
uploadPolicies: [...uploadPolicies],
|
|
67
|
-
installDurableHandlers(registry) {
|
|
68
|
-
for (const install of handlerInstallers) install(registry);
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
interface ScopedHostState {
|
|
74
|
-
moduleName: string;
|
|
75
|
-
uploadPolicies: ModuleUploadPolicy[];
|
|
76
|
-
uploadPurposes: Set<string>;
|
|
77
|
-
handlerKeys: Set<string>;
|
|
78
|
-
handlerInstallers: HandlerInstaller[];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function createScopedHost(state: ScopedHostState): ModuleHost {
|
|
82
|
-
return {
|
|
83
|
-
moduleName: state.moduleName,
|
|
84
|
-
registerUploadPolicy(policy) {
|
|
85
|
-
requireModuleNamespace("上传用途", policy.purpose, state.moduleName);
|
|
86
|
-
if (state.uploadPurposes.has(policy.purpose)) {
|
|
87
|
-
throw new Error(`上传用途重复注册:${policy.purpose}`);
|
|
88
|
-
}
|
|
89
|
-
state.uploadPurposes.add(policy.purpose);
|
|
90
|
-
state.uploadPolicies.push(policy);
|
|
91
|
-
},
|
|
92
|
-
registerDurableHandler<T>(definition: DurableHandlerDefinition<T>) {
|
|
93
|
-
requireModuleNamespace("任务 handler", definition.key, state.moduleName);
|
|
94
|
-
if (state.handlerKeys.has(definition.key)) {
|
|
95
|
-
throw new Error(`任务 handler 重复注册:${definition.key}`);
|
|
96
|
-
}
|
|
97
|
-
state.handlerKeys.add(definition.key);
|
|
98
|
-
state.handlerInstallers.push((registry) => {
|
|
99
|
-
registry.register(definition);
|
|
100
|
-
});
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function requireModuleNamespace(
|
|
106
|
-
kind: string,
|
|
107
|
-
value: string,
|
|
108
|
-
moduleName: string,
|
|
109
|
-
): void {
|
|
110
|
-
if (value !== moduleName && !value.startsWith(`${moduleName}.`)) {
|
|
111
|
-
throw new Error(`${kind}不属于模块 ${moduleName}:${value}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
initializeModuleHosts,
|
|
3
|
+
type InitializedModuleHosts,
|
|
4
|
+
type InitializeModuleHostsInput,
|
|
5
|
+
type RegisteredModuleRolePreset,
|
|
6
|
+
type RegisteredModuleRoute,
|
|
7
|
+
type RegisteredModuleSearchProvider,
|
|
8
|
+
type RegisteredModuleTaskHandler,
|
|
9
|
+
type RegisteredModuleToolHandler,
|
|
10
|
+
type RegisteredModuleAiOperationHandler,
|
|
11
|
+
} from "./module-host/initialize.js";
|
|
12
|
+
export {
|
|
13
|
+
buildModuleRequestContext,
|
|
14
|
+
requestActorFromModule,
|
|
15
|
+
toModuleActor,
|
|
16
|
+
} from "./module-host/request-context.js";
|
|
17
|
+
export {
|
|
18
|
+
installModuleRoutes,
|
|
19
|
+
type ModuleRouteInstallContext,
|
|
20
|
+
} from "./module-host/route-installer.js";
|
|
21
|
+
export {
|
|
22
|
+
moduleRolePresetId,
|
|
23
|
+
synchronizeModuleRolePresets,
|
|
24
|
+
} from "./module-host/role-presets.js";
|
|
25
|
+
export {
|
|
26
|
+
adaptModuleSearchProvider,
|
|
27
|
+
adaptModuleSearchProviders,
|
|
28
|
+
} from "./module-host/search-providers.js";
|
|
29
|
+
export {
|
|
30
|
+
createModuleStoragePort,
|
|
31
|
+
type ModuleStorageScope,
|
|
32
|
+
type ModuleStorageServices,
|
|
33
|
+
} from "./module-host/storage-port.js";
|
|
34
|
+
export { createModuleTaskDefinitions } from "./module-host/task-definitions.js";
|
|
35
|
+
export { createModuleToolHandlerMap } from "./module-host/tool-handlers.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
2
|
+
import type { ModuleToolHandlers } from "./agent/routes.js";
|
|
3
|
+
import { validateServerModuleComposition } from "./module-composition.js";
|
|
4
|
+
import type { InitializedModuleHosts } from "./module-host.js";
|
|
5
|
+
|
|
6
|
+
export function validateInstalledRuntime(input: {
|
|
7
|
+
modules: ModuleManifest[];
|
|
8
|
+
routes: readonly { method: string; path: string }[];
|
|
9
|
+
tasks: readonly { key: string }[];
|
|
10
|
+
tools: ModuleToolHandlers;
|
|
11
|
+
providers: readonly string[];
|
|
12
|
+
hosts: InitializedModuleHosts;
|
|
13
|
+
}): void {
|
|
14
|
+
const aiOperations = input.hosts.aiOperationHandlers.map(
|
|
15
|
+
({ registration }) => registration.operationKey,
|
|
16
|
+
);
|
|
17
|
+
validateServerModuleComposition({
|
|
18
|
+
modules: input.modules,
|
|
19
|
+
routes: input.routes,
|
|
20
|
+
tasks: input.tasks,
|
|
21
|
+
tools: input.tools,
|
|
22
|
+
aiOperations,
|
|
23
|
+
providers: input.providers,
|
|
24
|
+
moduleRegistrations: {
|
|
25
|
+
routes: input.hosts.routes.map(
|
|
26
|
+
({ definition }) => `${definition.method} ${definition.path}`,
|
|
27
|
+
),
|
|
28
|
+
tasks: input.hosts.taskHandlers.map(
|
|
29
|
+
({ registration }) => registration.taskKey,
|
|
30
|
+
),
|
|
31
|
+
tools: input.hosts.toolHandlers.map(
|
|
32
|
+
({ registration }) => registration.toolKey,
|
|
33
|
+
),
|
|
34
|
+
aiOperations,
|
|
35
|
+
providers: input.hosts.searchProviders.map(
|
|
36
|
+
({ provider }) => provider.key,
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
entitlementKey,
|
|
4
|
+
featureKey,
|
|
5
|
+
licenseVersionKey,
|
|
6
|
+
} from "@southwind-ai/shared";
|
|
3
7
|
import { evaluateAccessGuards } from "./access-guards.js";
|
|
4
8
|
import { createServerRuntime } from "./runtime.js";
|
|
5
9
|
|
|
@@ -81,6 +85,113 @@ describe("access guard pipeline", () => {
|
|
|
81
85
|
expect("edition" in context.license!).toBe(false);
|
|
82
86
|
});
|
|
83
87
|
|
|
88
|
+
test("Feature 依赖未启用时拒绝并记录 security.denied,依赖启用后放行", async () => {
|
|
89
|
+
const dependency = {
|
|
90
|
+
key: featureKey("business.base"),
|
|
91
|
+
label: "业务基础",
|
|
92
|
+
module: "business",
|
|
93
|
+
visible: "visible" as const,
|
|
94
|
+
enabled: true,
|
|
95
|
+
stage: "stable" as const,
|
|
96
|
+
};
|
|
97
|
+
const capability = {
|
|
98
|
+
...dependency,
|
|
99
|
+
key: featureKey("business.report"),
|
|
100
|
+
dependencies: [dependency.key],
|
|
101
|
+
};
|
|
102
|
+
let dependencyEnabled = false;
|
|
103
|
+
const runtime = createServerRuntime(
|
|
104
|
+
{},
|
|
105
|
+
{
|
|
106
|
+
actorResolver: { resolve: () => actor(["business.report.read"]) },
|
|
107
|
+
licenseResolver: { resolve: () => ({ entitlements: [] }) },
|
|
108
|
+
featureCatalog: [dependency, capability],
|
|
109
|
+
featureResolver: {
|
|
110
|
+
resolve: (key) => ({
|
|
111
|
+
key,
|
|
112
|
+
enabled: key === dependency.key ? dependencyEnabled : true,
|
|
113
|
+
visible: "visible" as const,
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
const policy = {
|
|
119
|
+
permission: { permissionKey: "business.report.read" },
|
|
120
|
+
feature: capability,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const deniedContext = await runtime.createGuardContext(
|
|
124
|
+
new Request("http://local"),
|
|
125
|
+
);
|
|
126
|
+
const denied = await evaluateAccessGuards(runtime, deniedContext, policy);
|
|
127
|
+
|
|
128
|
+
expect(denied.decision.reason).toBe("dependency-missing");
|
|
129
|
+
expect(denied.feature?.auditEvent?.action).toBe("security.denied");
|
|
130
|
+
const auditEvents = await runtime.listAuditEvents({
|
|
131
|
+
page: 1,
|
|
132
|
+
pageSize: 10,
|
|
133
|
+
});
|
|
134
|
+
expect(
|
|
135
|
+
auditEvents.items.some(
|
|
136
|
+
(event) =>
|
|
137
|
+
event.action === "security.denied" &&
|
|
138
|
+
event.target?.id === "business.report",
|
|
139
|
+
),
|
|
140
|
+
).toBe(true);
|
|
141
|
+
|
|
142
|
+
dependencyEnabled = true;
|
|
143
|
+
const allowedContext = await runtime.createGuardContext(
|
|
144
|
+
new Request("http://local"),
|
|
145
|
+
);
|
|
146
|
+
const allowed = await evaluateAccessGuards(runtime, allowedContext, policy);
|
|
147
|
+
|
|
148
|
+
expect(allowed.decision.reason).toBe("allowed");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("Feature 依赖不在当前 License 版本内时 fail closed", async () => {
|
|
152
|
+
const dependency = {
|
|
153
|
+
key: featureKey("business.advanced-base"),
|
|
154
|
+
label: "高级业务基础",
|
|
155
|
+
module: "business",
|
|
156
|
+
visible: "visible" as const,
|
|
157
|
+
enabled: true,
|
|
158
|
+
stage: "stable" as const,
|
|
159
|
+
allowedLicenseVersions: [licenseVersionKey("advanced")],
|
|
160
|
+
};
|
|
161
|
+
const capability = {
|
|
162
|
+
...dependency,
|
|
163
|
+
key: featureKey("business.report"),
|
|
164
|
+
allowedLicenseVersions: [licenseVersionKey("standard")],
|
|
165
|
+
dependencies: [dependency.key],
|
|
166
|
+
};
|
|
167
|
+
const runtime = createServerRuntime(
|
|
168
|
+
{},
|
|
169
|
+
{
|
|
170
|
+
actorResolver: { resolve: () => actor(["business.report.read"]) },
|
|
171
|
+
licenseResolver: {
|
|
172
|
+
resolve: () => ({
|
|
173
|
+
versionKey: licenseVersionKey("standard"),
|
|
174
|
+
entitlements: [],
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
177
|
+
featureCatalog: [dependency, capability],
|
|
178
|
+
featureResolver: {
|
|
179
|
+
resolve: (key) => ({ key, enabled: true, visible: "visible" }),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
const context = await runtime.createGuardContext(
|
|
184
|
+
new Request("http://local"),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const result = await evaluateAccessGuards(runtime, context, {
|
|
188
|
+
permission: { permissionKey: "business.report.read" },
|
|
189
|
+
feature: capability,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result.decision.reason).toBe("dependency-missing");
|
|
193
|
+
});
|
|
194
|
+
|
|
84
195
|
test("缺少 Entitlement 时不读取 Feature 状态,开关不能绕过商业授权", async () => {
|
|
85
196
|
let featureResolveCount = 0;
|
|
86
197
|
const runtime = createServerRuntime(
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
isLicenseVersionAllowed,
|
|
3
|
+
resolveFeatureState,
|
|
4
|
+
type FeatureFlagDefinition,
|
|
5
|
+
type FeatureFlagState,
|
|
6
|
+
} from "@southwind-ai/shared";
|
|
7
|
+
import type { RequestGuardContext } from "./guards.js";
|
|
2
8
|
|
|
3
9
|
export interface FeatureFlagRuntimeResolver {
|
|
4
10
|
resolve(
|
|
@@ -16,11 +22,16 @@ export interface FeatureFlagRuntimeState {
|
|
|
16
22
|
allowedLicenseVersions?: FeatureFlagDefinition["allowedLicenseVersions"];
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
/** 运行时解析后的 Feature;disabledReason 复用 shared resolveFeatureState 语义。 */
|
|
26
|
+
export interface ResolvedFeatureFlag extends FeatureFlagDefinition {
|
|
27
|
+
disabledReason?: FeatureFlagState["reason"];
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
export class RuntimeFeatureCatalog {
|
|
20
31
|
private readonly definitions = new Map<string, FeatureFlagDefinition>();
|
|
21
32
|
private readonly requestCache = new WeakMap<
|
|
22
33
|
object,
|
|
23
|
-
Map<string,
|
|
34
|
+
Map<string, ResolvedFeatureFlag>
|
|
24
35
|
>();
|
|
25
36
|
|
|
26
37
|
constructor(
|
|
@@ -34,18 +45,18 @@ export class RuntimeFeatureCatalog {
|
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
resolve(
|
|
37
|
-
context:
|
|
48
|
+
context: RequestGuardContext,
|
|
38
49
|
manifestFeature: FeatureFlagDefinition,
|
|
39
|
-
): Promise<
|
|
50
|
+
): Promise<ResolvedFeatureFlag> {
|
|
40
51
|
this.definitions.set(manifestFeature.key, manifestFeature);
|
|
41
52
|
return this.resolveInternal(context, manifestFeature, new Set());
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
private async resolveInternal(
|
|
45
|
-
context:
|
|
56
|
+
context: RequestGuardContext,
|
|
46
57
|
manifestFeature: FeatureFlagDefinition,
|
|
47
58
|
visiting: ReadonlySet<string>,
|
|
48
|
-
): Promise<
|
|
59
|
+
): Promise<ResolvedFeatureFlag> {
|
|
49
60
|
const cache = this.cacheFor(context);
|
|
50
61
|
const cached = cache.get(manifestFeature.key);
|
|
51
62
|
if (cached) return cached;
|
|
@@ -53,33 +64,42 @@ export class RuntimeFeatureCatalog {
|
|
|
53
64
|
return disabledFeature(manifestFeature);
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
const own = await this.resolveOwnState(manifestFeature);
|
|
68
|
+
// dependencies 来自 manifest;启用集合取运行时解析后的依赖状态,
|
|
69
|
+
// 最终判定统一交给 shared resolveFeatureState,避免第二套漂移逻辑。
|
|
70
|
+
const enabledDependencies = new Set<string>();
|
|
71
|
+
if (
|
|
72
|
+
own.enabled &&
|
|
73
|
+
own.visible === "visible" &&
|
|
74
|
+
manifestFeature.dependencies?.length
|
|
75
|
+
) {
|
|
76
|
+
const nextVisiting = new Set(visiting);
|
|
77
|
+
nextVisiting.add(manifestFeature.key);
|
|
78
|
+
for (const dependencyKey of manifestFeature.dependencies) {
|
|
79
|
+
const dependency = this.definitions.get(dependencyKey);
|
|
80
|
+
if (!dependency) continue;
|
|
81
|
+
const dependencyState = await this.resolveInternal(
|
|
82
|
+
context,
|
|
83
|
+
dependency,
|
|
84
|
+
nextVisiting,
|
|
85
|
+
);
|
|
86
|
+
if (
|
|
87
|
+
dependencyState.enabled &&
|
|
88
|
+
dependencyState.visible === "visible" &&
|
|
89
|
+
isFeatureLicensedForContext(dependencyState, context)
|
|
90
|
+
) {
|
|
91
|
+
enabledDependencies.add(dependencyKey);
|
|
92
|
+
}
|
|
80
93
|
}
|
|
81
94
|
}
|
|
82
95
|
|
|
96
|
+
const state = resolveFeatureState(own, enabledDependencies, true);
|
|
97
|
+
const resolved: ResolvedFeatureFlag = {
|
|
98
|
+
...own,
|
|
99
|
+
enabled: state.enabled,
|
|
100
|
+
visible: state.visible,
|
|
101
|
+
...(state.reason ? { disabledReason: state.reason } : {}),
|
|
102
|
+
};
|
|
83
103
|
cache.set(manifestFeature.key, resolved);
|
|
84
104
|
return resolved;
|
|
85
105
|
}
|
|
@@ -108,7 +128,9 @@ export class RuntimeFeatureCatalog {
|
|
|
108
128
|
}
|
|
109
129
|
}
|
|
110
130
|
|
|
111
|
-
private cacheFor(
|
|
131
|
+
private cacheFor(
|
|
132
|
+
context: RequestGuardContext,
|
|
133
|
+
): Map<string, ResolvedFeatureFlag> {
|
|
112
134
|
let cache = this.requestCache.get(context);
|
|
113
135
|
if (!cache) {
|
|
114
136
|
cache = new Map();
|
|
@@ -118,6 +140,19 @@ export class RuntimeFeatureCatalog {
|
|
|
118
140
|
}
|
|
119
141
|
}
|
|
120
142
|
|
|
143
|
+
function isFeatureLicensedForContext(
|
|
144
|
+
feature: FeatureFlagDefinition,
|
|
145
|
+
context: RequestGuardContext,
|
|
146
|
+
): boolean {
|
|
147
|
+
return (
|
|
148
|
+
isLicenseVersionAllowed(feature, context.license?.versionKey) &&
|
|
149
|
+
(!feature.requiredEntitlement ||
|
|
150
|
+
Boolean(
|
|
151
|
+
context.license?.entitlements?.includes(feature.requiredEntitlement),
|
|
152
|
+
))
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
function disabledFeature(
|
|
122
157
|
feature: FeatureFlagDefinition,
|
|
123
158
|
): FeatureFlagDefinition {
|
|
@@ -25,11 +25,13 @@ import { developmentAdminActor } from "./runtime-development.js";
|
|
|
25
25
|
import {
|
|
26
26
|
RuntimeFeatureCatalog,
|
|
27
27
|
type FeatureFlagRuntimeResolver,
|
|
28
|
+
type ResolvedFeatureFlag,
|
|
28
29
|
} from "./runtime-feature.js";
|
|
29
30
|
|
|
30
31
|
export type {
|
|
31
32
|
FeatureFlagRuntimeResolver,
|
|
32
33
|
FeatureFlagRuntimeState,
|
|
34
|
+
ResolvedFeatureFlag,
|
|
33
35
|
} from "./runtime-feature.js";
|
|
34
36
|
|
|
35
37
|
export interface SessionRecord {
|
|
@@ -230,7 +232,7 @@ export function createServerRuntime(
|
|
|
230
232
|
async resolveFeature(
|
|
231
233
|
context: ReturnType<typeof createRequestGuardContext>,
|
|
232
234
|
manifestFeature: FeatureFlagDefinition,
|
|
233
|
-
): Promise<
|
|
235
|
+
): Promise<ResolvedFeatureFlag> {
|
|
234
236
|
return featureCatalog.resolve(context, manifestFeature);
|
|
235
237
|
},
|
|
236
238
|
recordDecisions(decisions: Array<GuardDecision | undefined>) {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { featureKey } from "@southwind-ai/shared";
|
|
3
|
+
import { validateCapabilityCatalogs } from "@southwind-ai/modules";
|
|
3
4
|
import {
|
|
4
5
|
createFoundationSnapshot,
|
|
5
6
|
loadFoundationModules,
|
|
6
7
|
} from "../foundation.js";
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
|
|
8
|
+
import { createWorkOrderModule } from "../example-modules.js";
|
|
9
|
+
import {
|
|
10
|
+
adaptModuleSearchProviders,
|
|
11
|
+
initializeModuleHosts,
|
|
12
|
+
} from "../module-host.js";
|
|
10
13
|
|
|
11
14
|
describe("平台模块 composition root", () => {
|
|
12
15
|
test("默认安装平台能力模块与可删除的工单模块", () => {
|
|
@@ -47,6 +50,21 @@ describe("平台模块 composition root", () => {
|
|
|
47
50
|
);
|
|
48
51
|
});
|
|
49
52
|
|
|
53
|
+
test("示例工单的合法 Migration Bundle 通过启动校验", () => {
|
|
54
|
+
const modules = loadFoundationModules();
|
|
55
|
+
const workOrder = modules.find(({ name }) => name === "work-order");
|
|
56
|
+
|
|
57
|
+
expect(() => validateCapabilityCatalogs(modules)).not.toThrow();
|
|
58
|
+
expect(workOrder?.migrationBundle?.migrations.map(({ id }) => id)).toEqual([
|
|
59
|
+
"work-order.0001.create-work-orders",
|
|
60
|
+
]);
|
|
61
|
+
expect(
|
|
62
|
+
workOrder?.migrationBundle?.migrations.every(({ checksum }) =>
|
|
63
|
+
checksum.startsWith("sha256:"),
|
|
64
|
+
),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
50
68
|
test("业务 API 的 package 依赖图不经过 Windy Web 或 Vue", async () => {
|
|
51
69
|
const serverPackage = await packageJson(
|
|
52
70
|
new URL("../../package.json", import.meta.url),
|
|
@@ -66,21 +84,23 @@ describe("平台模块 composition root", () => {
|
|
|
66
84
|
});
|
|
67
85
|
expect(serverPackage.dependencies).not.toHaveProperty("@southwind-ai/web");
|
|
68
86
|
expect(serverPackage.dependencies).not.toHaveProperty("vue");
|
|
69
|
-
expect(businessPackage.dependencies).not.toHaveProperty(
|
|
87
|
+
expect(businessPackage.dependencies).not.toHaveProperty(
|
|
88
|
+
"@southwind-ai/web",
|
|
89
|
+
);
|
|
70
90
|
expect(businessPackage.dependencies).not.toHaveProperty("vue");
|
|
71
91
|
expect(webPackage.dependencies).not.toHaveProperty(
|
|
72
92
|
"@southwind-ai/example-work-order",
|
|
73
93
|
);
|
|
74
94
|
});
|
|
75
95
|
|
|
76
|
-
test("
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
test("示例模块经 Host 声明式装配提供搜索 Provider", async () => {
|
|
97
|
+
const module = createWorkOrderModule();
|
|
98
|
+
const hosts = await initializeModuleHosts({
|
|
99
|
+
modules: [module.manifest],
|
|
100
|
+
installations: [module],
|
|
81
101
|
});
|
|
82
102
|
|
|
83
|
-
expect(
|
|
103
|
+
expect(adaptModuleSearchProviders(hosts).map(({ key }) => key)).toEqual([
|
|
84
104
|
"work-order.ticket.search",
|
|
85
105
|
]);
|
|
86
106
|
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
workOrderModule,
|
|
4
|
+
type WorkOrder,
|
|
5
|
+
} from "@southwind-ai/example-work-order";
|
|
3
6
|
import { licenseVersionKey } from "@southwind-ai/shared";
|
|
7
|
+
import { initializeModuleHosts, installModuleRoutes } from "../module-host.js";
|
|
8
|
+
import { createWorkOrderModule } from "../example-modules.js";
|
|
4
9
|
import { InMemoryEntityRepository } from "../system/repository.js";
|
|
5
10
|
import { InMemoryScopedEntityRepository } from "../data-access/scoped-repository.js";
|
|
6
11
|
import { buildScopeContext } from "../data-access/context.js";
|
|
@@ -8,8 +13,6 @@ import type { ManagedDepartment } from "../system/entities.js";
|
|
|
8
13
|
import { FakeRouteApp } from "../system/route-test-app.js";
|
|
9
14
|
import { createServerRuntime } from "../runtime.js";
|
|
10
15
|
import type { RequestActor } from "../guards.js";
|
|
11
|
-
import { registerWorkOrderRoutes } from "./routes.js";
|
|
12
|
-
import { WorkOrderService } from "./service.js";
|
|
13
16
|
import { workOrderDataScopeBinding } from "./repository.js";
|
|
14
17
|
|
|
15
18
|
describe("示例工单 routes", () => {
|
|
@@ -39,6 +42,9 @@ describe("示例工单 routes", () => {
|
|
|
39
42
|
expect(await reason(rbacDenied.invoke("GET", "/work-orders"))).toBe(
|
|
40
43
|
"missing-permission",
|
|
41
44
|
);
|
|
45
|
+
expect(
|
|
46
|
+
rbacDenied.runtime.auditSink.list().map(({ action }) => action),
|
|
47
|
+
).toContain("security.denied");
|
|
42
48
|
|
|
43
49
|
const licenseDenied = await createFixture({ licenseVersion: "basic" });
|
|
44
50
|
expect(await reason(licenseDenied.invoke("GET", "/work-orders"))).toBe(
|
|
@@ -100,23 +106,29 @@ async function createFixture(options: FixtureOptions = {}) {
|
|
|
100
106
|
},
|
|
101
107
|
},
|
|
102
108
|
);
|
|
109
|
+
const module = createWorkOrderModule();
|
|
110
|
+
const hosts = await initializeModuleHosts({
|
|
111
|
+
modules: [module.manifest],
|
|
112
|
+
installations: [module],
|
|
113
|
+
});
|
|
114
|
+
module.attach({
|
|
115
|
+
departments: new InMemoryEntityRepository<ManagedDepartment>([
|
|
116
|
+
department("dept_a"),
|
|
117
|
+
department("dept_b"),
|
|
118
|
+
]),
|
|
119
|
+
repository: new InMemoryScopedEntityRepository<WorkOrder>(
|
|
120
|
+
[
|
|
121
|
+
ticket("ticket_self", "user_self", "dept_a"),
|
|
122
|
+
ticket("ticket_other", "user_other", "dept_b"),
|
|
123
|
+
],
|
|
124
|
+
workOrderDataScopeBinding,
|
|
125
|
+
),
|
|
126
|
+
});
|
|
103
127
|
const app = new FakeRouteApp();
|
|
104
|
-
|
|
105
|
-
[
|
|
106
|
-
ticket("ticket_self", "user_self", "dept_a"),
|
|
107
|
-
ticket("ticket_other", "user_other", "dept_b"),
|
|
108
|
-
],
|
|
109
|
-
workOrderDataScopeBinding,
|
|
110
|
-
);
|
|
111
|
-
const departments = new InMemoryEntityRepository<ManagedDepartment>([
|
|
112
|
-
department("dept_a"),
|
|
113
|
-
department("dept_b"),
|
|
114
|
-
]);
|
|
115
|
-
registerWorkOrderRoutes(
|
|
116
|
-
app,
|
|
117
|
-
new WorkOrderService(workOrders, departments),
|
|
128
|
+
installModuleRoutes(hosts, app, {
|
|
118
129
|
runtime,
|
|
119
|
-
|
|
130
|
+
features: workOrderModule().features,
|
|
131
|
+
});
|
|
120
132
|
const guardContext = await runtime.createGuardContext(
|
|
121
133
|
new Request("http://localhost/api/work-orders"),
|
|
122
134
|
);
|