feat: Production readiness improvements for WHOOSH council formation

Major security, observability, and configuration improvements:

## Security Hardening
- Implemented configurable CORS (no more wildcards)
- Added comprehensive auth middleware for admin endpoints
- Enhanced webhook HMAC validation
- Added input validation and rate limiting
- Security headers and CSP policies

## Configuration Management
- Made N8N webhook URL configurable (WHOOSH_N8N_BASE_URL)
- Replaced all hardcoded endpoints with environment variables
- Added feature flags for LLM vs heuristic composition
- Gitea fetch hardening with EAGER_FILTER and FULL_RESCAN options

## API Completeness
- Implemented GetCouncilComposition function
- Added GET /api/v1/councils/{id} endpoint
- Council artifacts API (POST/GET /api/v1/councils/{id}/artifacts)
- /admin/health/details endpoint with component status
- Database lookup for repository URLs (no hardcoded fallbacks)

## Observability & Performance
- Added OpenTelemetry distributed tracing with goal/pulse correlation
- Performance optimization database indexes
- Comprehensive health monitoring
- Enhanced logging and error handling

## Infrastructure
- Production-ready P2P discovery (replaces mock implementation)
- Removed unused Redis configuration
- Enhanced Docker Swarm integration
- Added migration files for performance indexes

## Code Quality
- Comprehensive input validation
- Graceful error handling and failsafe fallbacks
- Backwards compatibility maintained
- Following security best practices

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code
2025-09-12 20:34:17 +10:00
parent 56ea52b743
commit 131868bdca
1740 changed files with 575904 additions and 171 deletions

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/chorus-services/whoosh/internal/config"
"github.com/rs/zerolog/log"
)
// Client represents a Gitea API client
@@ -18,6 +19,7 @@ type Client struct {
baseURL string
token string
client *http.Client
config config.GITEAConfig
}
// Issue represents a Gitea issue
@@ -84,38 +86,87 @@ func NewClient(cfg config.GITEAConfig) *Client {
return &Client{
baseURL: cfg.BaseURL,
token: token,
config: cfg,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// makeRequest makes an authenticated request to the Gitea API
// makeRequest makes an authenticated request to the Gitea API with retry logic
func (c *Client) makeRequest(ctx context.Context, method, endpoint string) (*http.Response, error) {
url := fmt.Sprintf("%s/api/v1%s", c.baseURL, endpoint)
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
if c.config.DebugURLs {
log.Debug().
Str("method", method).
Str("url", url).
Msg("Making Gitea API request")
}
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
var lastErr error
for attempt := 0; attempt <= c.config.MaxRetries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.config.RetryDelay):
// Continue with retry
}
if c.config.DebugURLs {
log.Debug().
Int("attempt", attempt).
Str("url", url).
Msg("Retrying Gitea API request")
}
}
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
lastErr = fmt.Errorf("failed to make request: %w", err)
log.Warn().
Err(err).
Str("url", url).
Int("attempt", attempt).
Msg("Gitea API request failed")
continue
}
if resp.StatusCode >= 400 {
defer resp.Body.Close()
lastErr = fmt.Errorf("API request failed with status %d", resp.StatusCode)
// Only retry on specific status codes (5xx errors, rate limiting)
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
log.Warn().
Int("status_code", resp.StatusCode).
Str("url", url).
Int("attempt", attempt).
Msg("Retryable Gitea API error")
continue
}
// Don't retry on 4xx errors (client errors)
return nil, lastErr
}
// Success
return resp, nil
}
if resp.StatusCode >= 400 {
defer resp.Body.Close()
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
}
return resp, nil
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}
// GetRepository retrieves repository information
@@ -136,7 +187,7 @@ func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Reposi
return &repository, nil
}
// GetIssues retrieves issues from a repository
// GetIssues retrieves issues from a repository with hardening features
func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueListOptions) ([]Issue, error) {
endpoint := fmt.Sprintf("/repos/%s/%s/issues", url.PathEscape(owner), url.PathEscape(repo))
@@ -145,17 +196,39 @@ func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueLi
if opts.State != "" {
params.Set("state", opts.State)
}
if opts.Labels != "" {
// EAGER_FILTER: Apply label pre-filtering at the API level for efficiency
if c.config.EagerFilter && opts.Labels != "" {
params.Set("labels", opts.Labels)
if c.config.DebugURLs {
log.Debug().
Str("labels", opts.Labels).
Bool("eager_filter", true).
Msg("Applying eager label filtering")
}
}
if opts.Page > 0 {
params.Set("page", strconv.Itoa(opts.Page))
}
if opts.Limit > 0 {
params.Set("limit", strconv.Itoa(opts.Limit))
}
if !opts.Since.IsZero() {
// FULL_RESCAN: Optionally ignore since parameter for complete rescan
if !c.config.FullRescan && !opts.Since.IsZero() {
params.Set("since", opts.Since.Format(time.RFC3339))
if c.config.DebugURLs {
log.Debug().
Time("since", opts.Since).
Msg("Using since parameter for incremental fetch")
}
} else if c.config.FullRescan {
if c.config.DebugURLs {
log.Debug().
Bool("full_rescan", true).
Msg("Performing full rescan (ignoring since parameter)")
}
}
if len(params) > 0 {
@@ -173,6 +246,18 @@ func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueLi
return nil, fmt.Errorf("failed to decode issues: %w", err)
}
// Apply in-code filtering when EAGER_FILTER is disabled
if !c.config.EagerFilter && opts.Labels != "" {
issues = c.filterIssuesByLabels(issues, opts.Labels)
if c.config.DebugURLs {
log.Debug().
Str("labels", opts.Labels).
Bool("eager_filter", false).
Int("filtered_count", len(issues)).
Msg("Applied in-code label filtering")
}
}
// Set repository information on each issue for context
for i := range issues {
issues[i].Repository = IssueRepository{
@@ -182,9 +267,55 @@ func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueLi
}
}
if c.config.DebugURLs {
log.Debug().
Str("owner", owner).
Str("repo", repo).
Int("issue_count", len(issues)).
Msg("Gitea issues fetched successfully")
}
return issues, nil
}
// filterIssuesByLabels filters issues by label names (in-code filtering when eager filter is disabled)
func (c *Client) filterIssuesByLabels(issues []Issue, labelFilter string) []Issue {
if labelFilter == "" {
return issues
}
// Parse comma-separated label names
requiredLabels := strings.Split(labelFilter, ",")
for i, label := range requiredLabels {
requiredLabels[i] = strings.TrimSpace(label)
}
var filtered []Issue
for _, issue := range issues {
hasRequiredLabels := true
for _, requiredLabel := range requiredLabels {
found := false
for _, issueLabel := range issue.Labels {
if issueLabel.Name == requiredLabel {
found = true
break
}
}
if !found {
hasRequiredLabels = false
break
}
}
if hasRequiredLabels {
filtered = append(filtered, issue)
}
}
return filtered
}
// GetIssue retrieves a specific issue
func (c *Client) GetIssue(ctx context.Context, owner, repo string, issueNumber int64) (*Issue, error) {
endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), issueNumber)