mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: enhance JSON serialization safety & add dxt upload limit (#230)
This commit is contained in:
50
.github/copilot-instructions.md
vendored
Normal file
50
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# MCPHub Coding Instructions
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
|
||||||
|
|
||||||
|
**Core Components:**
|
||||||
|
|
||||||
|
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
|
||||||
|
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
|
||||||
|
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # Start both backend and frontend
|
||||||
|
pnpm backend:dev # Backend only
|
||||||
|
pnpm frontend:dev # Frontend only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
- `src/services/` - Core business logic
|
||||||
|
- `src/controllers/` - HTTP request handlers
|
||||||
|
- `src/types/index.ts` - TypeScript type definitions
|
||||||
|
- `src/config/index.ts` - Configuration management
|
||||||
|
|
||||||
|
### Key Notes
|
||||||
|
|
||||||
|
- Use ESM modules: Import with `.js` extensions, not `.ts`
|
||||||
|
- Configuration file: `mcp_settings.json`
|
||||||
|
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
|
||||||
|
- All code comments must be written in English
|
||||||
|
- Frontend uses i18n with resource files in `locales/` folder
|
||||||
|
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
|
||||||
|
|
||||||
|
## Development Process
|
||||||
|
|
||||||
|
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
|
||||||
|
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
|
||||||
|
|
||||||
|
### Development Entry Points
|
||||||
|
|
||||||
|
- **MCP Servers**: Modify `src/services/mcpService.ts`
|
||||||
|
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
|
||||||
|
- **Frontend Features**: Start from `frontend/src/pages/`
|
||||||
|
- **Testing**: Follow existing patterns in `tests/`
|
||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: samanhappy/mcphub
|
images: ${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||||
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import DashboardPage from './pages/Dashboard';
|
import DashboardPage from './pages/Dashboard';
|
||||||
import ServersPage from './pages/ServersPage';
|
import ServersPage from './pages/ServersPage';
|
||||||
import GroupsPage from './pages/GroupsPage';
|
import GroupsPage from './pages/GroupsPage';
|
||||||
|
import UsersPage from './pages/UsersPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import MarketPage from './pages/MarketPage';
|
import MarketPage from './pages/MarketPage';
|
||||||
import LogsPage from './pages/LogsPage';
|
import LogsPage from './pages/LogsPage';
|
||||||
@@ -31,6 +32,7 @@ function App() {
|
|||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/servers" element={<ServersPage />} />
|
<Route path="/servers" element={<ServersPage />} />
|
||||||
<Route path="/groups" element={<GroupsPage />} />
|
<Route path="/groups" element={<GroupsPage />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/market" element={<MarketPage />} />
|
<Route path="/market" element={<MarketPage />} />
|
||||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||||
<Route path="/logs" element={<LogsPage />} />
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
|
|||||||
95
frontend/src/components/PermissionChecker.tsx
Normal file
95
frontend/src/components/PermissionChecker.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface PermissionCheckerProps {
|
||||||
|
permissions: string | string[];
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission checker component for conditional rendering
|
||||||
|
* @param permissions Required permissions, supports single permission string or permission array
|
||||||
|
* @param fallback Content to show when permission is denied, defaults to null
|
||||||
|
* @param children Content to show when permission is granted
|
||||||
|
*/
|
||||||
|
export const PermissionChecker: React.FC<PermissionCheckerProps> = ({
|
||||||
|
permissions,
|
||||||
|
fallback = null,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const hasPermission = usePermissionCheck(permissions);
|
||||||
|
|
||||||
|
return hasPermission ? <>{children}</> : <>{fallback}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission check hook
|
||||||
|
* @param requiredPermissions Permissions to check
|
||||||
|
* @returns Whether user has permission
|
||||||
|
*/
|
||||||
|
export const usePermissionCheck = (requiredPermissions: string | string[]): boolean => {
|
||||||
|
const { auth } = useAuth();
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated || !auth.user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPermissions = auth.user.permissions || [];
|
||||||
|
|
||||||
|
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has '*' permission, they have all permissions
|
||||||
|
if (userPermissions.includes('*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is admin, they have all permissions by default
|
||||||
|
if (auth.user.isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize required permissions to array
|
||||||
|
const permissionsToCheck = Array.isArray(requiredPermissions)
|
||||||
|
? requiredPermissions
|
||||||
|
: [requiredPermissions];
|
||||||
|
|
||||||
|
// Check if user has any of the required permissions
|
||||||
|
return permissionsToCheck.some(permission =>
|
||||||
|
userPermissions.includes(permission)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission check hook - requires all permissions
|
||||||
|
* @param requiredPermissions Array of permissions to check
|
||||||
|
* @returns Whether user has all permissions
|
||||||
|
*/
|
||||||
|
export const usePermissionCheckAll = (requiredPermissions: string[]): boolean => {
|
||||||
|
const { auth } = useAuth();
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated || !auth.user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPermissions = auth.user.permissions || [];
|
||||||
|
|
||||||
|
// If user has '*' permission, they have all permissions
|
||||||
|
if (userPermissions.includes('*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is admin, they have all permissions by default
|
||||||
|
if (auth.user.isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has all required permissions
|
||||||
|
return requiredPermissions.every(permission =>
|
||||||
|
userPermissions.includes(permission)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PermissionChecker;
|
||||||
@@ -624,9 +624,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addHeaderVar}
|
onClick={addHeaderVar}
|
||||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||||
>
|
>
|
||||||
+ {t('server.add')}
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{headerVars.map((headerVar, index) => (
|
{headerVars.map((headerVar, index) => (
|
||||||
@@ -651,9 +651,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeHeaderVar(index)}
|
onClick={() => removeHeaderVar(index)}
|
||||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||||
>
|
>
|
||||||
- {t('server.remove')}
|
-
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -685,9 +685,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addHeaderVar}
|
onClick={addHeaderVar}
|
||||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||||
>
|
>
|
||||||
+ {t('server.add')}
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{headerVars.map((headerVar, index) => (
|
{headerVars.map((headerVar, index) => (
|
||||||
@@ -712,9 +712,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeHeaderVar(index)}
|
onClick={() => removeHeaderVar(index)}
|
||||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||||
>
|
>
|
||||||
- {t('server.remove')}
|
-
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -761,9 +761,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addEnvVar}
|
onClick={addEnvVar}
|
||||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||||
>
|
>
|
||||||
+ {t('server.add')}
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{envVars.map((envVar, index) => (
|
{envVars.map((envVar, index) => (
|
||||||
@@ -788,9 +788,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeEnvVar(index)}
|
onClick={() => removeEnvVar(index)}
|
||||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||||
>
|
>
|
||||||
- {t('server.remove')}
|
-
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
42
frontend/src/components/index.ts
Normal file
42
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Permission components unified export
|
||||||
|
export { PermissionChecker, usePermissionCheck, usePermissionCheckAll } from './PermissionChecker';
|
||||||
|
export { PERMISSIONS } from '../constants/permissions';
|
||||||
|
|
||||||
|
// Convenient permission check Hook
|
||||||
|
export { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
// Permission utility functions
|
||||||
|
export const hasPermission = (
|
||||||
|
userPermissions: string[] = [],
|
||||||
|
requiredPermissions: string | string[],
|
||||||
|
): boolean => {
|
||||||
|
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has '*' permission, it means they have all permissions
|
||||||
|
if (userPermissions.includes('*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize required permissions to array
|
||||||
|
const permissionsToCheck = Array.isArray(requiredPermissions)
|
||||||
|
? requiredPermissions
|
||||||
|
: [requiredPermissions];
|
||||||
|
|
||||||
|
// Check if user has any of the required permissions
|
||||||
|
return permissionsToCheck.some((permission) => userPermissions.includes(permission));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasAllPermissions = (
|
||||||
|
userPermissions: string[] = [],
|
||||||
|
requiredPermissions: string[],
|
||||||
|
): boolean => {
|
||||||
|
// If user has '*' permission, it means they have all permissions
|
||||||
|
if (userPermissions.includes('*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has all required permissions
|
||||||
|
return requiredPermissions.every((permission) => userPermissions.includes(permission));
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { usePermissionCheck } from '../PermissionChecker';
|
||||||
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -15,6 +17,7 @@ interface MenuItem {
|
|||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { auth } = useAuth();
|
||||||
|
|
||||||
// Application version from package.json (accessed via Vite environment variables)
|
// Application version from package.json (accessed via Vite environment variables)
|
||||||
const appVersion = import.meta.env.PACKAGE_VERSION as string;
|
const appVersion = import.meta.env.PACKAGE_VERSION as string;
|
||||||
@@ -49,6 +52,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(auth.user?.isAdmin && usePermissionCheck('x') ? [{
|
||||||
|
path: '/users',
|
||||||
|
label: t('nav.users'),
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
path: '/market',
|
path: '/market',
|
||||||
label: t('nav.market'),
|
label: t('nav.market'),
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ interface DeleteDialogProps {
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
serverName: string
|
serverName: string
|
||||||
isGroup?: boolean
|
isGroup?: boolean
|
||||||
|
isUser?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
|
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false, isUser = false }: DeleteDialogProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
@@ -18,12 +19,18 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
|
|||||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||||
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
|
{isUser
|
||||||
|
? t('users.confirmDelete')
|
||||||
|
: isGroup
|
||||||
|
? t('groups.confirmDelete')
|
||||||
|
: t('server.confirmDelete')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 mb-6">
|
<p className="text-gray-500 mb-6">
|
||||||
{isGroup
|
{isUser
|
||||||
? t('groups.deleteWarning', { name: serverName })
|
? t('users.deleteWarning', { username: serverName })
|
||||||
: t('server.deleteWarning', { name: serverName })}
|
: isGroup
|
||||||
|
? t('groups.deleteWarning', { name: serverName })
|
||||||
|
: t('server.deleteWarning', { name: serverName })}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
9
frontend/src/constants/permissions.ts
Normal file
9
frontend/src/constants/permissions.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Predefined permission constants
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
// Settings page permissions
|
||||||
|
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||||
|
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||||
|
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default PERMISSIONS;
|
||||||
@@ -274,16 +274,19 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--color-blue-100) !important;
|
background-color: #60a5fa !important;
|
||||||
color: var(--color-blue-800) !important;
|
color: #ffffff !important;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background-color: var(--color-blue-200) !important;
|
background-color: #3b82f6 !important;
|
||||||
color: var(--color-blue-800) !important;
|
color: #ffffff !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced button styles for dark theme */
|
/* Enhanced button styles for dark theme */
|
||||||
|
|||||||
@@ -173,6 +173,9 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"update": "Update",
|
||||||
|
"updating": "Updating...",
|
||||||
"submitting": "Submitting...",
|
"submitting": "Submitting...",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
@@ -186,6 +189,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"servers": "Servers",
|
"servers": "Servers",
|
||||||
"groups": "Groups",
|
"groups": "Groups",
|
||||||
|
"users": "Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"changePassword": "Change Password",
|
"changePassword": "Change Password",
|
||||||
"market": "Market",
|
"market": "Market",
|
||||||
@@ -206,6 +210,9 @@
|
|||||||
"groups": {
|
"groups": {
|
||||||
"title": "Group Management"
|
"title": "Group Management"
|
||||||
},
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "User Management"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -399,5 +406,41 @@
|
|||||||
"serverExistsTitle": "Server Already Exists",
|
"serverExistsTitle": "Server Already Exists",
|
||||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||||
"override": "Override"
|
"override": "Override"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"add": "Add User",
|
||||||
|
"addNew": "Add New User",
|
||||||
|
"edit": "Edit User",
|
||||||
|
"delete": "Delete User",
|
||||||
|
"create": "Create User",
|
||||||
|
"update": "Update User",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"adminRole": "Administrator",
|
||||||
|
"admin": "Admin",
|
||||||
|
"user": "User",
|
||||||
|
"permissions": "Permissions",
|
||||||
|
"adminPermissions": "Full system access",
|
||||||
|
"userPermissions": "Limited access",
|
||||||
|
"currentUser": "You",
|
||||||
|
"noUsers": "No users found",
|
||||||
|
"adminRequired": "Administrator access required to manage users",
|
||||||
|
"usernameRequired": "Username is required",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"passwordTooShort": "Password must be at least 6 characters long",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"usernamePlaceholder": "Enter username",
|
||||||
|
"passwordPlaceholder": "Enter password",
|
||||||
|
"newPasswordPlaceholder": "Leave empty to keep current password",
|
||||||
|
"confirmPasswordPlaceholder": "Confirm new password",
|
||||||
|
"createError": "Failed to create user",
|
||||||
|
"updateError": "Failed to update user",
|
||||||
|
"deleteError": "Failed to delete user",
|
||||||
|
"statsError": "Failed to fetch user statistics",
|
||||||
|
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
|
||||||
|
"confirmDelete": "Delete User",
|
||||||
|
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +174,9 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"create": "创建",
|
"create": "创建",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"update": "更新",
|
||||||
|
"updating": "更新中...",
|
||||||
"submitting": "提交中...",
|
"submitting": "提交中...",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
@@ -189,6 +192,7 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"changePassword": "修改密码",
|
"changePassword": "修改密码",
|
||||||
"groups": "分组",
|
"groups": "分组",
|
||||||
|
"users": "用户",
|
||||||
"market": "市场",
|
"market": "市场",
|
||||||
"logs": "日志"
|
"logs": "日志"
|
||||||
},
|
},
|
||||||
@@ -217,6 +221,9 @@
|
|||||||
"groups": {
|
"groups": {
|
||||||
"title": "分组管理"
|
"title": "分组管理"
|
||||||
},
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "用户管理"
|
||||||
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||||
},
|
},
|
||||||
@@ -401,5 +408,41 @@
|
|||||||
"serverExistsTitle": "服务器已存在",
|
"serverExistsTitle": "服务器已存在",
|
||||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||||
"override": "覆盖"
|
"override": "覆盖"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"add": "添加",
|
||||||
|
"addNew": "添加新用户",
|
||||||
|
"edit": "编辑用户",
|
||||||
|
"delete": "删除用户",
|
||||||
|
"create": "创建",
|
||||||
|
"update": "用户",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"newPassword": "新密码",
|
||||||
|
"confirmPassword": "确认密码",
|
||||||
|
"adminRole": "管理员",
|
||||||
|
"admin": "管理员",
|
||||||
|
"user": "用户",
|
||||||
|
"permissions": "权限",
|
||||||
|
"adminPermissions": "完全系统访问权限",
|
||||||
|
"userPermissions": "受限访问权限",
|
||||||
|
"currentUser": "当前用户",
|
||||||
|
"noUsers": "没有找到用户",
|
||||||
|
"adminRequired": "需要管理员权限才能管理用户",
|
||||||
|
"usernameRequired": "用户名是必需的",
|
||||||
|
"passwordRequired": "密码是必需的",
|
||||||
|
"passwordTooShort": "密码至少需要6个字符",
|
||||||
|
"passwordMismatch": "密码不匹配",
|
||||||
|
"usernamePlaceholder": "输入用户名",
|
||||||
|
"passwordPlaceholder": "输入密码",
|
||||||
|
"newPasswordPlaceholder": "留空保持当前密码",
|
||||||
|
"confirmPasswordPlaceholder": "确认新密码",
|
||||||
|
"createError": "创建用户失败",
|
||||||
|
"updateError": "更新用户失败",
|
||||||
|
"deleteError": "删除用户失败",
|
||||||
|
"statsError": "获取用户统计失败",
|
||||||
|
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
|
||||||
|
"confirmDelete": "删除用户",
|
||||||
|
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ const LoginPage: React.FC = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
<div className="rounded-md -space-y-px">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="sr-only">
|
<label htmlFor="username" className="sr-only">
|
||||||
{t('auth.username')}
|
{t('auth.username')}
|
||||||
@@ -62,7 +62,7 @@ const LoginPage: React.FC = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input"
|
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input shadow-sm"
|
||||||
placeholder={t('auth.username')}
|
placeholder={t('auth.username')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
@@ -78,7 +78,7 @@ const LoginPage: React.FC = () => {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input"
|
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input shadow-sm"
|
||||||
placeholder={t('auth.password')}
|
placeholder={t('auth.password')}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Switch } from '@/components/ui/ToggleGroup';
|
|||||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
import { generateRandomKey } from '@/utils/key';
|
import { generateRandomKey } from '@/utils/key';
|
||||||
|
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
|
||||||
const SettingsPage: React.FC = () => {
|
const SettingsPage: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -230,129 +232,131 @@ const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Smart Routing Configuration Settings */}
|
{/* Smart Routing Configuration Settings */}
|
||||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
|
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SMART_ROUTING}>
|
||||||
<div
|
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
|
||||||
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
|
<div
|
||||||
onClick={() => toggleSection('smartRoutingConfig')}
|
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
|
||||||
>
|
onClick={() => toggleSection('smartRoutingConfig')}
|
||||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
>
|
||||||
<span className="text-gray-500 transition-transform duration-200">
|
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
||||||
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
<span className="text-gray-500 transition-transform duration-200">
|
||||||
</span>
|
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
{sectionsVisible.smartRoutingConfig && (
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
|
||||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
disabled={loading}
|
|
||||||
checked={smartRoutingConfig.enabled}
|
|
||||||
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="font-medium text-gray-700">
|
|
||||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tempSmartRoutingConfig.dbUrl}
|
|
||||||
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
|
||||||
placeholder={t('settings.dbUrlPlaceholder')}
|
|
||||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="font-medium text-gray-700">
|
|
||||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={tempSmartRoutingConfig.openaiApiKey}
|
|
||||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
|
||||||
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
|
||||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
|
||||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
|
||||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
|
||||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
|
||||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
|
||||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
|
||||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{sectionsVisible.smartRoutingConfig && (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
disabled={loading}
|
||||||
|
checked={smartRoutingConfig.enabled}
|
||||||
|
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">
|
||||||
|
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tempSmartRoutingConfig.dbUrl}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
||||||
|
placeholder={t('settings.dbUrlPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">
|
||||||
|
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={tempSmartRoutingConfig.openaiApiKey}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
||||||
|
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||||
|
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||||
|
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PermissionChecker>
|
||||||
|
|
||||||
{/* Route Configuration Settings */}
|
{/* Route Configuration Settings */}
|
||||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||||
@@ -430,86 +434,90 @@ const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
|
||||||
<div>
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
<div>
|
||||||
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
|
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
disabled={loading}
|
||||||
|
checked={routingConfig.skipAuth}
|
||||||
|
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
</PermissionChecker>
|
||||||
disabled={loading}
|
|
||||||
checked={routingConfig.skipAuth}
|
|
||||||
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Installation Configuration Settings */}
|
{/* Installation Configuration Settings */}
|
||||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||||
<div
|
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||||
className="flex justify-between items-center cursor-pointer"
|
<div
|
||||||
onClick={() => toggleSection('installConfig')}
|
className="flex justify-between items-center cursor-pointer"
|
||||||
>
|
onClick={() => toggleSection('installConfig')}
|
||||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
>
|
||||||
<span className="text-gray-500">
|
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
<span className="text-gray-500">
|
||||||
</span>
|
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
{sectionsVisible.installConfig && (
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
|
||||||
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={installConfig.pythonIndexUrl}
|
|
||||||
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
|
||||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
|
||||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
|
||||||
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={installConfig.npmRegistry}
|
|
||||||
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
|
||||||
placeholder={t('settings.npmRegistryPlaceholder')}
|
|
||||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => saveInstallConfig('npmRegistry')}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{sectionsVisible.installConfig && (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={installConfig.pythonIndexUrl}
|
||||||
|
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
||||||
|
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={installConfig.npmRegistry}
|
||||||
|
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
||||||
|
placeholder={t('settings.npmRegistryPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveInstallConfig('npmRegistry')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PermissionChecker>
|
||||||
|
|
||||||
{/* Change Password */}
|
{/* Change Password */}
|
||||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||||
|
|||||||
9
frontend/src/pages/UsersPage.tsx
Normal file
9
frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const UsersPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div></div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersPage;
|
||||||
@@ -210,6 +210,30 @@ export interface ApiResponse<T = any> {
|
|||||||
export interface IUser {
|
export interface IUser {
|
||||||
username: string;
|
username: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// User management types
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserFormData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserUpdateData {
|
||||||
|
isAdmin?: boolean;
|
||||||
|
newPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
totalUsers: number;
|
||||||
|
adminUsers: number;
|
||||||
|
regularUsers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ module.exports = {
|
|||||||
'ts-jest',
|
'ts-jest',
|
||||||
{
|
{
|
||||||
useESM: true,
|
useESM: true,
|
||||||
|
tsconfig: './tsconfig.test.json',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -37,8 +38,10 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
},
|
},
|
||||||
|
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
|
||||||
extensionsToTreatAsEsm: ['.ts'],
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
testTimeout: 10000,
|
testTimeout: 30000,
|
||||||
verbose: true,
|
verbose: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,6 +72,8 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
|
"@swc/core": "^1.13.0",
|
||||||
|
"@swc/jest": "^0.2.39",
|
||||||
"@tailwindcss/postcss": "^4.1.3",
|
"@tailwindcss/postcss": "^4.1.3",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
@@ -118,5 +120,5 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
|
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9"
|
||||||
}
|
}
|
||||||
|
|||||||
210
pnpm-lock.yaml
generated
210
pnpm-lock.yaml
generated
@@ -70,7 +70,7 @@ importers:
|
|||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.24
|
specifier: ^0.3.24
|
||||||
version: 0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
version: 0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@@ -84,6 +84,12 @@ importers:
|
|||||||
'@shadcn/ui':
|
'@shadcn/ui':
|
||||||
specifier: ^0.0.4
|
specifier: ^0.0.4
|
||||||
version: 0.0.4
|
version: 0.0.4
|
||||||
|
'@swc/core':
|
||||||
|
specifier: ^1.13.0
|
||||||
|
version: 1.13.0
|
||||||
|
'@swc/jest':
|
||||||
|
specifier: ^0.2.39
|
||||||
|
version: 0.2.39(@swc/core@1.13.0)
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.1.3
|
specifier: ^4.1.3
|
||||||
version: 4.1.11
|
version: 4.1.11
|
||||||
@@ -149,13 +155,13 @@ importers:
|
|||||||
version: 8.2.0
|
version: 8.2.0
|
||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
specifier: ^29.7.0
|
||||||
version: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
version: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
jest-environment-node:
|
jest-environment-node:
|
||||||
specifier: ^30.0.0
|
specifier: ^30.0.0
|
||||||
version: 30.0.2
|
version: 30.0.2
|
||||||
jest-mock-extended:
|
jest-mock-extended:
|
||||||
specifier: 4.0.0-beta1
|
specifier: 4.0.0-beta1
|
||||||
version: 4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3)
|
version: 4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.486.0
|
specifier: ^0.486.0
|
||||||
version: 0.486.0(react@19.1.0)
|
version: 0.486.0(react@19.1.0)
|
||||||
@@ -194,10 +200,10 @@ importers:
|
|||||||
version: 4.1.11
|
version: 4.1.11
|
||||||
ts-jest:
|
ts-jest:
|
||||||
specifier: ^29.1.1
|
specifier: ^29.1.1
|
||||||
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3)
|
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3)
|
||||||
ts-node-dev:
|
ts-node-dev:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0(@types/node@22.15.34)(typescript@5.8.3)
|
version: 2.0.0(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.20.3
|
version: 4.20.3
|
||||||
@@ -901,6 +907,10 @@ packages:
|
|||||||
node-notifier:
|
node-notifier:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@jest/create-cache-key-function@30.0.2':
|
||||||
|
resolution: {integrity: sha512-AwlDAHwEHDi+etw9vKWx9HeIApVos8GD/sSTpHtDkqhm9OWuEUPKKPP6EaS17yv0GSzBB3TeeJFLyJ5LPjRqWg==}
|
||||||
|
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
|
|
||||||
'@jest/environment@29.7.0':
|
'@jest/environment@29.7.0':
|
||||||
resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==}
|
resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -1348,12 +1358,90 @@ packages:
|
|||||||
'@sqltools/formatter@1.2.5':
|
'@sqltools/formatter@1.2.5':
|
||||||
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
|
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
|
||||||
|
|
||||||
|
'@swc/core-darwin-arm64@1.13.0':
|
||||||
|
resolution: {integrity: sha512-SkmR9u7MHDu2X8hf7SjZTmsAfQTmel0mi+TJ7AGtufLwGySv6pwQfJ/CIJpcPxYENVqDJAFnDrHaKV8mgA6kxQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@swc/core-darwin-x64@1.13.0':
|
||||||
|
resolution: {integrity: sha512-15/SyDjXRtFJ09fYHBXUXrj4tpiSpCkjgsF1z3/sSpHH1POWpQUQzxmFyomPQVZ/SsDqP18WGH09Vph4Qriuiw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@swc/core-linux-arm-gnueabihf@1.13.0':
|
||||||
|
resolution: {integrity: sha512-AHauVHZQEJI/dCZQg6VYNNQ6HROz8dSOnCSheXzzBw1DGWo77BlcxRP0fF0jaAXM9WNqtCUOY1HiJ9ohkAE61Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@swc/core-linux-arm64-gnu@1.13.0':
|
||||||
|
resolution: {integrity: sha512-qyZmBZF7asF6954/x7yn6R7Bzd45KRG05rK2atIF9J3MTa8az7vubP1Q3BWmmss1j8699DELpbuoJucGuhsNXw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@swc/core-linux-arm64-musl@1.13.0':
|
||||||
|
resolution: {integrity: sha512-whskQCOUlLQT7MjnronpHmyHegBka5ig9JkQvecbqhWzRfdwN+c2xTJs3kQsWy2Vc2f1hcL3D8hGIwY5TwPxMQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@swc/core-linux-x64-gnu@1.13.0':
|
||||||
|
resolution: {integrity: sha512-51n4P4nv6rblXyH3zCEktvmR9uSAZ7+zbfeby0sxbj8LS/IKuVd7iCwD5dwMj4CxG9Fs+HgjN73dLQF/OerHhg==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@swc/core-linux-x64-musl@1.13.0':
|
||||||
|
resolution: {integrity: sha512-VMqelgvnXs27eQyhDf1S2O2MxSdchIH7c1tkxODRtu9eotcAeniNNgqqLjZ5ML0MGeRk/WpbsAY/GWi7eSpiHw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@swc/core-win32-arm64-msvc@1.13.0':
|
||||||
|
resolution: {integrity: sha512-NLJmseWJngWeENgat+O/WB4ptNxtx2X4OfPnSG5a/A4sxcn2E4jq91OPvbeUQwDkH+ZQWKXmbXFzt7Nn661QYA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@swc/core-win32-ia32-msvc@1.13.0':
|
||||||
|
resolution: {integrity: sha512-UBfwrp0xW37KQGTA08mwrCLIm1ZKy6pXK8IVwou7BvhMgrItRNweTGyUrCnvDLUfyYFuJCmzcEaJ3NudtctD6g==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@swc/core-win32-x64-msvc@1.13.0':
|
||||||
|
resolution: {integrity: sha512-BAB1P7Z/y2EENsfsPytPnjIyBVRZN2WULY+s3ozW4QkGmYHde6XXG28n0ABTHhcIOmmR2VzM+uaW1x48laSimw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@swc/core@1.13.0':
|
||||||
|
resolution: {integrity: sha512-7Fh16ZH/Rj3Di720if+sw9BictD4N5kbTpsyDC+URXhvsZ7qRt1lH7PaeIQYyJJQHwFhoKpwwGxfGU9SHgPLdw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
'@swc/helpers': '>=0.5.17'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@swc/helpers':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@swc/counter@0.1.3':
|
'@swc/counter@0.1.3':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
|
'@swc/jest@0.2.39':
|
||||||
|
resolution: {integrity: sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA==}
|
||||||
|
engines: {npm: '>= 7.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@swc/core': '*'
|
||||||
|
|
||||||
|
'@swc/types@0.1.23':
|
||||||
|
resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==}
|
||||||
|
|
||||||
'@tailwindcss/node@4.1.11':
|
'@tailwindcss/node@4.1.11':
|
||||||
resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
|
resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
|
||||||
|
|
||||||
@@ -2920,6 +3008,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1:
|
||||||
|
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||||
|
|
||||||
jsonfile@6.1.0:
|
jsonfile@6.1.0:
|
||||||
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
||||||
|
|
||||||
@@ -4825,7 +4916,7 @@ snapshots:
|
|||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
slash: 3.0.0
|
slash: 3.0.0
|
||||||
|
|
||||||
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))':
|
'@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/console': 29.7.0
|
'@jest/console': 29.7.0
|
||||||
'@jest/reporters': 29.7.0
|
'@jest/reporters': 29.7.0
|
||||||
@@ -4839,7 +4930,7 @@ snapshots:
|
|||||||
exit: 0.1.2
|
exit: 0.1.2
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
jest-changed-files: 29.7.0
|
jest-changed-files: 29.7.0
|
||||||
jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
jest-haste-map: 29.7.0
|
jest-haste-map: 29.7.0
|
||||||
jest-message-util: 29.7.0
|
jest-message-util: 29.7.0
|
||||||
jest-regex-util: 29.6.3
|
jest-regex-util: 29.6.3
|
||||||
@@ -4860,6 +4951,10 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- ts-node
|
- ts-node
|
||||||
|
|
||||||
|
'@jest/create-cache-key-function@30.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@jest/types': 30.0.1
|
||||||
|
|
||||||
'@jest/environment@29.7.0':
|
'@jest/environment@29.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/fake-timers': 29.7.0
|
'@jest/fake-timers': 29.7.0
|
||||||
@@ -5320,12 +5415,69 @@ snapshots:
|
|||||||
|
|
||||||
'@sqltools/formatter@1.2.5': {}
|
'@sqltools/formatter@1.2.5': {}
|
||||||
|
|
||||||
|
'@swc/core-darwin-arm64@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-darwin-x64@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-linux-arm-gnueabihf@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-linux-arm64-gnu@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-linux-arm64-musl@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-linux-x64-gnu@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-linux-x64-musl@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-win32-arm64-msvc@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-win32-ia32-msvc@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core-win32-x64-msvc@1.13.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@swc/core@1.13.0':
|
||||||
|
dependencies:
|
||||||
|
'@swc/counter': 0.1.3
|
||||||
|
'@swc/types': 0.1.23
|
||||||
|
optionalDependencies:
|
||||||
|
'@swc/core-darwin-arm64': 1.13.0
|
||||||
|
'@swc/core-darwin-x64': 1.13.0
|
||||||
|
'@swc/core-linux-arm-gnueabihf': 1.13.0
|
||||||
|
'@swc/core-linux-arm64-gnu': 1.13.0
|
||||||
|
'@swc/core-linux-arm64-musl': 1.13.0
|
||||||
|
'@swc/core-linux-x64-gnu': 1.13.0
|
||||||
|
'@swc/core-linux-x64-musl': 1.13.0
|
||||||
|
'@swc/core-win32-arm64-msvc': 1.13.0
|
||||||
|
'@swc/core-win32-ia32-msvc': 1.13.0
|
||||||
|
'@swc/core-win32-x64-msvc': 1.13.0
|
||||||
|
|
||||||
'@swc/counter@0.1.3': {}
|
'@swc/counter@0.1.3': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@swc/jest@0.2.39(@swc/core@1.13.0)':
|
||||||
|
dependencies:
|
||||||
|
'@jest/create-cache-key-function': 30.0.2
|
||||||
|
'@swc/core': 1.13.0
|
||||||
|
'@swc/counter': 0.1.3
|
||||||
|
jsonc-parser: 3.3.1
|
||||||
|
|
||||||
|
'@swc/types@0.1.23':
|
||||||
|
dependencies:
|
||||||
|
'@swc/counter': 0.1.3
|
||||||
|
|
||||||
'@tailwindcss/node@4.1.11':
|
'@tailwindcss/node@4.1.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
@@ -6093,13 +6245,13 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
vary: 1.1.2
|
vary: 1.1.2
|
||||||
|
|
||||||
create-jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)):
|
create-jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
exit: 0.1.2
|
exit: 0.1.2
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
prompts: 2.4.2
|
prompts: 2.4.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -6911,16 +7063,16 @@ snapshots:
|
|||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
jest-cli@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)):
|
jest-cli@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
'@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
'@jest/test-result': 29.7.0
|
'@jest/test-result': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
create-jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
create-jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
exit: 0.1.2
|
exit: 0.1.2
|
||||||
import-local: 3.2.0
|
import-local: 3.2.0
|
||||||
jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
jest-validate: 29.7.0
|
jest-validate: 29.7.0
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
@@ -6930,7 +7082,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- ts-node
|
- ts-node
|
||||||
|
|
||||||
jest-config@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)):
|
jest-config@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.27.4
|
'@babel/core': 7.27.4
|
||||||
'@jest/test-sequencer': 29.7.0
|
'@jest/test-sequencer': 29.7.0
|
||||||
@@ -6956,7 +7108,7 @@ snapshots:
|
|||||||
strip-json-comments: 3.1.1
|
strip-json-comments: 3.1.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.15.34
|
'@types/node': 22.15.34
|
||||||
ts-node: 10.9.2(@types/node@22.15.34)(typescript@5.8.3)
|
ts-node: 10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -7053,10 +7205,10 @@ snapshots:
|
|||||||
slash: 3.0.0
|
slash: 3.0.0
|
||||||
stack-utils: 2.0.6
|
stack-utils: 2.0.6
|
||||||
|
|
||||||
jest-mock-extended@4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3):
|
jest-mock-extended@4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/globals': 29.7.0
|
'@jest/globals': 29.7.0
|
||||||
jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
ts-essentials: 10.1.0(typescript@5.8.3)
|
ts-essentials: 10.1.0(typescript@5.8.3)
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
@@ -7231,12 +7383,12 @@ snapshots:
|
|||||||
merge-stream: 2.0.0
|
merge-stream: 2.0.0
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
|
|
||||||
jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)):
|
jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
'@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
import-local: 3.2.0
|
import-local: 3.2.0
|
||||||
jest-cli: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
jest-cli: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
@@ -7270,6 +7422,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1: {}
|
||||||
|
|
||||||
jsonfile@6.1.0:
|
jsonfile@6.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
@@ -8257,12 +8411,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3):
|
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
bs-logger: 0.2.6
|
bs-logger: 0.2.6
|
||||||
ejs: 3.1.10
|
ejs: 3.1.10
|
||||||
fast-json-stable-stringify: 2.1.0
|
fast-json-stable-stringify: 2.1.0
|
||||||
jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))
|
jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))
|
||||||
json5: 2.2.3
|
json5: 2.2.3
|
||||||
lodash.memoize: 4.1.2
|
lodash.memoize: 4.1.2
|
||||||
make-error: 1.3.6
|
make-error: 1.3.6
|
||||||
@@ -8277,7 +8431,7 @@ snapshots:
|
|||||||
babel-jest: 29.7.0(@babel/core@7.27.4)
|
babel-jest: 29.7.0(@babel/core@7.27.4)
|
||||||
jest-util: 30.0.2
|
jest-util: 30.0.2
|
||||||
|
|
||||||
ts-node-dev@2.0.0(@types/node@22.15.34)(typescript@5.8.3):
|
ts-node-dev@2.0.0(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 3.6.0
|
chokidar: 3.6.0
|
||||||
dynamic-dedupe: 0.3.0
|
dynamic-dedupe: 0.3.0
|
||||||
@@ -8287,7 +8441,7 @@ snapshots:
|
|||||||
rimraf: 2.7.1
|
rimraf: 2.7.1
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
ts-node: 10.9.2(@types/node@22.15.34)(typescript@5.8.3)
|
ts-node: 10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)
|
||||||
tsconfig: 7.0.0
|
tsconfig: 7.0.0
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -8295,7 +8449,7 @@ snapshots:
|
|||||||
- '@swc/wasm'
|
- '@swc/wasm'
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
|
|
||||||
ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3):
|
ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cspotcode/source-map-support': 0.8.1
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
'@tsconfig/node10': 1.0.11
|
'@tsconfig/node10': 1.0.11
|
||||||
@@ -8312,6 +8466,8 @@ snapshots:
|
|||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
v8-compile-cache-lib: 3.0.1
|
v8-compile-cache-lib: 3.0.1
|
||||||
yn: 3.1.1
|
yn: 3.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@swc/core': 1.13.0
|
||||||
|
|
||||||
tsconfig@7.0.0:
|
tsconfig@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8354,7 +8510,7 @@ snapshots:
|
|||||||
|
|
||||||
typedarray@0.0.6: {}
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typeorm@0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)):
|
typeorm@0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sqltools/formatter': 1.2.5
|
'@sqltools/formatter': 1.2.5
|
||||||
ansis: 3.17.0
|
ansis: 3.17.0
|
||||||
@@ -8373,7 +8529,7 @@ snapshots:
|
|||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
ts-node: 10.9.2(@types/node@22.15.34)(typescript@5.8.3)
|
ts-node: 10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|||||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- '@swc/core'
|
||||||
@@ -3,6 +3,8 @@ import fs from 'fs';
|
|||||||
import { McpSettings } from '../types/index.js';
|
import { McpSettings } from '../types/index.js';
|
||||||
import { getConfigFilePath } from '../utils/path.js';
|
import { getConfigFilePath } from '../utils/path.js';
|
||||||
import { getPackageVersion } from '../utils/version.js';
|
import { getPackageVersion } from '../utils/version.js';
|
||||||
|
import { getDataService } from '../services/services.js';
|
||||||
|
import { DataService } from '../services/dataService.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ const defaultConfig = {
|
|||||||
mcpHubVersion: getPackageVersion(),
|
mcpHubVersion: getPackageVersion(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dataService: DataService = getDataService();
|
||||||
|
|
||||||
// Settings cache
|
// Settings cache
|
||||||
let settingsCache: McpSettings | null = null;
|
let settingsCache: McpSettings | null = null;
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ export const getSettingsPath = (): string => {
|
|||||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSettings = (): McpSettings => {
|
export const loadOriginalSettings = (): McpSettings => {
|
||||||
// If cache exists, return cached data directly
|
// If cache exists, return cached data directly
|
||||||
if (settingsCache) {
|
if (settingsCache) {
|
||||||
return settingsCache;
|
return settingsCache;
|
||||||
@@ -49,13 +53,18 @@ export const loadSettings = (): McpSettings => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadSettings = (): McpSettings => {
|
||||||
|
return dataService.filterSettings!(loadOriginalSettings());
|
||||||
|
};
|
||||||
|
|
||||||
export const saveSettings = (settings: McpSettings): boolean => {
|
export const saveSettings = (settings: McpSettings): boolean => {
|
||||||
const settingsPath = getSettingsPath();
|
const settingsPath = getSettingsPath();
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings);
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||||
|
|
||||||
// Update cache after successful save
|
// Update cache after successful save
|
||||||
settingsCache = settings;
|
settingsCache = mergedSettings;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
import { findUserByUsername, verifyPassword, createUser, updateUserPassword } from '../models/User.js';
|
import {
|
||||||
|
findUserByUsername,
|
||||||
|
verifyPassword,
|
||||||
|
createUser,
|
||||||
|
updateUserPassword,
|
||||||
|
} from '../models/User.js';
|
||||||
|
import { getDataService } from '../services/services.js';
|
||||||
|
import { DataService } from '../services/dataService.js';
|
||||||
|
|
||||||
|
const dataService: DataService = getDataService();
|
||||||
|
|
||||||
// Default secret key - in production, use an environment variable
|
// Default secret key - in production, use an environment variable
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||||
@@ -21,7 +30,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
// Find user by username
|
// Find user by username
|
||||||
const user = findUserByUsername(username);
|
const user = findUserByUsername(username);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||||
return;
|
return;
|
||||||
@@ -29,7 +38,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
|||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const isPasswordValid = await verifyPassword(password, user.password);
|
const isPasswordValid = await verifyPassword(password, user.password);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||||
return;
|
return;
|
||||||
@@ -39,26 +48,22 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
user: {
|
user: {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
isAdmin: user.isAdmin || false
|
isAdmin: user.isAdmin || false,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
jwt.sign(
|
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||||
payload,
|
if (err) throw err;
|
||||||
JWT_SECRET,
|
res.json({
|
||||||
{ expiresIn: TOKEN_EXPIRY },
|
success: true,
|
||||||
(err, token) => {
|
token,
|
||||||
if (err) throw err;
|
user: {
|
||||||
res.json({
|
username: user.username,
|
||||||
success: true,
|
isAdmin: user.isAdmin,
|
||||||
token,
|
permissions: dataService.getPermissions(user),
|
||||||
user: {
|
},
|
||||||
username: user.username,
|
});
|
||||||
isAdmin: user.isAdmin
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Server error' });
|
res.status(500).json({ success: false, message: 'Server error' });
|
||||||
@@ -79,7 +84,7 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
// Create new user
|
// Create new user
|
||||||
const newUser = await createUser({ username, password, isAdmin });
|
const newUser = await createUser({ username, password, isAdmin });
|
||||||
|
|
||||||
if (!newUser) {
|
if (!newUser) {
|
||||||
res.status(400).json({ success: false, message: 'User already exists' });
|
res.status(400).json({ success: false, message: 'User already exists' });
|
||||||
return;
|
return;
|
||||||
@@ -89,26 +94,22 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
user: {
|
user: {
|
||||||
username: newUser.username,
|
username: newUser.username,
|
||||||
isAdmin: newUser.isAdmin || false
|
isAdmin: newUser.isAdmin || false,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
jwt.sign(
|
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||||
payload,
|
if (err) throw err;
|
||||||
JWT_SECRET,
|
res.json({
|
||||||
{ expiresIn: TOKEN_EXPIRY },
|
success: true,
|
||||||
(err, token) => {
|
token,
|
||||||
if (err) throw err;
|
user: {
|
||||||
res.json({
|
username: newUser.username,
|
||||||
success: true,
|
isAdmin: newUser.isAdmin,
|
||||||
token,
|
permissions: dataService.getPermissions(newUser),
|
||||||
user: {
|
},
|
||||||
username: newUser.username,
|
});
|
||||||
isAdmin: newUser.isAdmin
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Registration error:', error);
|
console.error('Registration error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Server error' });
|
res.status(500).json({ success: false, message: 'Server error' });
|
||||||
@@ -120,13 +121,14 @@ export const getCurrentUser = (req: Request, res: Response): void => {
|
|||||||
try {
|
try {
|
||||||
// User is already attached to request by auth middleware
|
// User is already attached to request by auth middleware
|
||||||
const user = (req as any).user;
|
const user = (req as any).user;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
isAdmin: user.isAdmin
|
isAdmin: user.isAdmin,
|
||||||
}
|
permissions: dataService.getPermissions(user),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get current user error:', error);
|
console.error('Get current user error:', error);
|
||||||
@@ -149,7 +151,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
|||||||
try {
|
try {
|
||||||
// Find user by username
|
// Find user by username
|
||||||
const user = findUserByUsername(username);
|
const user = findUserByUsername(username);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ success: false, message: 'User not found' });
|
res.status(404).json({ success: false, message: 'User not found' });
|
||||||
return;
|
return;
|
||||||
@@ -157,7 +159,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
// Verify current password
|
// Verify current password
|
||||||
const isPasswordValid = await verifyPassword(currentPassword, user.password);
|
const isPasswordValid = await verifyPassword(currentPassword, user.password);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
res.status(401).json({ success: false, message: 'Current password is incorrect' });
|
res.status(401).json({ success: false, message: 'Current password is incorrect' });
|
||||||
return;
|
return;
|
||||||
@@ -165,7 +167,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
// Update the password
|
// Update the password
|
||||||
const updated = await updateUserPassword(username, newPassword);
|
const updated = await updateUserPassword(username, newPassword);
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
res.status(500).json({ success: false, message: 'Failed to update password' });
|
res.status(500).json({ success: false, message: 'Failed to update password' });
|
||||||
return;
|
return;
|
||||||
@@ -176,4 +178,4 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
|||||||
console.error('Change password error:', error);
|
console.error('Change password error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Server error' });
|
res.status(500).json({ success: false, message: 'Server error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ import { Request, Response } from 'express';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
|
||||||
// Get the directory name in ESM
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
|
const uploadDir = path.join(process.cwd(), 'data/uploads/dxt');
|
||||||
if (!fs.existsSync(uploadDir)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -36,7 +31,7 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 100 * 1024 * 1024, // 100MB limit
|
fileSize: 500 * 1024 * 1024, // 500MB limit
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +40,7 @@ export const uploadMiddleware = upload.single('dxtFile');
|
|||||||
// Clean up old DXT server files when installing a new version
|
// Clean up old DXT server files when installing a new version
|
||||||
const cleanupOldDxtServer = (serverName: string): void => {
|
const cleanupOldDxtServer = (serverName: string): void => {
|
||||||
try {
|
try {
|
||||||
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
|
const uploadDir = path.join(process.cwd(), 'data/uploads/dxt');
|
||||||
const serverPattern = `server-${serverName}`;
|
const serverPattern = `server-${serverName}`;
|
||||||
|
|
||||||
if (fs.existsSync(uploadDir)) {
|
if (fs.existsSync(uploadDir)) {
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverList = Array.isArray(servers) ? servers : [];
|
const serverList = Array.isArray(servers) ? servers : [];
|
||||||
const newGroup = createGroup(name, description, serverList);
|
|
||||||
|
// Set owner property - use current user's username, default to 'admin'
|
||||||
|
const currentUser = (req as any).user;
|
||||||
|
const owner = currentUser?.username || 'admin';
|
||||||
|
|
||||||
|
const newGroup = createGroup(name, description, serverList, owner);
|
||||||
if (!newGroup) {
|
if (!newGroup) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -11,16 +11,18 @@ import {
|
|||||||
} from '../services/mcpService.js';
|
} from '../services/mcpService.js';
|
||||||
import { loadSettings, saveSettings } from '../config/index.js';
|
import { loadSettings, saveSettings } from '../config/index.js';
|
||||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||||
|
import { createSafeJSON } from '../utils/serialization.js';
|
||||||
|
|
||||||
export const getAllServers = (_: Request, res: Response): void => {
|
export const getAllServers = (_: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
const serversInfo = getServersInfo();
|
const serversInfo = getServersInfo();
|
||||||
const response: ApiResponse = {
|
const response: ApiResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
data: serversInfo,
|
data: createSafeJSON(serversInfo),
|
||||||
};
|
};
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Failed to get servers information:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to get servers information',
|
message: 'Failed to get servers information',
|
||||||
@@ -33,7 +35,7 @@ export const getAllSettings = (_: Request, res: Response): void => {
|
|||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
const response: ApiResponse = {
|
const response: ApiResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
data: settings,
|
data: createSafeJSON(settings),
|
||||||
};
|
};
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -127,6 +129,12 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
|||||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set owner property - use current user's username, default to 'admin'
|
||||||
|
if (!config.owner) {
|
||||||
|
const currentUser = (req as any).user;
|
||||||
|
config.owner = currentUser?.username || 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
const result = await addServer(name, config);
|
const result = await addServer(name, config);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
notifyToolChanged();
|
notifyToolChanged();
|
||||||
@@ -264,6 +272,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
|||||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set owner property if not provided - use current user's username, default to 'admin'
|
||||||
|
if (!config.owner) {
|
||||||
|
const currentUser = (req as any).user;
|
||||||
|
config.owner = currentUser?.username || 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
|
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
notifyToolChanged();
|
notifyToolChanged();
|
||||||
|
|||||||
263
src/controllers/userController.ts
Normal file
263
src/controllers/userController.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
getAllUsers,
|
||||||
|
getUserByUsername,
|
||||||
|
createNewUser,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
|
getUserCount,
|
||||||
|
getAdminCount,
|
||||||
|
} from '../services/userService.js';
|
||||||
|
|
||||||
|
// Admin permission check middleware function
|
||||||
|
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Admin privileges required',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all users (admin only)
|
||||||
|
export const getUsers = (req: Request, res: Response): void => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: users,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to get users information',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get a specific user by username (admin only)
|
||||||
|
export const getUser = (req: Request, res: Response): void => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { username } = req.params;
|
||||||
|
if (!username) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Username is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = getUserByUsername(username);
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password: _, ...userData } = user; // Remove password from response
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: userData,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to get user information',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a new user (admin only)
|
||||||
|
export const createUser = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { username, password, isAdmin } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Username and password are required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await createNewUser(username, password, isAdmin || false);
|
||||||
|
if (!newUser) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create user or username already exists',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password: _, ...userData } = newUser; // Remove password from response
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: userData,
|
||||||
|
message: 'User created successfully',
|
||||||
|
};
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update an existing user (admin only)
|
||||||
|
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { username } = req.params;
|
||||||
|
const { isAdmin, newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Username is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if trying to change admin status
|
||||||
|
if (isAdmin !== undefined) {
|
||||||
|
const currentUser = getUserByUsername(username);
|
||||||
|
if (!currentUser) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent removing admin status from the last admin
|
||||||
|
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Cannot remove admin status from the last admin user',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
|
||||||
|
if (newPassword) updateData.newPassword = newPassword;
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'At least one field (isAdmin or newPassword) is required to update',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await updateUser(username, updateData);
|
||||||
|
if (!updatedUser) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found or update failed',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password: _, ...userData } = updatedUser; // Remove password from response
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: userData,
|
||||||
|
message: 'User updated successfully',
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a user (admin only)
|
||||||
|
export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { username } = req.params;
|
||||||
|
if (!username) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Username is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if trying to delete the current admin user
|
||||||
|
const currentUser = (req as any).user;
|
||||||
|
if (currentUser.username === username) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Cannot delete your own account',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = deleteUser(username);
|
||||||
|
if (!success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found, failed to delete, or cannot delete the last admin',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'User deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user statistics (admin only)
|
||||||
|
export const getUserStats = (req: Request, res: Response): void => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const totalUsers = getUserCount();
|
||||||
|
const adminUsers = getAdminCount();
|
||||||
|
const regularUsers = totalUsers - adminUsers;
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalUsers,
|
||||||
|
adminUsers,
|
||||||
|
regularUsers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to get user statistics',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import { auth } from './auth.js';
|
import { auth } from './auth.js';
|
||||||
|
import { userContextMiddleware } from './userContext.js';
|
||||||
import { initializeDefaultUser } from '../models/User.js';
|
import { initializeDefaultUser } from '../models/User.js';
|
||||||
import config from '../config/index.js';
|
import config from '../config/index.js';
|
||||||
|
|
||||||
@@ -27,7 +28,13 @@ export const initMiddlewares = (app: express.Application): void => {
|
|||||||
if (
|
if (
|
||||||
req.path !== `${basePath}/sse` &&
|
req.path !== `${basePath}/sse` &&
|
||||||
!req.path.startsWith(`${basePath}/sse/`) &&
|
!req.path.startsWith(`${basePath}/sse/`) &&
|
||||||
req.path !== `${basePath}/messages`
|
req.path !== `${basePath}/messages` &&
|
||||||
|
!req.path.match(
|
||||||
|
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/messages$`),
|
||||||
|
) &&
|
||||||
|
!req.path.match(
|
||||||
|
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/sse(/.*)?$`),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
express.json()(req, res, next);
|
express.json()(req, res, next);
|
||||||
} else {
|
} else {
|
||||||
@@ -46,7 +53,15 @@ export const initMiddlewares = (app: express.Application): void => {
|
|||||||
if (req.path === '/auth/login' || req.path === '/auth/register') {
|
if (req.path === '/auth/login' || req.path === '/auth/register') {
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
auth(req, res, next);
|
// Apply authentication middleware first
|
||||||
|
auth(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
next(err);
|
||||||
|
} else {
|
||||||
|
// Apply user context middleware after successful authentication
|
||||||
|
userContextMiddleware(req, res, next);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
136
src/middlewares/userContext.ts
Normal file
136
src/middlewares/userContext.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { UserContextService } from '../services/userContextService.js';
|
||||||
|
import { IUser } from '../types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User context middleware
|
||||||
|
* Sets user context after authentication middleware, allowing service layer to access current user information
|
||||||
|
*/
|
||||||
|
export const userContextMiddleware = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const currentUser = (req as any).user as IUser;
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
// Set user context
|
||||||
|
const userContextService = UserContextService.getInstance();
|
||||||
|
userContextService.setCurrentUser(currentUser);
|
||||||
|
|
||||||
|
// Clean up user context when response ends
|
||||||
|
res.on('finish', () => {
|
||||||
|
const userContextService = UserContextService.getInstance();
|
||||||
|
userContextService.clearCurrentUser();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in user context middleware:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User context middleware for SSE/MCP endpoints
|
||||||
|
* Extracts user from URL path parameter and sets user context
|
||||||
|
*/
|
||||||
|
export const sseUserContextMiddleware = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userContextService = UserContextService.getInstance();
|
||||||
|
const username = req.params.user;
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
// For user-scoped routes, set the user context
|
||||||
|
// Note: In a real implementation, you should validate the user exists
|
||||||
|
// and has proper permissions
|
||||||
|
const user: IUser = {
|
||||||
|
username,
|
||||||
|
password: '',
|
||||||
|
isAdmin: false, // TODO: Should be retrieved from user database
|
||||||
|
};
|
||||||
|
|
||||||
|
userContextService.setCurrentUser(user);
|
||||||
|
|
||||||
|
// Clean up user context when response ends
|
||||||
|
res.on('finish', () => {
|
||||||
|
userContextService.clearCurrentUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also clean up on connection close for SSE
|
||||||
|
res.on('close', () => {
|
||||||
|
userContextService.clearCurrentUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`User context set for SSE/MCP endpoint: ${username}`);
|
||||||
|
} else {
|
||||||
|
// For global routes, clear user context (admin access)
|
||||||
|
userContextService.clearCurrentUser();
|
||||||
|
console.log('Global SSE/MCP endpoint access - no user context');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in SSE user context middleware:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended data service that can directly access current user context
|
||||||
|
*/
|
||||||
|
export interface ContextAwareDataService {
|
||||||
|
getCurrentUserFromContext(): Promise<IUser | null>;
|
||||||
|
getUserDataFromContext(dataType: string): Promise<any>;
|
||||||
|
isCurrentUserAdmin(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContextAwareDataServiceImpl implements ContextAwareDataService {
|
||||||
|
private getUserContextService() {
|
||||||
|
return UserContextService.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentUserFromContext(): Promise<IUser | null> {
|
||||||
|
const userContextService = this.getUserContextService();
|
||||||
|
return userContextService.getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDataFromContext(dataType: string): Promise<any> {
|
||||||
|
const userContextService = this.getUserContextService();
|
||||||
|
const user = userContextService.getCurrentUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('No user in context');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Getting ${dataType} data for user: ${user.username}`);
|
||||||
|
|
||||||
|
// Return different data based on user permissions
|
||||||
|
if (user.isAdmin) {
|
||||||
|
return {
|
||||||
|
type: dataType,
|
||||||
|
data: 'Admin level data from context',
|
||||||
|
user: user.username,
|
||||||
|
access: 'full',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: dataType,
|
||||||
|
data: 'User level data from context',
|
||||||
|
user: user.username,
|
||||||
|
access: 'limited',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isCurrentUserAdmin(): Promise<boolean> {
|
||||||
|
const userContextService = this.getUserContextService();
|
||||||
|
return userContextService.isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,14 @@ import {
|
|||||||
getGroupServers,
|
getGroupServers,
|
||||||
updateGroupServersBatch,
|
updateGroupServersBatch,
|
||||||
} from '../controllers/groupController.js';
|
} from '../controllers/groupController.js';
|
||||||
|
import {
|
||||||
|
getUsers,
|
||||||
|
getUser,
|
||||||
|
createUser,
|
||||||
|
updateExistingUser,
|
||||||
|
deleteExistingUser,
|
||||||
|
getUserStats,
|
||||||
|
} from '../controllers/userController.js';
|
||||||
import {
|
import {
|
||||||
getAllMarketServers,
|
getAllMarketServers,
|
||||||
getMarketServer,
|
getMarketServer,
|
||||||
@@ -65,6 +73,14 @@ export const initRoutes = (app: express.Application): void => {
|
|||||||
// New route for batch updating servers in a group
|
// New route for batch updating servers in a group
|
||||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||||
|
|
||||||
|
// User management routes (admin only)
|
||||||
|
router.get('/users', getUsers);
|
||||||
|
router.get('/users/:username', getUser);
|
||||||
|
router.post('/users', createUser);
|
||||||
|
router.put('/users/:username', updateExistingUser);
|
||||||
|
router.delete('/users/:username', deleteExistingUser);
|
||||||
|
router.get('/users-stats', getUserStats);
|
||||||
|
|
||||||
// Tool management routes
|
// Tool management routes
|
||||||
router.post('/tools/call/:server', callTool);
|
router.post('/tools/call/:server', callTool);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import config from './config/index.js';
|
import config from './config/index.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { initUpstreamServers } from './services/mcpService.js';
|
import { initUpstreamServers, connected } from './services/mcpService.js';
|
||||||
import { initMiddlewares } from './middlewares/index.js';
|
import { initMiddlewares } from './middlewares/index.js';
|
||||||
import { initRoutes } from './routes/index.js';
|
import { initRoutes } from './routes/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -13,10 +12,10 @@ import {
|
|||||||
handleMcpOtherRequest,
|
handleMcpOtherRequest,
|
||||||
} from './services/sseService.js';
|
} from './services/sseService.js';
|
||||||
import { initializeDefaultUser } from './models/User.js';
|
import { initializeDefaultUser } from './models/User.js';
|
||||||
|
import { sseUserContextMiddleware } from './middlewares/userContext.js';
|
||||||
|
|
||||||
// Get the directory name in ESM
|
// Get the current working directory (will be project root in most cases)
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const currentFileDir = process.cwd() + '/src';
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
export class AppServer {
|
export class AppServer {
|
||||||
private app: express.Application;
|
private app: express.Application;
|
||||||
@@ -42,11 +41,52 @@ export class AppServer {
|
|||||||
initUpstreamServers()
|
initUpstreamServers()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('MCP server initialized successfully');
|
console.log('MCP server initialized successfully');
|
||||||
this.app.get(`${this.basePath}/sse/:group?`, (req, res) => handleSseConnection(req, res));
|
|
||||||
this.app.post(`${this.basePath}/messages`, handleSseMessage);
|
// Original routes (global and group-based)
|
||||||
this.app.post(`${this.basePath}/mcp/:group?`, handleMcpPostRequest);
|
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||||
this.app.get(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
|
handleSseConnection(req, res),
|
||||||
this.app.delete(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
|
);
|
||||||
|
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
|
||||||
|
this.app.post(
|
||||||
|
`${this.basePath}/mcp/:group?`,
|
||||||
|
sseUserContextMiddleware,
|
||||||
|
handleMcpPostRequest,
|
||||||
|
);
|
||||||
|
this.app.get(
|
||||||
|
`${this.basePath}/mcp/:group?`,
|
||||||
|
sseUserContextMiddleware,
|
||||||
|
handleMcpOtherRequest,
|
||||||
|
);
|
||||||
|
this.app.delete(
|
||||||
|
`${this.basePath}/mcp/:group?`,
|
||||||
|
sseUserContextMiddleware,
|
||||||
|
handleMcpOtherRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
// User-scoped routes with user context middleware
|
||||||
|
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||||
|
handleSseConnection(req, res),
|
||||||
|
);
|
||||||
|
this.app.post(
|
||||||
|
`${this.basePath}/:user/messages`,
|
||||||
|
sseUserContextMiddleware,
|
||||||
|
handleSseMessage,
|
||||||
|
);
|
||||||
|
this.app.post(
|
||||||
|
`${this.basePath}/:user/mcp/:group?`,
|
||||||
|
sseUserContextMiddleware,
|
||||||
|
handleMcpPostRequest,
|
||||||
|
);
|
||||||
|
this.app.get(
|
||||||
|
`${this.basePath}/:user/mcp/:group?`,
|
||||||
|
sseUserContextMiddleware,
|
||||||
|
handleMcpOtherRequest,
|
||||||
|
);
|
||||||
|
this.app.delete(
|
||||||
|
`${this.basePath}/:user/mcp/:group?`,
|
||||||
|
sseUserContextMiddleware,
|
||||||
|
handleMcpOtherRequest,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Error initializing MCP server:', error);
|
console.error('Error initializing MCP server:', error);
|
||||||
@@ -108,6 +148,10 @@ export class AppServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connected(): boolean {
|
||||||
|
return connected();
|
||||||
|
}
|
||||||
|
|
||||||
getApp(): express.Application {
|
getApp(): express.Application {
|
||||||
return this.app;
|
return this.app;
|
||||||
}
|
}
|
||||||
@@ -119,7 +163,7 @@ export class AppServer {
|
|||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
console.log('DEBUG: Current directory:', process.cwd());
|
console.log('DEBUG: Current directory:', process.cwd());
|
||||||
console.log('DEBUG: Script directory:', __dirname);
|
console.log('DEBUG: Script directory:', currentFileDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, find the package root directory
|
// First, find the package root directory
|
||||||
@@ -159,13 +203,13 @@ export class AppServer {
|
|||||||
// Possible locations for package.json
|
// Possible locations for package.json
|
||||||
const possibleRoots = [
|
const possibleRoots = [
|
||||||
// Standard npm package location
|
// Standard npm package location
|
||||||
path.resolve(__dirname, '..', '..'),
|
path.resolve(currentFileDir, '..', '..'),
|
||||||
// Current working directory
|
// Current working directory
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
// When running from dist directory
|
// When running from dist directory
|
||||||
path.resolve(__dirname, '..'),
|
path.resolve(currentFileDir, '..'),
|
||||||
// When installed via npx
|
// When installed via npx
|
||||||
path.resolve(__dirname, '..', '..', '..'),
|
path.resolve(currentFileDir, '..', '..', '..'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Special handling for npx
|
// Special handling for npx
|
||||||
|
|||||||
13
src/services/dataService.test.ts
Normal file
13
src/services/dataService.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { DataService } from './dataService.js';
|
||||||
|
import { getDataService } from './services.js';
|
||||||
|
import './services.js';
|
||||||
|
|
||||||
|
describe('DataService', () => {
|
||||||
|
test('should get default implementation and call foo method', async () => {
|
||||||
|
const dataService: DataService = await getDataService();
|
||||||
|
const consoleSpy = jest.spyOn(console, 'log');
|
||||||
|
dataService.foo();
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/services/dataService.ts
Normal file
31
src/services/dataService.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { IUser, McpSettings } from '../types/index.js';
|
||||||
|
|
||||||
|
export interface DataService {
|
||||||
|
foo(): void;
|
||||||
|
filterData(data: any[]): any[];
|
||||||
|
filterSettings(settings: McpSettings): McpSettings;
|
||||||
|
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings;
|
||||||
|
getPermissions(user: IUser): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DataServiceImpl implements DataService {
|
||||||
|
foo() {
|
||||||
|
console.log('default implementation');
|
||||||
|
}
|
||||||
|
|
||||||
|
filterData(data: any[]): any[] {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterSettings(settings: McpSettings): McpSettings {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings {
|
||||||
|
return newSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPermissions(_user: IUser): string[] {
|
||||||
|
return ['*'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { IGroup } from '../types/index.js';
|
import { IGroup } from '../types/index.js';
|
||||||
import { loadSettings, saveSettings } from '../config/index.js';
|
import { loadSettings, saveSettings } from '../config/index.js';
|
||||||
import { notifyToolChanged } from './mcpService.js';
|
import { notifyToolChanged } from './mcpService.js';
|
||||||
|
import { getDataService } from './services.js';
|
||||||
|
|
||||||
// Get all groups
|
// Get all groups
|
||||||
export const getAllGroups = (): IGroup[] => {
|
export const getAllGroups = (): IGroup[] => {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
return settings.groups || [];
|
const dataService = getDataService();
|
||||||
|
return dataService.filterData
|
||||||
|
? dataService.filterData(settings.groups || [])
|
||||||
|
: settings.groups || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get group by ID or name
|
// Get group by ID or name
|
||||||
@@ -29,6 +33,7 @@ export const createGroup = (
|
|||||||
name: string,
|
name: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
servers: string[] = [],
|
servers: string[] = [],
|
||||||
|
owner?: string,
|
||||||
): IGroup | null => {
|
): IGroup | null => {
|
||||||
try {
|
try {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
@@ -47,6 +52,7 @@ export const createGroup = (
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
servers: validServers,
|
servers: validServers,
|
||||||
|
owner: owner || 'admin',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize groups array if it doesn't exist
|
// Initialize groups array if it doesn't exist
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getGroup } from './sseService.js';
|
|||||||
import { getServersInGroup } from './groupService.js';
|
import { getServersInGroup } from './groupService.js';
|
||||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||||
import { OpenAPIClient } from '../clients/openapi.js';
|
import { OpenAPIClient } from '../clients/openapi.js';
|
||||||
|
import { getDataService } from './services.js';
|
||||||
|
|
||||||
const servers: { [sessionId: string]: Server } = {};
|
const servers: { [sessionId: string]: Server } = {};
|
||||||
|
|
||||||
@@ -101,6 +102,33 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
|
|||||||
// Store all server information
|
// Store all server information
|
||||||
let serverInfos: ServerInfo[] = [];
|
let serverInfos: ServerInfo[] = [];
|
||||||
|
|
||||||
|
// Returns true if all servers are connected
|
||||||
|
export const connected = (): boolean => {
|
||||||
|
return serverInfos.every((serverInfo) => serverInfo.status === 'connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global cleanup function to close all connections
|
||||||
|
export const cleanupAllServers = (): void => {
|
||||||
|
for (const serverInfo of serverInfos) {
|
||||||
|
try {
|
||||||
|
if (serverInfo.client) {
|
||||||
|
serverInfo.client.close();
|
||||||
|
}
|
||||||
|
if (serverInfo.transport) {
|
||||||
|
serverInfo.transport.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error closing server ${serverInfo.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serverInfos = [];
|
||||||
|
|
||||||
|
// Clear session servers as well
|
||||||
|
Object.keys(servers).forEach((sessionId) => {
|
||||||
|
delete servers[sessionId];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to create transport based on server configuration
|
// Helper function to create transport based on server configuration
|
||||||
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||||
let transport;
|
let transport;
|
||||||
@@ -294,6 +322,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
|||||||
console.log(`Skipping disabled server: ${name}`);
|
console.log(`Skipping disabled server: ${name}`);
|
||||||
serverInfos.push({
|
serverInfos.push({
|
||||||
name,
|
name,
|
||||||
|
owner: conf.owner,
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
error: null,
|
error: null,
|
||||||
tools: [],
|
tools: [],
|
||||||
@@ -327,6 +356,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
|||||||
);
|
);
|
||||||
serverInfos.push({
|
serverInfos.push({
|
||||||
name,
|
name,
|
||||||
|
owner: conf.owner,
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
error: 'Missing OpenAPI specification URL or schema',
|
error: 'Missing OpenAPI specification URL or schema',
|
||||||
tools: [],
|
tools: [],
|
||||||
@@ -338,6 +368,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
|||||||
// Create server info first and keep reference to it
|
// Create server info first and keep reference to it
|
||||||
const serverInfo: ServerInfo = {
|
const serverInfo: ServerInfo = {
|
||||||
name,
|
name,
|
||||||
|
owner: conf.owner,
|
||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
error: null,
|
error: null,
|
||||||
tools: [],
|
tools: [],
|
||||||
@@ -418,6 +449,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
|||||||
// Create server info first and keep reference to it
|
// Create server info first and keep reference to it
|
||||||
const serverInfo: ServerInfo = {
|
const serverInfo: ServerInfo = {
|
||||||
name,
|
name,
|
||||||
|
owner: conf.owner,
|
||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
error: null,
|
error: null,
|
||||||
tools: [],
|
tools: [],
|
||||||
@@ -480,7 +512,11 @@ export const registerAllTools = async (isInit: boolean): Promise<void> => {
|
|||||||
// Get all server information
|
// Get all server information
|
||||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
|
const dataService = getDataService();
|
||||||
|
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||||
|
? dataService.filterData(serverInfos)
|
||||||
|
: serverInfos;
|
||||||
|
const infos = filterServerInfos.map(({ name, status, tools, createTime, error }) => {
|
||||||
const serverConfig = settings.mcpServers[name];
|
const serverConfig = settings.mcpServers[name];
|
||||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||||
|
|
||||||
@@ -774,13 +810,15 @@ Available servers: ${serversList}`;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
const allServerInfos = getDataService()
|
||||||
if (serverInfo.enabled === false) return false;
|
.filterData(serverInfos)
|
||||||
if (!group) return true;
|
.filter((serverInfo) => {
|
||||||
const serversInGroup = getServersInGroup(group);
|
if (serverInfo.enabled === false) return false;
|
||||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
if (!group) return true;
|
||||||
return serversInGroup.includes(serverInfo.name);
|
const serversInGroup = getServersInGroup(group);
|
||||||
});
|
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||||
|
return serversInGroup.includes(serverInfo.name);
|
||||||
|
});
|
||||||
|
|
||||||
const allTools = [];
|
const allTools = [];
|
||||||
for (const serverInfo of allServerInfos) {
|
for (const serverInfo of allServerInfos) {
|
||||||
|
|||||||
37
src/services/registry.ts
Normal file
37
src/services/registry.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
type Class<T> = new (...args: any[]) => T;
|
||||||
|
|
||||||
|
interface Service<T> {
|
||||||
|
defaultImpl: Class<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new Map<string, Service<any>>();
|
||||||
|
const instances = new Map<string, unknown>();
|
||||||
|
|
||||||
|
export function registerService<T>(key: string, entry: Service<T>) {
|
||||||
|
registry.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getService<T>(key: string): T {
|
||||||
|
if (instances.has(key)) {
|
||||||
|
return instances.get(key) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = registry.get(key);
|
||||||
|
if (!entry) throw new Error(`Service not registered for key: ${key.toString()}`);
|
||||||
|
|
||||||
|
let Impl = entry.defaultImpl;
|
||||||
|
|
||||||
|
const overridePath = './' + key + 'x.js';
|
||||||
|
import(overridePath)
|
||||||
|
.then((mod) => {
|
||||||
|
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'] ?? Impl.name;
|
||||||
|
if (typeof override === 'function') {
|
||||||
|
Impl = override;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
const instance = new Impl();
|
||||||
|
instances.set(key, instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
10
src/services/services.ts
Normal file
10
src/services/services.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { registerService, getService } from './registry.js';
|
||||||
|
import { DataService, DataServiceImpl } from './dataService.js';
|
||||||
|
|
||||||
|
registerService('dataService', {
|
||||||
|
defaultImpl: DataServiceImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getDataService(): DataService {
|
||||||
|
return getService<DataService>('dataService');
|
||||||
|
}
|
||||||
482
src/services/sseService.test.ts
Normal file
482
src/services/sseService.test.ts
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import {
|
||||||
|
handleSseConnection,
|
||||||
|
handleSseMessage,
|
||||||
|
handleMcpPostRequest,
|
||||||
|
handleMcpOtherRequest,
|
||||||
|
getGroup,
|
||||||
|
getConnectionCount,
|
||||||
|
} from './sseService.js';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('./mcpService.js', () => ({
|
||||||
|
deleteMcpServer: jest.fn(),
|
||||||
|
getMcpServer: jest.fn(() => ({
|
||||||
|
connect: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../config/index.js', () => {
|
||||||
|
const config = {
|
||||||
|
basePath: '/test',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: config,
|
||||||
|
loadSettings: jest.fn(() => ({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('./userContextService.js', () => ({
|
||||||
|
UserContextService: {
|
||||||
|
getInstance: jest.fn(() => ({
|
||||||
|
getCurrentUser: jest.fn(() => ({ username: 'testuser' })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
||||||
|
SSEServerTransport: jest.fn().mockImplementation((_path, _res) => ({
|
||||||
|
sessionId: 'test-session-id',
|
||||||
|
connect: jest.fn(),
|
||||||
|
handlePostMessage: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||||
|
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({
|
||||||
|
sessionId: 'test-session-id',
|
||||||
|
connect: jest.fn(),
|
||||||
|
handleRequest: jest.fn(),
|
||||||
|
onclose: null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||||
|
isInitializeRequest: jest.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import mocked modules
|
||||||
|
import { getMcpServer } from './mcpService.js';
|
||||||
|
import { loadSettings } from '../config/index.js';
|
||||||
|
import { UserContextService } from './userContextService.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
|
||||||
|
// Mock Express Request and Response
|
||||||
|
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
|
||||||
|
({
|
||||||
|
headers: {},
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
body: {},
|
||||||
|
...overrides,
|
||||||
|
}) as Request;
|
||||||
|
|
||||||
|
const createMockResponse = (): Response => {
|
||||||
|
const res = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
send: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn().mockReturnThis(),
|
||||||
|
on: jest.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('sseService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Reset settings cache
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bearer authentication', () => {
|
||||||
|
it('should pass when bearer auth is disabled', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(401);
|
||||||
|
expect(SSEServerTransport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when bearer auth is enabled but no authorization header', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest();
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when bearer auth is enabled with invalid token', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
headers: { authorization: 'Bearer invalid-token' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass when bearer auth is enabled with valid token', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
headers: { authorization: 'Bearer test-key' },
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(401);
|
||||||
|
expect(SSEServerTransport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGroup', () => {
|
||||||
|
it('should return empty string for non-existent session', () => {
|
||||||
|
const result = getGroup('non-existent-session');
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return group for existing session', () => {
|
||||||
|
// This would need to be tested after a connection is established
|
||||||
|
// For now, testing the default behavior
|
||||||
|
const result = getGroup('test-session');
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConnectionCount', () => {
|
||||||
|
it('should return current number of connections', () => {
|
||||||
|
const count = getConnectionCount();
|
||||||
|
// The count may be > 0 due to previous tests since transports is module-level
|
||||||
|
expect(typeof count).toBe('number');
|
||||||
|
expect(count).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSseConnection', () => {
|
||||||
|
it('should reject global routes when disabled', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: false,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest(); // No group in params
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
'Global routes are disabled. Please specify a group ID.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create SSE transport for valid request', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser/messages', res);
|
||||||
|
expect(getMcpServer).toHaveBeenCalledWith('test-session-id', 'test-group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle user context correctly', async () => {
|
||||||
|
const mockGetCurrentUser = jest.fn(() => ({ username: 'testuser2' }));
|
||||||
|
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
|
||||||
|
getCurrentUser: mockGetCurrentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(mockGetCurrentUser).toHaveBeenCalled();
|
||||||
|
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser2/messages', res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle anonymous user correctly', async () => {
|
||||||
|
const mockGetCurrentUser = jest.fn(() => null);
|
||||||
|
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
|
||||||
|
getCurrentUser: mockGetCurrentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseConnection(req, res);
|
||||||
|
|
||||||
|
expect(mockGetCurrentUser).toHaveBeenCalled();
|
||||||
|
expect(SSEServerTransport).toHaveBeenCalledWith('/test/messages', res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSseMessage', () => {
|
||||||
|
it('should return 400 when sessionId is missing', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
query: {}, // No sessionId
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseMessage(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Missing sessionId parameter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when transport not found', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
query: { sessionId: 'non-existent-session' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseMessage(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('No transport found for sessionId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when bearer auth fails', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
query: { sessionId: 'test-session' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleSseMessage(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleMcpPostRequest', () => {
|
||||||
|
it('should reject global routes when disabled', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: false,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: {}, // No group
|
||||||
|
body: { method: 'initialize' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleMcpPostRequest(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
'Global routes are disabled. Please specify a group ID.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new transport for initialize request without sessionId', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
body: { method: 'initialize' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleMcpPostRequest(req, res);
|
||||||
|
|
||||||
|
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
|
||||||
|
expect(getMcpServer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for invalid session', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
headers: { 'mcp-session-id': 'invalid-session' },
|
||||||
|
body: { method: 'someMethod' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleMcpPostRequest(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: 'Bad Request: No valid session ID provided',
|
||||||
|
},
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when bearer auth fails', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
params: { group: 'test-group' },
|
||||||
|
body: { method: 'initialize' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleMcpPostRequest(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleMcpOtherRequest', () => {
|
||||||
|
it('should return 400 for missing session ID', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
headers: {}, // No mcp-session-id
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleMcpOtherRequest(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid session ID', async () => {
|
||||||
|
const req = createMockRequest({
|
||||||
|
headers: { 'mcp-session-id': 'invalid-session' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleMcpOtherRequest(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when bearer auth fails', async () => {
|
||||||
|
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||||
|
mcpServers: {},
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = createMockRequest({
|
||||||
|
headers: { 'mcp-session-id': 'test-session' },
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleMcpOtherRequest(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|||||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||||
import { loadSettings } from '../config/index.js';
|
import { loadSettings } from '../config/index.js';
|
||||||
import config from '../config/index.js';
|
import config from '../config/index.js';
|
||||||
|
import { UserContextService } from './userContextService.js';
|
||||||
|
|
||||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||||
|
|
||||||
@@ -38,8 +39,14 @@ const validateBearerAuth = (req: Request): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||||
// Check bearer auth
|
// User context is now set by sseUserContextMiddleware
|
||||||
|
const userContextService = UserContextService.getInstance();
|
||||||
|
const currentUser = userContextService.getCurrentUser();
|
||||||
|
const username = currentUser?.username;
|
||||||
|
|
||||||
|
// Check bearer auth using filtered settings
|
||||||
if (!validateBearerAuth(req)) {
|
if (!validateBearerAuth(req)) {
|
||||||
|
console.warn('Bearer authentication failed or not provided');
|
||||||
res.status(401).send('Bearer authentication required or invalid token');
|
res.status(401).send('Bearer authentication required or invalid token');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -55,11 +62,25 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
|||||||
|
|
||||||
// Check if this is a global route (no group) and if it's allowed
|
// Check if this is a global route (no group) and if it's allowed
|
||||||
if (!group && !routingConfig.enableGlobalRoute) {
|
if (!group && !routingConfig.enableGlobalRoute) {
|
||||||
|
console.warn('Global routes are disabled, group ID is required');
|
||||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = new SSEServerTransport(`${config.basePath}/messages`, res);
|
// For user-scoped routes, validate that the user has access to the requested group
|
||||||
|
if (username && group) {
|
||||||
|
// Additional validation can be added here to check if user has access to the group
|
||||||
|
console.log(`User ${username} accessing group: ${group}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the appropriate messages path based on user context
|
||||||
|
const messagesPath = username
|
||||||
|
? `${config.basePath}/${username}/messages`
|
||||||
|
: `${config.basePath}/messages`;
|
||||||
|
|
||||||
|
console.log(`Creating SSE transport with messages path: ${messagesPath}`);
|
||||||
|
|
||||||
|
const transport = new SSEServerTransport(messagesPath, res);
|
||||||
transports[transport.sessionId] = { transport, group: group };
|
transports[transport.sessionId] = { transport, group: group };
|
||||||
|
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
@@ -69,13 +90,18 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
|
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}${username ? ` for user: ${username}` : ''}`,
|
||||||
);
|
);
|
||||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||||
// Check bearer auth
|
// User context is now set by sseUserContextMiddleware
|
||||||
|
const userContextService = UserContextService.getInstance();
|
||||||
|
const currentUser = userContextService.getCurrentUser();
|
||||||
|
const username = currentUser?.username;
|
||||||
|
|
||||||
|
// Check bearer auth using filtered settings
|
||||||
if (!validateBearerAuth(req)) {
|
if (!validateBearerAuth(req)) {
|
||||||
res.status(401).send('Bearer authentication required or invalid token');
|
res.status(401).send('Bearer authentication required or invalid token');
|
||||||
return;
|
return;
|
||||||
@@ -101,24 +127,31 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
|||||||
const { transport, group } = transportData;
|
const { transport, group } = transportData;
|
||||||
req.params.group = group;
|
req.params.group = group;
|
||||||
req.query.group = group;
|
req.query.group = group;
|
||||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
|
console.log(`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`);
|
||||||
|
|
||||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
// User context is now set by sseUserContextMiddleware
|
||||||
|
const userContextService = UserContextService.getInstance();
|
||||||
|
const currentUser = userContextService.getCurrentUser();
|
||||||
|
const username = currentUser?.username;
|
||||||
|
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
const group = req.params.group;
|
const group = req.params.group;
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
console.log(
|
console.log(
|
||||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group} with body: ${JSON.stringify(body)}`,
|
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
|
||||||
);
|
);
|
||||||
// Check bearer auth
|
|
||||||
|
// Check bearer auth using filtered settings
|
||||||
if (!validateBearerAuth(req)) {
|
if (!validateBearerAuth(req)) {
|
||||||
res.status(401).send('Bearer authentication required or invalid token');
|
res.status(401).send('Bearer authentication required or invalid token');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get filtered settings based on user context (after setting user context)
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
const routingConfig = settings.systemConfig?.routing || {
|
const routingConfig = settings.systemConfig?.routing || {
|
||||||
enableGlobalRoute: true,
|
enableGlobalRoute: true,
|
||||||
@@ -150,7 +183,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`MCP connection established: ${transport.sessionId}`);
|
console.log(`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`);
|
||||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -169,8 +202,14 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||||
console.log('Handling MCP other request');
|
// User context is now set by sseUserContextMiddleware
|
||||||
// Check bearer auth
|
const userContextService = UserContextService.getInstance();
|
||||||
|
const currentUser = userContextService.getCurrentUser();
|
||||||
|
const username = currentUser?.username;
|
||||||
|
|
||||||
|
console.log(`Handling MCP other request${username ? ` for user: ${username}` : ''}`);
|
||||||
|
|
||||||
|
// Check bearer auth using filtered settings
|
||||||
if (!validateBearerAuth(req)) {
|
if (!validateBearerAuth(req)) {
|
||||||
res.status(401).send('Bearer authentication required or invalid token');
|
res.status(401).send('Bearer authentication required or invalid token');
|
||||||
return;
|
return;
|
||||||
|
|||||||
59
src/services/userContextService.ts
Normal file
59
src/services/userContextService.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { IUser } from '../types/index.js';
|
||||||
|
|
||||||
|
// User context storage
|
||||||
|
class UserContext {
|
||||||
|
private static instance: UserContext;
|
||||||
|
private currentUser: IUser | null = null;
|
||||||
|
|
||||||
|
static getInstance(): UserContext {
|
||||||
|
if (!UserContext.instance) {
|
||||||
|
UserContext.instance = new UserContext();
|
||||||
|
}
|
||||||
|
return UserContext.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(user: IUser): void {
|
||||||
|
this.currentUser = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(): IUser | null {
|
||||||
|
return this.currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUser(): void {
|
||||||
|
this.currentUser = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserContextService {
|
||||||
|
private static instance: UserContextService;
|
||||||
|
private userContext = UserContext.getInstance();
|
||||||
|
|
||||||
|
static getInstance(): UserContextService {
|
||||||
|
if (!UserContextService.instance) {
|
||||||
|
UserContextService.instance = new UserContextService();
|
||||||
|
}
|
||||||
|
return UserContextService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentUser(): IUser | null {
|
||||||
|
return this.userContext.getUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentUser(user: IUser): void {
|
||||||
|
this.userContext.setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCurrentUser(): void {
|
||||||
|
this.userContext.clearUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin(): boolean {
|
||||||
|
const user = this.getCurrentUser();
|
||||||
|
return user?.isAdmin || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUser(): boolean {
|
||||||
|
return this.getCurrentUser() !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/services/userService.ts
Normal file
126
src/services/userService.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { IUser } from '../types/index.js';
|
||||||
|
import { getUsers, createUser, findUserByUsername } from '../models/User.js';
|
||||||
|
import { saveSettings, loadSettings } from '../config/index.js';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
export const getAllUsers = (): IUser[] => {
|
||||||
|
return getUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user by username
|
||||||
|
export const getUserByUsername = (username: string): IUser | undefined => {
|
||||||
|
return findUserByUsername(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
export const createNewUser = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
isAdmin: boolean = false,
|
||||||
|
): Promise<IUser | null> => {
|
||||||
|
try {
|
||||||
|
const existingUser = findUserByUsername(username);
|
||||||
|
if (existingUser) {
|
||||||
|
return null; // User already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData: IUser = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
isAdmin,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await createUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update user information
|
||||||
|
export const updateUser = async (
|
||||||
|
username: string,
|
||||||
|
data: { isAdmin?: boolean; newPassword?: string },
|
||||||
|
): Promise<IUser | null> => {
|
||||||
|
try {
|
||||||
|
const users = getUsers();
|
||||||
|
const userIndex = users.findIndex((user) => user.username === username);
|
||||||
|
|
||||||
|
if (userIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[userIndex];
|
||||||
|
|
||||||
|
// Update admin status if provided
|
||||||
|
if (data.isAdmin !== undefined) {
|
||||||
|
user.isAdmin = data.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password if provided
|
||||||
|
if (data.newPassword) {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
user.password = await bcrypt.hash(data.newPassword, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save users array back to settings
|
||||||
|
const { saveSettings, loadSettings } = await import('../config/index.js');
|
||||||
|
const settings = loadSettings();
|
||||||
|
settings.users = users;
|
||||||
|
|
||||||
|
if (!saveSettings(settings)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a user
|
||||||
|
export const deleteUser = (username: string): boolean => {
|
||||||
|
try {
|
||||||
|
// Cannot delete the last admin user
|
||||||
|
const users = getUsers();
|
||||||
|
const adminUsers = users.filter((user) => user.isAdmin);
|
||||||
|
const userToDelete = users.find((user) => user.username === username);
|
||||||
|
|
||||||
|
if (userToDelete?.isAdmin && adminUsers.length === 1) {
|
||||||
|
return false; // Cannot delete the last admin
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredUsers = users.filter((user) => user.username !== username);
|
||||||
|
|
||||||
|
if (filteredUsers.length === users.length) {
|
||||||
|
return false; // User not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save filtered users back to settings
|
||||||
|
const settings = loadSettings();
|
||||||
|
settings.users = filteredUsers;
|
||||||
|
|
||||||
|
return saveSettings(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user has admin permissions
|
||||||
|
export const isUserAdmin = (username: string): boolean => {
|
||||||
|
const user = findUserByUsername(username);
|
||||||
|
return user?.isAdmin || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user count
|
||||||
|
export const getUserCount = (): number => {
|
||||||
|
return getUsers().length;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get admin count
|
||||||
|
export const getAdminCount = (): number => {
|
||||||
|
return getUsers().filter((user) => user.isAdmin).length;
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ export interface IGroup {
|
|||||||
name: string; // Display name of the group
|
name: string; // Display name of the group
|
||||||
description?: string; // Optional description of the group
|
description?: string; // Optional description of the group
|
||||||
servers: string[]; // Array of server names that belong to this group
|
servers: string[]; // Array of server names that belong to this group
|
||||||
|
owner?: string; // Owner of the group, defaults to 'admin' user
|
||||||
}
|
}
|
||||||
|
|
||||||
// Market server types
|
// Market server types
|
||||||
@@ -74,6 +75,30 @@ export interface MarketServer {
|
|||||||
is_official?: boolean;
|
is_official?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemConfig {
|
||||||
|
routing?: {
|
||||||
|
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||||
|
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||||
|
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||||
|
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||||
|
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
|
||||||
|
};
|
||||||
|
install?: {
|
||||||
|
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||||
|
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||||
|
};
|
||||||
|
smartRouting?: SmartRoutingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserConfig {
|
||||||
|
routing?: {
|
||||||
|
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||||
|
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||||
|
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||||
|
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Represents the settings for MCP servers
|
// Represents the settings for MCP servers
|
||||||
export interface McpSettings {
|
export interface McpSettings {
|
||||||
users?: IUser[]; // Array of user credentials and permissions
|
users?: IUser[]; // Array of user credentials and permissions
|
||||||
@@ -81,21 +106,8 @@ export interface McpSettings {
|
|||||||
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
|
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
|
||||||
};
|
};
|
||||||
groups?: IGroup[]; // Array of server groups
|
groups?: IGroup[]; // Array of server groups
|
||||||
systemConfig?: {
|
systemConfig?: SystemConfig; // System-wide configuration settings
|
||||||
routing?: {
|
userConfigs?: Record<string, UserConfig>; // User-specific configurations
|
||||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
|
||||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
|
||||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
|
||||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
|
||||||
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
|
|
||||||
};
|
|
||||||
install?: {
|
|
||||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
|
||||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
|
||||||
};
|
|
||||||
smartRouting?: SmartRoutingConfig;
|
|
||||||
// Add other system configuration sections here in the future
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration details for an individual server
|
// Configuration details for an individual server
|
||||||
@@ -107,6 +119,7 @@ export interface ServerConfig {
|
|||||||
env?: Record<string, string>; // Environment variables
|
env?: Record<string, string>; // Environment variables
|
||||||
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
|
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
|
||||||
enabled?: boolean; // Flag to enable/disable the server
|
enabled?: boolean; // Flag to enable/disable the server
|
||||||
|
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||||
@@ -154,6 +167,7 @@ export interface OpenAPISecurityConfig {
|
|||||||
// Information about a server's status and tools
|
// Information about a server's status and tools
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
name: string; // Unique name of the server
|
name: string; // Unique name of the server
|
||||||
|
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||||
error: string | null; // Error message if any
|
error: string | null; // Error message if any
|
||||||
tools: ToolInfo[]; // List of tools available on the server
|
tools: ToolInfo[]; // List of tools available on the server
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
|
||||||
// Get current file's directory
|
// Project root directory - use process.cwd() as a simpler alternative
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const rootDir = process.cwd();
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
// Project root directory should be the parent directory of src
|
|
||||||
const rootDir = dirname(dirname(__dirname));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the path to a configuration file by checking multiple potential locations.
|
* Find the path to a configuration file by checking multiple potential locations.
|
||||||
@@ -24,7 +20,7 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
|||||||
// Use path relative to the root directory
|
// Use path relative to the root directory
|
||||||
path.join(rootDir, filename),
|
path.join(rootDir, filename),
|
||||||
// If installed with npx, may need to look one level up
|
// If installed with npx, may need to look one level up
|
||||||
path.join(dirname(rootDir), filename)
|
path.join(dirname(rootDir), filename),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const filePath of potentialPaths) {
|
for (const filePath of potentialPaths) {
|
||||||
@@ -38,6 +34,8 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
|||||||
// even if the configuration file is missing. This fallback is particularly useful in
|
// even if the configuration file is missing. This fallback is particularly useful in
|
||||||
// development environments or when the file is optional.
|
// development environments or when the file is optional.
|
||||||
const defaultPath = path.resolve(process.cwd(), filename);
|
const defaultPath = path.resolve(process.cwd(), filename);
|
||||||
console.debug(`${description} file not found at any expected location, using default path: ${defaultPath}`);
|
console.debug(
|
||||||
|
`${description} file not found at any expected location, using default path: ${defaultPath}`,
|
||||||
|
);
|
||||||
return defaultPath;
|
return defaultPath;
|
||||||
};
|
};
|
||||||
|
|||||||
72
src/utils/serialization.ts
Normal file
72
src/utils/serialization.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for safe JSON serialization
|
||||||
|
* Handles circular references and provides type-safe serialization
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a JSON-safe copy of an object by removing circular references
|
||||||
|
* Uses a replacer function with WeakSet to efficiently track visited objects
|
||||||
|
*
|
||||||
|
* @param obj - The object to make JSON-safe
|
||||||
|
* @returns A new object that can be safely serialized to JSON
|
||||||
|
*/
|
||||||
|
export const createSafeJSON = <T>(obj: T): T => {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
|
||||||
|
return JSON.parse(
|
||||||
|
JSON.stringify(obj, (key, value) => {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return '[Circular Reference]';
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe JSON stringifier that handles circular references
|
||||||
|
* Useful for logging or debugging purposes
|
||||||
|
*
|
||||||
|
* @param obj - The object to stringify
|
||||||
|
* @param space - Number of spaces to use for indentation (optional)
|
||||||
|
* @returns JSON string representation of the object
|
||||||
|
*/
|
||||||
|
export const safeStringify = (obj: any, space?: number): string => {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
|
||||||
|
return JSON.stringify(
|
||||||
|
obj,
|
||||||
|
(key, value) => {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return '[Circular Reference]';
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
space,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes specific properties that might contain circular references
|
||||||
|
* More targeted approach for known problematic properties
|
||||||
|
*
|
||||||
|
* @param obj - The object to clean
|
||||||
|
* @param excludeProps - Array of property names to exclude
|
||||||
|
* @returns A new object without the specified properties
|
||||||
|
*/
|
||||||
|
export const excludeCircularProps = <T extends Record<string, any>>(
|
||||||
|
obj: T,
|
||||||
|
excludeProps: string[],
|
||||||
|
): Omit<T, keyof (typeof excludeProps)[number]> => {
|
||||||
|
const result = { ...obj };
|
||||||
|
excludeProps.forEach((prop) => {
|
||||||
|
delete result[prop];
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
// Get the directory name in ESM
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the package version from package.json
|
* Gets the package version from package.json
|
||||||
@@ -12,7 +7,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
*/
|
*/
|
||||||
export const getPackageVersion = (): string => {
|
export const getPackageVersion = (): string => {
|
||||||
try {
|
try {
|
||||||
const packageJsonPath = path.resolve(__dirname, '../../package.json');
|
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
||||||
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
||||||
const packageJson = JSON.parse(packageJsonContent);
|
const packageJson = JSON.parse(packageJsonContent);
|
||||||
return packageJson.version || 'dev';
|
return packageJson.version || 'dev';
|
||||||
|
|||||||
465
tests/integration/sse-service-real-client.test.ts
Normal file
465
tests/integration/sse-service-real-client.test.ts
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import { Server } from 'http';
|
||||||
|
import { AppServer } from '../../src/server.js';
|
||||||
|
import { TestServerHelper } from '../utils/testServerHelper.js';
|
||||||
|
import * as mockSettings from '../utils/mockSettings.js';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { cleanupAllServers } from '../../src/services/mcpService.js';
|
||||||
|
|
||||||
|
describe('Real Client Transport Integration Tests', () => {
|
||||||
|
let _appServer: AppServer;
|
||||||
|
let httpServer: Server;
|
||||||
|
let baseURL: string;
|
||||||
|
let testServerHelper: TestServerHelper;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const settings = mockSettings.createMockSettings();
|
||||||
|
testServerHelper = new TestServerHelper();
|
||||||
|
const result = await testServerHelper.createTestServer(settings);
|
||||||
|
|
||||||
|
_appServer = result.appServer;
|
||||||
|
httpServer = result.httpServer;
|
||||||
|
baseURL = result.baseURL;
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up all MCP server connections first
|
||||||
|
cleanupAllServers();
|
||||||
|
|
||||||
|
// Close the test server properly using the helper
|
||||||
|
if (testServerHelper) {
|
||||||
|
await testServerHelper.closeTestServer();
|
||||||
|
} else if (httpServer) {
|
||||||
|
// Fallback to direct close if helper is not available
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
httpServer.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit to ensure all async operations complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSE Client Transport Tests', () => {
|
||||||
|
it('should connect using real SSEClientTransport', async () => {
|
||||||
|
const sseUrl = new URL(`${baseURL}/sse`);
|
||||||
|
const options = {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-auth-token-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = new SSEClientTransport(sseUrl, options);
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'real-sse-test-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
let error: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, {});
|
||||||
|
isConnected = true;
|
||||||
|
console.log('SSE Client connected successfully');
|
||||||
|
|
||||||
|
// Test list tools
|
||||||
|
const tools = await client.listTools({});
|
||||||
|
console.log('Available tools (SSE):', JSON.stringify(tools, null, 2));
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
console.log('SSE Client closed successfully');
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
console.error('SSE Client test failed:', err);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
console.error('Error closing client:', closeErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(isConnected).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it('should connect using real SSEClientTransport with group', async () => {
|
||||||
|
const testGroup = 'integration-test-group';
|
||||||
|
const options = {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-auth-token-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sseUrl = new URL(`${baseURL}/sse/${testGroup}`);
|
||||||
|
|
||||||
|
const transport = new SSEClientTransport(sseUrl, options);
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'real-sse-group-test-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
let error: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, {});
|
||||||
|
isConnected = true;
|
||||||
|
|
||||||
|
console.log(`SSE Client with group ${testGroup} connected successfully`);
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
const tools = await client.listTools({});
|
||||||
|
console.log('Available tools (SSE with group):', JSON.stringify(tools, null, 2));
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
console.error('SSE Client with group test failed:', err);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
console.error('Error closing client:', closeErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(isConnected).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StreamableHTTP Client Transport Tests', () => {
|
||||||
|
it('should connect using real StreamableHTTPClientTransport', async () => {
|
||||||
|
const mcpUrl = new URL(`${baseURL}/mcp`);
|
||||||
|
const options: any = {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer test-auth-token-123`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPClientTransport(mcpUrl, options);
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'real-http-test-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
let error: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, {});
|
||||||
|
isConnected = true;
|
||||||
|
console.log('HTTP Client connected successfully');
|
||||||
|
|
||||||
|
// Test list tools
|
||||||
|
const tools = await client.listTools({});
|
||||||
|
console.log('Available tools (HTTP):', JSON.stringify(tools, null, 2));
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
console.log('HTTP Client closed successfully');
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
console.error('HTTP Client test failed:', err);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
console.error('Error closing client:', closeErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(isConnected).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it('should connect using real StreamableHTTPClientTransport with group', async () => {
|
||||||
|
const testGroup = 'integration-test-group';
|
||||||
|
const mcpUrl = new URL(`${baseURL}/mcp/${testGroup}`);
|
||||||
|
const options: any = {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer test-auth-token-123`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPClientTransport(mcpUrl, options);
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'real-http-group-test-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
let error: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, {});
|
||||||
|
isConnected = true;
|
||||||
|
|
||||||
|
console.log(`HTTP Client with group ${testGroup} connected successfully`);
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
const tools = await client.listTools({});
|
||||||
|
console.log('Available tools (HTTP with group):', JSON.stringify(tools, null, 2));
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
console.error('HTTP Client with group test failed:', err);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
console.error('Error closing client:', closeErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(isConnected).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real Client Authentication Tests', () => {
|
||||||
|
let _authAppServer: AppServer;
|
||||||
|
let _authHttpServer: Server;
|
||||||
|
let authBaseURL: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const authSettings = mockSettings.createMockSettingsWithAuth();
|
||||||
|
const authTestServerHelper = new TestServerHelper();
|
||||||
|
const authResult = await authTestServerHelper.createTestServer(authSettings);
|
||||||
|
|
||||||
|
_authAppServer = authResult.appServer;
|
||||||
|
_authHttpServer = authResult.httpServer;
|
||||||
|
authBaseURL = authResult.baseURL;
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (_authHttpServer) {
|
||||||
|
_authHttpServer.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to connect with SSEClientTransport without auth', async () => {
|
||||||
|
const sseUrl = new URL(`${authBaseURL}/sse`);
|
||||||
|
const options = {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-auth-token-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const transport = new SSEClientTransport(sseUrl, options);
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'real-sse-test-client-no-auth',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let error: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, {});
|
||||||
|
|
||||||
|
// Should not reach here due to auth failure
|
||||||
|
await client.listTools({});
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
console.log('Expected auth error:', err);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
// Ignore close errors after connection failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
if (error) {
|
||||||
|
expect(error.message).toContain('401');
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it('should connect with SSEClientTransport with valid auth', async () => {
|
||||||
|
const sseUrl = new URL(`${authBaseURL}/sse`);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-auth-token-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = new SSEClientTransport(sseUrl, options);
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'real-sse-auth-test-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
let error: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, {});
|
||||||
|
isConnected = true;
|
||||||
|
console.log('SSE Client with auth connected successfully');
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
const tools = await client.listTools({});
|
||||||
|
console.log('Available tools (SSE with auth):', JSON.stringify(tools, null, 2));
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
console.error('SSE Client with auth test failed:', err);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
console.error('Error closing client:', closeErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(isConnected).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it('should connect with StreamableHTTPClientTransport with auth', async () => {
|
||||||
|
const mcpUrl = new URL(`${authBaseURL}/mcp`);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-auth-token-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPClientTransport(mcpUrl, options);
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'real-http-auth-test-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
let error: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, {});
|
||||||
|
isConnected = true;
|
||||||
|
|
||||||
|
console.log('HTTP Client with auth connected successfully');
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
const tools = await client.listTools({});
|
||||||
|
console.log('Available tools (HTTP with auth):', JSON.stringify(tools, null, 2));
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
console.error('HTTP Client with auth test failed:', err);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
console.error('Error closing client:', closeErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(isConnected).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
tests/utils/mockSettings.ts
Normal file
107
tests/utils/mockSettings.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { McpSettings, ServerConfig, SystemConfig, IGroup, IUser } from '../../src/types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates mock MCP settings for testing
|
||||||
|
* @param overrides Optional configuration overrides
|
||||||
|
* @returns Mock McpSettings object
|
||||||
|
*/
|
||||||
|
export const createMockSettings = (overrides: Partial<McpSettings> = {}): McpSettings => {
|
||||||
|
const defaultSettings: McpSettings = {
|
||||||
|
mcpServers: {
|
||||||
|
'test-server-1': {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', 'time-mcp'],
|
||||||
|
env: {},
|
||||||
|
enabled: true,
|
||||||
|
keepAliveInterval: 30000,
|
||||||
|
type: 'stdio',
|
||||||
|
} as ServerConfig,
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: 'integration-test-group',
|
||||||
|
servers: ['test-server-1'],
|
||||||
|
description: 'Test group for integration tests',
|
||||||
|
owner: 'admin',
|
||||||
|
} as IGroup,
|
||||||
|
],
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'test-auth-token-123',
|
||||||
|
},
|
||||||
|
} as SystemConfig,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
isAdmin: false,
|
||||||
|
} as IUser,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultSettings,
|
||||||
|
...overrides,
|
||||||
|
mcpServers: {
|
||||||
|
...defaultSettings.mcpServers,
|
||||||
|
...(overrides.mcpServers || {}),
|
||||||
|
},
|
||||||
|
groups: [...(defaultSettings.groups || []), ...(overrides.groups || [])],
|
||||||
|
systemConfig: {
|
||||||
|
...defaultSettings.systemConfig,
|
||||||
|
...(overrides.systemConfig || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates mock settings with bearer authentication enabled
|
||||||
|
*/
|
||||||
|
export const createMockSettingsWithAuth = (bearerKey = 'test-auth-token-123'): McpSettings => {
|
||||||
|
return createMockSettings({
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: true,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: bearerKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates mock settings with global routes disabled
|
||||||
|
*/
|
||||||
|
export const createMockSettingsNoGlobalRoutes = (): McpSettings => {
|
||||||
|
return createMockSettings({
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableGlobalRoute: false,
|
||||||
|
enableGroupNameRoute: true,
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock settings helper for specific test scenarios
|
||||||
|
*/
|
||||||
|
export const getMockSettingsForScenario = (
|
||||||
|
scenario: 'auth' | 'no-global' | 'basic',
|
||||||
|
): McpSettings => {
|
||||||
|
switch (scenario) {
|
||||||
|
case 'auth':
|
||||||
|
return createMockSettingsWithAuth();
|
||||||
|
case 'no-global':
|
||||||
|
return createMockSettingsNoGlobalRoutes();
|
||||||
|
case 'basic':
|
||||||
|
default:
|
||||||
|
return createMockSettings();
|
||||||
|
}
|
||||||
|
};
|
||||||
176
tests/utils/testServerHelper.ts
Normal file
176
tests/utils/testServerHelper.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Server } from 'http';
|
||||||
|
import { AppServer } from '../../src/server.js';
|
||||||
|
import { McpSettings } from '../../src/types/index.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { createMockSettings } from './mockSettings.js';
|
||||||
|
import { clearSettingsCache } from '../../src/config/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test server helper class for managing AppServer instances during testing
|
||||||
|
*/
|
||||||
|
export class TestServerHelper {
|
||||||
|
private appServer: AppServer | null = null;
|
||||||
|
private httpServer: Server | null = null;
|
||||||
|
private originalConfigPath: string | null = null;
|
||||||
|
private testConfigPath: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and initializes a test server with mock settings
|
||||||
|
* @param mockSettings Optional mock settings to use
|
||||||
|
* @returns Object containing server instance and base URL
|
||||||
|
*/
|
||||||
|
async createTestServer(mockSettings?: McpSettings): Promise<{
|
||||||
|
appServer: AppServer;
|
||||||
|
httpServer: Server;
|
||||||
|
baseURL: string;
|
||||||
|
port: number;
|
||||||
|
}> {
|
||||||
|
// Use provided mock settings or create default ones
|
||||||
|
const settings = mockSettings || createMockSettings();
|
||||||
|
|
||||||
|
// Create temporary config file for testing
|
||||||
|
await this.setupTemporaryConfig(settings);
|
||||||
|
|
||||||
|
// Create and initialize AppServer
|
||||||
|
this.appServer = new AppServer();
|
||||||
|
await this.appServer.initialize();
|
||||||
|
|
||||||
|
// Wait for server connection with timeout
|
||||||
|
const maxAttempts = 30;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
if (this.appServer.connected()) {
|
||||||
|
console.log('Test server is ready');
|
||||||
|
break;
|
||||||
|
} else if (attempt === maxAttempts - 1) {
|
||||||
|
throw new Error('Test server did not become ready in time');
|
||||||
|
}
|
||||||
|
console.log(`Waiting for test server to be ready... Attempt ${attempt + 1}/${maxAttempts}`);
|
||||||
|
await delay(3000); // Short delay between checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server on random available port
|
||||||
|
const app = this.appServer.getApp();
|
||||||
|
this.httpServer = app.listen(0);
|
||||||
|
|
||||||
|
const address = this.httpServer.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 3000;
|
||||||
|
const baseURL = `http://localhost:${port}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
appServer: this.appServer,
|
||||||
|
httpServer: this.httpServer,
|
||||||
|
baseURL,
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the test server and cleans up temporary files
|
||||||
|
*/
|
||||||
|
async closeTestServer(): Promise<void> {
|
||||||
|
if (this.httpServer) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.httpServer!.close(() => resolve());
|
||||||
|
});
|
||||||
|
this.httpServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appServer = null;
|
||||||
|
|
||||||
|
// Clean up temporary config file
|
||||||
|
await this.cleanupTemporaryConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a temporary config file for testing
|
||||||
|
* @param settings Mock settings to write to the config file
|
||||||
|
*/
|
||||||
|
private async setupTemporaryConfig(settings: McpSettings): Promise<void> {
|
||||||
|
// Store original path if it exists
|
||||||
|
this.originalConfigPath = process.env.MCPHUB_SETTING_PATH || null;
|
||||||
|
|
||||||
|
const configDir = path.join(process.cwd(), 'temp-test-config');
|
||||||
|
|
||||||
|
// Create temp config directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(configDir)) {
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.testConfigPath = path.join(configDir, 'mcp_settings.json');
|
||||||
|
|
||||||
|
// Write mock settings to temporary file
|
||||||
|
fs.writeFileSync(this.testConfigPath, JSON.stringify(settings, null, 2));
|
||||||
|
|
||||||
|
// Override the settings path for the test
|
||||||
|
process.env.MCPHUB_SETTING_PATH = this.testConfigPath;
|
||||||
|
|
||||||
|
// Clear settings cache to force re-reading from the new config file
|
||||||
|
clearSettingsCache();
|
||||||
|
|
||||||
|
console.log(`Set test config path: ${this.testConfigPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up the temporary config file
|
||||||
|
*/
|
||||||
|
private async cleanupTemporaryConfig(): Promise<void> {
|
||||||
|
if (this.testConfigPath && fs.existsSync(this.testConfigPath)) {
|
||||||
|
fs.unlinkSync(this.testConfigPath);
|
||||||
|
|
||||||
|
// Try to remove the temp directory if empty
|
||||||
|
const configDir = path.dirname(this.testConfigPath);
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(configDir);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error if directory is not empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset environment variable
|
||||||
|
if (this.originalConfigPath !== null) {
|
||||||
|
process.env.MCPHUB_SETTING_PATH = this.originalConfigPath;
|
||||||
|
} else {
|
||||||
|
delete process.env.MCPHUB_SETTING_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.testConfigPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a server to be ready by attempting to connect
|
||||||
|
* @param baseURL Base URL of the server
|
||||||
|
* @param maxAttempts Maximum number of connection attempts
|
||||||
|
* @param delay Delay between attempts in milliseconds
|
||||||
|
*/
|
||||||
|
export const waitForServerReady = async (
|
||||||
|
baseURL: string,
|
||||||
|
maxAttempts = 10,
|
||||||
|
delay = 500,
|
||||||
|
): Promise<void> => {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseURL}/health`);
|
||||||
|
if (response.ok || response.status === 404) {
|
||||||
|
return; // Server is responding
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Server not ready yet
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < maxAttempts - 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Server at ${baseURL} not ready after ${maxAttempts} attempts`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a promise that resolves after the specified delay
|
||||||
|
* @param ms Delay in milliseconds
|
||||||
|
*/
|
||||||
|
export const delay = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
};
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"strictPropertyInitialization": false
|
"strictPropertyInitialization": false,
|
||||||
|
"isolatedModules": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "**/*.test.ts", "dist"]
|
"exclude": ["node_modules", "**/*.test.ts", "dist"]
|
||||||
|
|||||||
12
tsconfig.test.json
Normal file
12
tsconfig.test.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user