feat: add admin edit endpoint for server moderation (#315)
## Summary
Implements `PUT /v0/servers/{id}` admin edit endpoint to address part of
issue #182, providing an API-based solution for server moderation
without requiring direct database access.
- **New endpoint**: `PUT /v0/servers/{id}` for editing existing servers
- **Security**: Uses existing JWT auth with `PermissionActionEdit`
permissions
- **Anti-undelete protection**: Added `ServerStatusDeleted` status that
cannot be reverted
- **Full implementation**: Database, service, API, and comprehensive
test coverage
## Key Use Cases
- Mark malicious servers as `deleted` for takedown
- Update server metadata for moderation actions
Related to: https://github.com/modelcontextprotocol/registry/issues/182
(but that is not _done_ yet because we need admins to be able to login
(probably with #283), and need to add docs on how to do the takedown,
and probably guidelines on what we will/won't takedown)
This commit is contained in:
parent
36fcdff044
commit
6431f23107
12 changed files with 681 additions and 23 deletions
|
|
@ -66,9 +66,9 @@
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["active", "deprecated"],
|
"enum": ["active", "deprecated", "deleted"],
|
||||||
"default": "active",
|
"default": "active",
|
||||||
"description": "Server lifecycle status. 'deprecated' indicates the server is no longer recommended for new usage."
|
"description": "Server lifecycle status. 'deprecated' indicates the server is no longer recommended for new usage. 'deleted' indicates the server should never be installed and existing installations should be uninstalled - this is rare, and usually indicates malware or a legal takedown."
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"$ref": "#/$defs/Repository"
|
"$ref": "#/$defs/Repository"
|
||||||
|
|
|
||||||
110
internal/api/handlers/v0/edit.go
Normal file
110
internal/api/handlers/v0/edit.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
package v0
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/model"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/validators"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EditServerInput represents the input for editing a server
|
||||||
|
type EditServerInput struct {
|
||||||
|
Authorization string `header:"Authorization" doc:"Registry JWT token with edit permissions" required:"true"`
|
||||||
|
ID string `path:"id" doc:"Server ID (UUID)" format:"uuid"`
|
||||||
|
RawBody []byte `body:"raw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterEditEndpoints registers the edit endpoint
|
||||||
|
func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg *config.Config) {
|
||||||
|
jwtManager := auth.NewJWTManager(cfg)
|
||||||
|
|
||||||
|
// Edit server endpoint
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "edit-server",
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/v0/servers/{id}",
|
||||||
|
Summary: "Edit MCP server",
|
||||||
|
Description: "Update an existing MCP server (admin only)",
|
||||||
|
Tags: []string{"admin"},
|
||||||
|
Security: []map[string][]string{
|
||||||
|
{"bearer": {}},
|
||||||
|
},
|
||||||
|
}, func(ctx context.Context, input *EditServerInput) (*Response[model.ServerResponse], error) {
|
||||||
|
// Extract bearer token
|
||||||
|
const bearerPrefix = "Bearer "
|
||||||
|
authHeader := input.Authorization
|
||||||
|
if len(authHeader) < len(bearerPrefix) || !strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) {
|
||||||
|
return nil, huma.Error401Unauthorized("Invalid Authorization header format. Expected 'Bearer <token>'")
|
||||||
|
}
|
||||||
|
token := authHeader[len(bearerPrefix):]
|
||||||
|
|
||||||
|
// Validate Registry JWT token
|
||||||
|
claims, err := jwtManager.ValidateToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, huma.Error401Unauthorized("Invalid or expired Registry JWT token", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that only allowed extension fields are present
|
||||||
|
if err := model.ValidatePublishRequestExtensions(input.RawBody); err != nil {
|
||||||
|
return nil, huma.Error400BadRequest("Invalid request format", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current server to check permissions against existing name
|
||||||
|
currentServer, err := registry.GetByID(input.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrNotFound) {
|
||||||
|
return nil, huma.Error404NotFound("Server not found")
|
||||||
|
}
|
||||||
|
return nil, huma.Error500InternalServerError("Failed to get current server", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify edit permissions for this server using the existing server name
|
||||||
|
if !jwtManager.HasPermission(currentServer.Server.Name, auth.PermissionActionEdit, claims.Permissions) {
|
||||||
|
return nil, huma.Error403Forbidden("You do not have edit permissions for this server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the validated request body
|
||||||
|
var editRequest model.PublishRequest
|
||||||
|
if err := json.Unmarshal(input.RawBody, &editRequest); err != nil {
|
||||||
|
return nil, huma.Error400BadRequest("Invalid JSON format", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the server detail
|
||||||
|
validator := validators.NewObjectValidator()
|
||||||
|
if err := validator.Validate(&editRequest.Server); err != nil {
|
||||||
|
return nil, huma.Error400BadRequest(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent renaming servers
|
||||||
|
if currentServer.Server.Name != editRequest.Server.Name {
|
||||||
|
return nil, huma.Error400BadRequest("Cannot rename server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent undeleting servers - once deleted, they stay deleted
|
||||||
|
if currentServer.Server.Status == model.ServerStatusDeleted && editRequest.Server.Status != model.ServerStatusDeleted {
|
||||||
|
return nil, huma.Error400BadRequest("Cannot change status of deleted server. Deleted servers cannot be undeleted.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit the server
|
||||||
|
updatedServer, err := registry.EditServer(input.ID, editRequest)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrNotFound) {
|
||||||
|
return nil, huma.Error404NotFound("Server not found")
|
||||||
|
}
|
||||||
|
return nil, huma.Error400BadRequest("Failed to edit server", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Response[model.ServerResponse]{
|
||||||
|
Body: *updatedServer,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
348
internal/api/handlers/v0/edit_test.go
Normal file
348
internal/api/handlers/v0/edit_test.go
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
package v0_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
|
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEditServerEndpoint(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
serverID string
|
||||||
|
authHeader string
|
||||||
|
requestBody interface{}
|
||||||
|
setupMocks func(*MockRegistryService)
|
||||||
|
expectedStatus int
|
||||||
|
expectedError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful edit with valid token and permissions",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: func() string {
|
||||||
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
AuthMethod: model.AuthMethodGitHubAT,
|
||||||
|
AuthMethodSubject: "domdomegg",
|
||||||
|
Permissions: []auth.Permission{
|
||||||
|
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return "Bearer " + token
|
||||||
|
}(),
|
||||||
|
requestBody: model.PublishRequest{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Updated test server",
|
||||||
|
Status: model.ServerStatusDeprecated,
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/domdomegg/test-server",
|
||||||
|
Source: "github",
|
||||||
|
ID: "domdomegg/test-server",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupMocks: func(registry *MockRegistryService) {
|
||||||
|
// Current server (not deleted)
|
||||||
|
currentServer := &model.ServerResponse{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Original server",
|
||||||
|
Status: model.ServerStatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
||||||
|
|
||||||
|
expectedResponse := &model.ServerResponse{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Updated test server",
|
||||||
|
Status: model.ServerStatusDeprecated,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("model.PublishRequest")).Return(expectedResponse, nil)
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing authorization header",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: "",
|
||||||
|
requestBody: model.PublishRequest{},
|
||||||
|
setupMocks: func(_ *MockRegistryService) {},
|
||||||
|
expectedStatus: 422,
|
||||||
|
expectedError: "required header parameter is missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid authorization header format",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: "InvalidFormat token123",
|
||||||
|
requestBody: model.PublishRequest{},
|
||||||
|
setupMocks: func(_ *MockRegistryService) {},
|
||||||
|
expectedStatus: http.StatusUnauthorized,
|
||||||
|
expectedError: "Unauthorized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid token",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: "Bearer invalid-token",
|
||||||
|
requestBody: model.PublishRequest{},
|
||||||
|
setupMocks: func(_ *MockRegistryService) {},
|
||||||
|
expectedStatus: http.StatusUnauthorized,
|
||||||
|
expectedError: "Unauthorized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission denied - no edit permissions",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: func() string {
|
||||||
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
AuthMethod: model.AuthMethodGitHubAT,
|
||||||
|
AuthMethodSubject: "domdomegg",
|
||||||
|
Permissions: []auth.Permission{
|
||||||
|
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.domdomegg/*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return "Bearer " + token
|
||||||
|
}(),
|
||||||
|
requestBody: model.PublishRequest{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Updated test server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupMocks: func(registry *MockRegistryService) {
|
||||||
|
// Need to mock GetByID since we check permissions against existing server name
|
||||||
|
currentServer := &model.ServerResponse{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Original server",
|
||||||
|
Status: model.ServerStatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusForbidden,
|
||||||
|
expectedError: "Forbidden",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission denied - wrong resource",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: func() string {
|
||||||
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
AuthMethod: model.AuthMethodGitHubAT,
|
||||||
|
AuthMethodSubject: "domdomegg",
|
||||||
|
Permissions: []auth.Permission{
|
||||||
|
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return "Bearer " + token
|
||||||
|
}(),
|
||||||
|
requestBody: model.PublishRequest{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.other/test-server",
|
||||||
|
Description: "Updated test server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupMocks: func(registry *MockRegistryService) {
|
||||||
|
// Need to mock GetByID since we check permissions against existing server name
|
||||||
|
// This test case shows a different scenario: existing server is "other" but user only has perms for "domdomegg"
|
||||||
|
currentServer := &model.ServerResponse{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.other/test-server",
|
||||||
|
Description: "Original server",
|
||||||
|
Status: model.ServerStatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusForbidden,
|
||||||
|
expectedError: "Forbidden",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server not found",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: func() string {
|
||||||
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
AuthMethod: model.AuthMethodGitHubAT,
|
||||||
|
AuthMethodSubject: "domdomegg",
|
||||||
|
Permissions: []auth.Permission{
|
||||||
|
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return "Bearer " + token
|
||||||
|
}(),
|
||||||
|
requestBody: model.PublishRequest{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Updated test server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupMocks: func(registry *MockRegistryService) {
|
||||||
|
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(nil, database.ErrNotFound)
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusNotFound,
|
||||||
|
expectedError: "Not Found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service error",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: func() string {
|
||||||
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
AuthMethod: model.AuthMethodGitHubAT,
|
||||||
|
AuthMethodSubject: "domdomegg",
|
||||||
|
Permissions: []auth.Permission{
|
||||||
|
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return "Bearer " + token
|
||||||
|
}(),
|
||||||
|
requestBody: model.PublishRequest{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Updated test server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupMocks: func(registry *MockRegistryService) {
|
||||||
|
// Current server (not deleted)
|
||||||
|
currentServer := &model.ServerResponse{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Original server",
|
||||||
|
Status: model.ServerStatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
||||||
|
registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("model.PublishRequest")).Return(nil, errors.New("database error"))
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusBadRequest,
|
||||||
|
expectedError: "Bad Request",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid request body",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: func() string {
|
||||||
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
AuthMethod: model.AuthMethodGitHubAT,
|
||||||
|
AuthMethodSubject: "domdomegg",
|
||||||
|
Permissions: []auth.Permission{
|
||||||
|
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return "Bearer " + token
|
||||||
|
}(),
|
||||||
|
requestBody: "invalid json",
|
||||||
|
setupMocks: func(_ *MockRegistryService) {},
|
||||||
|
expectedStatus: http.StatusBadRequest,
|
||||||
|
expectedError: "Bad Request",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cannot undelete server",
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
authHeader: func() string {
|
||||||
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
AuthMethod: model.AuthMethodGitHubAT,
|
||||||
|
AuthMethodSubject: "domdomegg",
|
||||||
|
Permissions: []auth.Permission{
|
||||||
|
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return "Bearer " + token
|
||||||
|
}(),
|
||||||
|
requestBody: model.PublishRequest{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Trying to undelete server",
|
||||||
|
Status: model.ServerStatusActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupMocks: func(registry *MockRegistryService) {
|
||||||
|
// Current server is deleted
|
||||||
|
currentServer := &model.ServerResponse{
|
||||||
|
Server: model.ServerDetail{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Original server",
|
||||||
|
Status: model.ServerStatusDeleted,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusBadRequest,
|
||||||
|
expectedError: "Cannot change status of deleted server",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create mock registry
|
||||||
|
mockRegistry := new(MockRegistryService)
|
||||||
|
tc.setupMocks(mockRegistry)
|
||||||
|
|
||||||
|
// Create Huma API
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
humaConfig := huma.DefaultConfig("Test API", "1.0.0")
|
||||||
|
api := humago.New(mux, humaConfig)
|
||||||
|
|
||||||
|
// Register edit endpoints
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c",
|
||||||
|
}
|
||||||
|
v0.RegisterEditEndpoints(api, mockRegistry, cfg)
|
||||||
|
|
||||||
|
// Create request body
|
||||||
|
var requestBody []byte
|
||||||
|
var err error
|
||||||
|
if str, ok := tc.requestBody.(string); ok {
|
||||||
|
requestBody = []byte(str)
|
||||||
|
} else {
|
||||||
|
requestBody, err = json.Marshal(tc.requestBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/v0/servers/"+tc.serverID, bytes.NewReader(requestBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if tc.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tc.authHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Call the endpoint
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
assert.Equal(t, tc.expectedStatus, w.Code)
|
||||||
|
|
||||||
|
// Check error message if expected
|
||||||
|
if tc.expectedError != "" {
|
||||||
|
assert.Contains(t, w.Body.String(), tc.expectedError)
|
||||||
|
}
|
||||||
|
|
||||||
|
mockRegistry.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,14 @@ func (m *MockRegistryService) Publish(request model.PublishRequest) (*model.Serv
|
||||||
return args.Get(0).(*model.ServerResponse), args.Error(1)
|
return args.Get(0).(*model.ServerResponse), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockRegistryService) EditServer(id string, request model.PublishRequest) (*model.ServerResponse, error) {
|
||||||
|
args := m.Called(id, request)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*model.ServerResponse), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to generate a valid JWT token for testing
|
// Helper function to generate a valid JWT token for testing
|
||||||
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
|
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
|
||||||
jwtManager := auth.NewJWTManager(cfg)
|
jwtManager := auth.NewJWTManager(cfg)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ func RegisterV0Routes(
|
||||||
v0.RegisterHealthEndpoint(api, cfg, metrics)
|
v0.RegisterHealthEndpoint(api, cfg, metrics)
|
||||||
v0.RegisterPingEndpoint(api)
|
v0.RegisterPingEndpoint(api)
|
||||||
v0.RegisterServersEndpoints(api, registry)
|
v0.RegisterServersEndpoints(api, registry)
|
||||||
|
v0.RegisterEditEndpoints(api, registry, cfg)
|
||||||
v0auth.RegisterAuthEndpoints(api, cfg)
|
v0auth.RegisterAuthEndpoints(api, cfg)
|
||||||
v0.RegisterPublishEndpoint(api, registry, cfg)
|
v0.RegisterPublishEndpoint(api, registry, cfg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ type Database interface {
|
||||||
Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error)
|
Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error)
|
||||||
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
||||||
UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error
|
UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error
|
||||||
|
// UpdateServer updates an existing server record with new server details
|
||||||
|
UpdateServer(ctx context.Context, id string, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error)
|
||||||
// ImportSeed imports initial data from a seed file
|
// ImportSeed imports initial data from a seed file
|
||||||
ImportSeed(ctx context.Context, seedFilePath string) error
|
ImportSeed(ctx context.Context, seedFilePath string) error
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,29 @@ func (db *MemoryDB) UpdateLatestFlag(ctx context.Context, id string, isLatest bo
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateServer updates an existing server record with new server details
|
||||||
|
func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := db.entries[id]
|
||||||
|
if !exists {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the server details
|
||||||
|
entry.ServerJSON = serverDetail
|
||||||
|
entry.PublisherExtensions = publisherExtensions
|
||||||
|
entry.RegistryMetadata.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// Return the updated record
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
// For an in-memory database, this is a no-op
|
// For an in-memory database, this is a no-op
|
||||||
func (db *MemoryDB) Close() error {
|
func (db *MemoryDB) Close() error {
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,96 @@ func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateServer updates an existing server record with new server details
|
||||||
|
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if the server exists
|
||||||
|
_, err := db.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a transaction for atomic updates
|
||||||
|
tx, err := db.conn.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
|
||||||
|
log.Printf("Failed to rollback transaction: %v", rollbackErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Prepare JSON data
|
||||||
|
repositoryJSON, err := json.Marshal(serverDetail.Repository)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
packagesJSON, err := json.Marshal(serverDetail.Packages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal packages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remotesJSON, err := json.Marshal(serverDetail.Remotes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal remotes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publisherExtensionsJSON, err := json.Marshal(publisherExtensions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal publisher extensions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Update servers table
|
||||||
|
serverQuery := `
|
||||||
|
UPDATE servers
|
||||||
|
SET name = $1, description = $2, status = $3, repository = $4,
|
||||||
|
version = $5, packages = $6, remotes = $7, updated_at = $8
|
||||||
|
WHERE id = $9
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, serverQuery,
|
||||||
|
serverDetail.Name,
|
||||||
|
serverDetail.Description,
|
||||||
|
serverDetail.Status,
|
||||||
|
repositoryJSON,
|
||||||
|
serverDetail.VersionDetail.Version,
|
||||||
|
packagesJSON,
|
||||||
|
remotesJSON,
|
||||||
|
now,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update server_extensions table
|
||||||
|
extQuery := `
|
||||||
|
UPDATE server_extensions
|
||||||
|
SET updated_at = $1, publisher_extensions = $2
|
||||||
|
WHERE server_id = $3
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, extQuery, now, publisherExtensionsJSON, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update server extensions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the updated record
|
||||||
|
return db.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
func (db *PostgreSQL) Close() error {
|
func (db *PostgreSQL) Close() error {
|
||||||
return db.conn.Close(context.Background())
|
return db.conn.Close(context.Background())
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,9 @@ const (
|
||||||
type ServerStatus string
|
type ServerStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ServerStatusActive represents an actively maintained server (as asserted by the publisher)
|
ServerStatusActive ServerStatus = "active"
|
||||||
ServerStatusActive ServerStatus = "active"
|
|
||||||
// ServerStatusDeprecated represents a server that is no longer actively maintained
|
|
||||||
ServerStatusDeprecated ServerStatus = "deprecated"
|
ServerStatusDeprecated ServerStatus = "deprecated"
|
||||||
|
ServerStatusDeleted ServerStatus = "deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository represents a source code repository as defined in the spec
|
// Repository represents a source code repository as defined in the spec
|
||||||
|
|
@ -91,17 +90,17 @@ type Argument struct {
|
||||||
|
|
||||||
type Package struct {
|
type Package struct {
|
||||||
// RegistryType indicates how to download packages (e.g., "npm", "pypi", "docker-hub", "mcpb")
|
// RegistryType indicates how to download packages (e.g., "npm", "pypi", "docker-hub", "mcpb")
|
||||||
RegistryType string `json:"registry_type,omitempty" bson:"registry_type,omitempty"`
|
RegistryType string `json:"registry_type,omitempty" bson:"registry_type,omitempty"`
|
||||||
// RegistryBaseURL is the base URL of the package registry
|
// RegistryBaseURL is the base URL of the package registry
|
||||||
RegistryBaseURL string `json:"registry_base_url,omitempty" bson:"registry_base_url,omitempty"`
|
RegistryBaseURL string `json:"registry_base_url,omitempty" bson:"registry_base_url,omitempty"`
|
||||||
// Identifier is the package identifier - either a package name (for registries) or URL (for direct downloads)
|
// Identifier is the package identifier - either a package name (for registries) or URL (for direct downloads)
|
||||||
Identifier string `json:"identifier,omitempty" bson:"identifier,omitempty"`
|
Identifier string `json:"identifier,omitempty" bson:"identifier,omitempty"`
|
||||||
Version string `json:"version,omitempty" bson:"version,omitempty"`
|
Version string `json:"version,omitempty" bson:"version,omitempty"`
|
||||||
FileSHA256 string `json:"file_sha256,omitempty" bson:"file_sha256,omitempty"`
|
FileSHA256 string `json:"file_sha256,omitempty" bson:"file_sha256,omitempty"`
|
||||||
RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"`
|
RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"`
|
||||||
RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"`
|
RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"`
|
||||||
PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"`
|
PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"`
|
||||||
EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"`
|
EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote represents a remote connection endpoint
|
// Remote represents a remote connection endpoint
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,35 @@ func (s *fakeRegistryService) Publish(req model.PublishRequest) (*model.ServerRe
|
||||||
return &response, nil
|
return &response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditServer updates an existing server with new details (admin operation)
|
||||||
|
func (s *fakeRegistryService) EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Validate the request
|
||||||
|
if err := model.ValidatePublisherExtensions(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate server name exists and format
|
||||||
|
if _, err := model.ParseServerName(req.Server); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract publisher extensions from request
|
||||||
|
publisherExtensions := model.ExtractPublisherExtensions(req)
|
||||||
|
|
||||||
|
// Update server in database
|
||||||
|
serverRecord, err := s.db.UpdateServer(ctx, id, req.Server, publisherExtensions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ServerRecord to ServerResponse format
|
||||||
|
response := serverRecord.ToServerResponse()
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the in-memory database connection
|
// Close closes the in-memory database connection
|
||||||
func (s *fakeRegistryService) Close() error {
|
func (s *fakeRegistryService) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ func validatePackage(pkg *model.Package) error {
|
||||||
|
|
||||||
// For direct download packages (mcpb or direct URLs)
|
// For direct download packages (mcpb or direct URLs)
|
||||||
if registryType == "mcpb" ||
|
if registryType == "mcpb" ||
|
||||||
strings.HasPrefix(pkg.Identifier, "http://") || strings.HasPrefix(pkg.Identifier, "https://") {
|
strings.HasPrefix(pkg.Identifier, "http://") || strings.HasPrefix(pkg.Identifier, "https://") {
|
||||||
parsedURL, err := url.Parse(pkg.Identifier)
|
parsedURL, err := url.Parse(pkg.Identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid package URL: %w", err)
|
return fmt.Errorf("invalid package URL: %w", err)
|
||||||
|
|
@ -254,3 +254,49 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditServer updates an existing server with new details (admin operation)
|
||||||
|
func (s *registryServiceImpl) EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Validate the request
|
||||||
|
if err := model.ValidatePublisherExtensions(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate server name exists and format
|
||||||
|
if _, err := model.ParseServerName(req.Server); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all packages
|
||||||
|
for _, pkg := range req.Server.Packages {
|
||||||
|
if err := validatePackage(&pkg); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reverse-DNS namespace matching for remote URLs
|
||||||
|
if err := model.ValidateRemoteNamespaceMatch(req.Server); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate remote URLs
|
||||||
|
if err := s.validateNoDuplicateRemoteURLs(ctx, req.Server); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract publisher extensions from request
|
||||||
|
publisherExtensions := model.ExtractPublisherExtensions(req)
|
||||||
|
|
||||||
|
// Update server in database
|
||||||
|
serverRecord, err := s.db.UpdateServer(ctx, id, req.Server, publisherExtensions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ServerRecord to ServerResponse format
|
||||||
|
response := serverRecord.ToServerResponse()
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,6 @@ type RegistryService interface {
|
||||||
GetByID(id string) (*model.ServerResponse, error)
|
GetByID(id string) (*model.ServerResponse, error)
|
||||||
// Publish publishes a server with separated extensions
|
// Publish publishes a server with separated extensions
|
||||||
Publish(req model.PublishRequest) (*model.ServerResponse, error)
|
Publish(req model.PublishRequest) (*model.ServerResponse, error)
|
||||||
|
// EditServer updates an existing server with new details
|
||||||
|
EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue