sshinmen Claude commited on
Commit
2e6b65c
·
1 Parent(s): fcf8101

Update HF Spaces deployment with latest changes

Browse files

- Refactored middleware (removed correlation, rate_limit)
- Added security and request_id middleware
- Updated error handling and logging
- Added new plans directory structure
- Various executor and translator improvements

Co-Authored-By: Claude <noreply@anthropic.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .factory/settings.json +3 -3
  2. .github/workflows/lint.yml +25 -0
  3. .github/workflows/security.yml +67 -0
  4. .gitignore +1 -0
  5. .golangci.yml +101 -0
  6. .kilocode/mcp.json +21 -0
  7. HF_SPACES_DEPLOYMENT.md +129 -0
  8. Makefile +20 -0
  9. internal/access/config_access/provider.go +1 -1
  10. internal/api/handlers/management/auth_files.go +3 -3
  11. internal/api/handlers/management/config_basic.go +1 -1
  12. internal/api/handlers/management/handler.go +1 -1
  13. internal/api/middleware/correlation.go +0 -54
  14. internal/api/middleware/error_handler.go +55 -83
  15. internal/api/middleware/middleware_test.go +31 -30
  16. internal/api/middleware/rate_limit.go +0 -148
  17. internal/api/middleware/recovery.go +10 -14
  18. internal/api/middleware/request_id.go +18 -0
  19. internal/api/middleware/security.go +56 -0
  20. internal/api/modules/amp/amp.go +0 -1
  21. internal/api/modules/amp/amp_test.go +4 -3
  22. internal/api/modules/amp/gemini_bridge_test.go +2 -2
  23. internal/api/modules/amp/proxy_test.go +1 -1
  24. internal/api/modules/amp/response_rewriter_test.go +17 -17
  25. internal/api/modules/amp/routes_test.go +10 -10
  26. internal/api/server.go +3 -0
  27. internal/api/server_test.go +1 -1
  28. internal/application/dto/config_dto.go +23 -23
  29. internal/application/dto/error.go +21 -0
  30. internal/application/mapper/config_mapper.go +1 -1
  31. internal/application/usecase/config_usecase.go +1 -1
  32. internal/auth/antigravity/auth.go +1 -1
  33. internal/auth/codex/token.go +0 -1
  34. internal/auth/gemini/gemini_auth.go +1 -1
  35. internal/auth/iflow/iflow_auth.go +2 -2
  36. internal/cmd/login.go +2 -2
  37. internal/domain/errors/errors.go +93 -8
  38. internal/domain/ports/rate_limit.go +0 -43
  39. internal/domain/ports/repositories.go +59 -59
  40. internal/domain/ports/services.go +64 -64
  41. internal/domain/services/auth_service.go +1 -1
  42. internal/domain/services/config_service.go +2 -2
  43. internal/domain/services/log_service.go +1 -1
  44. internal/domain/services/rate_limit_service.go +0 -153
  45. internal/domain/services/rate_limit_service_test.go +0 -200
  46. internal/infrastructure/logging/structured.go +8 -3
  47. internal/infrastructure/persistence/auth_repository.go +15 -15
  48. internal/infrastructure/persistence/config_repository.go +2 -2
  49. internal/infrastructure/persistence/log_index.go +13 -13
  50. internal/infrastructure/persistence/log_repository.go +5 -5
.factory/settings.json CHANGED
@@ -51,9 +51,9 @@
51
  },
52
  {
53
  "model": "gemini-3-pro-preview",
54
- "displayName": "gemini-3-pro-preview",
55
- "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
56
- "apiKey": "shin",
57
  "provider": "generic-chat-completion-api"
58
  },
59
  {
 
51
  },
52
  {
53
  "model": "gemini-3-pro-preview",
54
+ "displayName": "Gemini 3 Pro [Google]",
55
+ "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
56
+ "apiKey": "AIzaSyCc9zZOS82GvtbQQFItyHNbQwc7zzxFkN0",
57
  "provider": "generic-chat-completion-api"
58
  },
59
  {
.github/workflows/lint.yml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Lint
2
+
3
+ on:
4
+ push:
5
+ branches: [main, develop]
6
+ pull_request:
7
+ branches: [main, develop]
8
+
9
+ jobs:
10
+ golangci:
11
+ name: lint
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-go@v5
17
+ with:
18
+ go-version: '1.24'
19
+
20
+ - name: golangci-lint
21
+ uses: golangci/golangci-lint-action@v6
22
+ with:
23
+ version: latest
24
+ args: --timeout=5m
25
+ only-new-issues: true
.github/workflows/security.yml ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Security
2
+
3
+ on:
4
+ push:
5
+ branches: [main, develop]
6
+ pull_request:
7
+ branches: [main, develop]
8
+ schedule:
9
+ - cron: '0 0 * * 0' # Weekly
10
+
11
+ jobs:
12
+ gosec:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Run Gosec
18
+ uses: securego/gosec@master
19
+ with:
20
+ args: '-fmt sarif -out results.sarif ./...'
21
+
22
+ - name: Upload SARIF
23
+ uses: github/codeql-action/upload-sarif@v2
24
+ with:
25
+ sarif_file: results.sarif
26
+
27
+ govulncheck:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+
32
+ - uses: actions/setup-go@v5
33
+ with:
34
+ go-version: '1.24'
35
+
36
+ - name: Install govulncheck
37
+ run: go install golang.org/x/vuln/cmd/govulncheck@latest
38
+
39
+ - name: Run govulncheck
40
+ run: govulncheck ./...
41
+
42
+ trivy:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+
47
+ - name: Build image
48
+ run: docker build -t cliproxy:test .
49
+
50
+ - name: Run Trivy
51
+ uses: aquasecurity/trivy-action@master
52
+ with:
53
+ image-ref: 'cliproxy:test'
54
+ format: 'sarif'
55
+ output: 'trivy-results.sarif'
56
+
57
+ - name: Upload SARIF
58
+ uses: github/codeql-action/upload-sarif@v2
59
+ with:
60
+ sarif_file: trivy-results.sarif
61
+
62
+ dependency-review:
63
+ runs-on: ubuntu-latest
64
+ if: github.event_name == 'pull_request'
65
+ steps:
66
+ - uses: actions/checkout@v4
67
+ - uses: actions/dependency-review-action@v3
.gitignore CHANGED
@@ -1,6 +1,7 @@
1
  # Binaries
2
  cli-proxy-api
3
  *.exe
 
4
 
5
  # Configuration
6
  config.yaml
 
1
  # Binaries
2
  cli-proxy-api
3
  *.exe
4
+ main
5
 
6
  # Configuration
7
  config.yaml
.golangci.yml ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ run:
2
+ timeout: 5m
3
+ go: '1.24'
4
+
5
+ issues:
6
+ exclude-dirs:
7
+ - management-center
8
+ - kiro-gateway
9
+ exclude-rules:
10
+ # Exclude init() function warnings in translator registrations
11
+ - path: internal/translator/.*/init\.go
12
+ linters:
13
+ - gochecknoinits
14
+
15
+ # Exclude underscore variable warnings in test files
16
+ - path: _test\.go
17
+ linters:
18
+ - errcheck
19
+
20
+ # Exclude long function warnings in handlers (will refactor separately)
21
+ - path: internal/api/handlers/
22
+ linters:
23
+ - funlen
24
+ - gocognit
25
+
26
+ exclude-use-default: false
27
+ max-issues-per-linter: 0
28
+ max-same-issues: 0
29
+
30
+ linters:
31
+ enable:
32
+ # Default
33
+ - errcheck
34
+ - gosimple
35
+ - govet
36
+ - ineffassign
37
+ - staticcheck
38
+ - unused
39
+ # Additional
40
+ - bodyclose
41
+ - dogsled
42
+ - dupl
43
+ - exhaustive
44
+ - goconst
45
+ - gocritic
46
+ - gofmt
47
+ - goimports
48
+ - mnd
49
+ - goprintffuncname
50
+ - gosec
51
+ - misspell
52
+ - nakedret
53
+ - noctx
54
+ - nolintlint
55
+ - prealloc
56
+ - revive
57
+ - stylecheck
58
+ - unconvert
59
+ - unparam
60
+ - whitespace
61
+
62
+ linters-settings:
63
+ gocritic:
64
+ enabled-tags:
65
+ - performance
66
+ - style
67
+ - experimental
68
+ disabled-checks:
69
+ - wrapperFunc
70
+ - dupImport
71
+
72
+ revive:
73
+ rules:
74
+ - name: unexported-return
75
+ disabled: false
76
+ - name: exported
77
+ disabled: false
78
+ - name: package-comments
79
+ disabled: true
80
+
81
+ mnd:
82
+ checks:
83
+ - argument
84
+ - case
85
+ - condition
86
+ - operation
87
+ - return
88
+ ignored-numbers:
89
+ - '0'
90
+ - '1'
91
+ - '2'
92
+ - '10'
93
+ - '60'
94
+ - '100'
95
+
96
+ dupl:
97
+ threshold: 100
98
+
99
+ gosec:
100
+ excludes:
101
+ - G104 # Audit errors not checked (handled by errcheck)
.kilocode/mcp.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mcpServers": {
3
+ "context7": {
4
+ "command": "npx",
5
+ "args": [
6
+ "-y",
7
+ "@upstash/context7-mcp"
8
+ ],
9
+ "env": {
10
+ "DEFAULT_MINIMUM_TOKENS": ""
11
+ }
12
+ },
13
+ "sequentialthinking": {
14
+ "command": "npx",
15
+ "args": [
16
+ "-y",
17
+ "@modelcontextprotocol/server-sequential-thinking"
18
+ ]
19
+ }
20
+ }
21
+ }
HF_SPACES_DEPLOYMENT.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Spaces Deployment - Claude Multi-API Key Feature
2
+
3
+ ## Overview
4
+ This version of CLIProxyAPI includes support for multiple Claude API keys with round-robin load balancing, accessible through both the Management UI and API endpoints.
5
+
6
+ ## What's New
7
+
8
+ ### 1. Multiple Claude API Keys
9
+ - Add multiple API keys per Claude configuration
10
+ - Each key can have its own proxy URL
11
+ - Automatic round-robin load balancing across all keys
12
+ - Backward compatible with single-key configurations
13
+
14
+ ### 2. Updated Management UI
15
+ The management UI (`/management.html`) now includes:
16
+ - **AI Providers** → **Claude API Configuration** section
17
+ - Add/remove multiple API keys with individual proxy settings
18
+ - Visual interface for managing key entries
19
+
20
+ ### 3. API Endpoints
21
+ The following endpoints support the new `api-key-entries` field:
22
+
23
+ - `GET /api/config/claude-api-key` - Returns configurations with `api-key-entries`
24
+ - `PUT /api/config/claude-api-key` - Create/update with multiple keys
25
+ - `PATCH /api/config/claude-api-key` - Update specific fields including `api-key-entries`
26
+ - `DELETE /api/config/claude-api-key` - Delete by API key or index
27
+
28
+ ## Configuration Examples
29
+
30
+ ### Via Management UI
31
+ 1. Access `https://your-space.hf.space/management.html`
32
+ 2. Navigate to **AI Providers** → **Claude API Configuration**
33
+ 3. Click **Add Configuration**
34
+ 4. Add multiple API keys in the **API Keys** section
35
+ 5. Save
36
+
37
+ ### Via Config File (config.yaml)
38
+
39
+ ```yaml
40
+ claude-api-key:
41
+ - api-key-entries:
42
+ - api-key: "sk-ant-api03-key1..."
43
+ proxy-url: "http://proxy1:8080"
44
+ - api-key: "sk-ant-api03-key2..."
45
+ proxy-url: "http://proxy2:8080"
46
+ - api-key: "sk-ant-api03-key3..."
47
+ base-url: "https://api.anthropic.com"
48
+ priority: 10
49
+ prefix: "team-a/"
50
+ models:
51
+ - name: claude-3-5-sonnet-20241022
52
+ alias: claude-sonnet
53
+ - name: claude-3-opus-20240229
54
+ alias: claude-opus
55
+ ```
56
+
57
+ ### Via API
58
+
59
+ ```bash
60
+ # Add Claude configuration with multiple keys
61
+ curl -X PUT https://your-space.hf.space/api/config/claude-api-key \
62
+ -H "Content-Type: application/json" \
63
+ -H "Authorization: Bearer your-api-key" \
64
+ -d '[{
65
+ "api-key-entries": [
66
+ {"api-key": "sk-ant-api03-key1...", "proxy-url": "http://proxy1:8080"},
67
+ {"api-key": "sk-ant-api03-key2...", "proxy-url": "http://proxy2:8080"}
68
+ ],
69
+ "base-url": "https://api.anthropic.com",
70
+ "priority": 10,
71
+ "models": [
72
+ {"name": "claude-3-5-sonnet-20241022", "alias": "claude-sonnet"}
73
+ ]
74
+ }]'
75
+ ```
76
+
77
+ ## How It Works
78
+
79
+ 1. **Load Balancing**: Each API key becomes a separate Auth entry. The system uses round-robin selection to distribute requests across all available keys.
80
+
81
+ 2. **Per-Key Proxy**: Each API key entry can specify its own `proxy-url`. If not specified, it falls back to the key-level `proxy-url` or no proxy.
82
+
83
+ 3. **Backward Compatibility**: The old single `api-key` field still works. If `api-key-entries` is empty, the system uses the single `api-key`.
84
+
85
+ 4. **Failover**: If one API key fails (e.g., rate limited), the system automatically tries the next key in the rotation.
86
+
87
+ ## Files Modified
88
+
89
+ ### Backend (Go)
90
+ - `internal/config/config.go` - Added `ClaudeAPIKeyEntry` type and updated `ClaudeKey`
91
+ - `internal/watcher/synthesizer/config.go` - Updated `synthesizeClaudeKeys()` for multi-key support
92
+ - `internal/watcher/diff/config_diff.go` - Added diff detection for `api-key-entries`
93
+ - `internal/api/handlers/management/config_lists.go` - Updated API handlers
94
+ - `internal/managementasset/management.html` - Updated React UI build
95
+
96
+ ### Frontend (React/TypeScript)
97
+ - `management-center/src/types/provider.ts` - Added `apiKeyEntries` to types
98
+ - `management-center/src/components/providers/ClaudeSection/ClaudeModal.tsx` - New multi-key UI
99
+ - `management-center/src/components/providers/types.ts` - Updated form state types
100
+ - `management-center/src/i18n/locales/*.json` - Added translation keys
101
+
102
+ ## Testing
103
+
104
+ All tests pass for the modified components:
105
+ ```bash
106
+ go test ./internal/config/... ./internal/watcher/... ./internal/api/handlers/management/... -v
107
+ ```
108
+
109
+ ## Deployment Notes
110
+
111
+ 1. The `management.html` file is embedded in the binary at `/home/internal/managementasset/management.html`
112
+ 2. The Dockerfile copies the entire project, so the management UI is included automatically
113
+ 3. No additional environment variables are required for the multi-key feature
114
+ 4. The feature works out of the box once deployed
115
+
116
+ ## Troubleshooting
117
+
118
+ ### Management UI Not Loading
119
+ - Ensure the `management.html` file exists in the Docker image
120
+ - Check that `RemoteManagement.DisableControlPanel` is not set to `true` in config
121
+
122
+ ### API Keys Not Rotating
123
+ - Verify that `api-key-entries` is properly formatted in the config
124
+ - Check logs for any synthesis errors
125
+ - Ensure at least one API key has a non-empty value
126
+
127
+ ### Per-Key Proxy Not Working
128
+ - Verify the proxy URL format (e.g., `http://proxy.example.com:8080`)
129
+ - Check that the proxy is accessible from the HF Spaces environment
Makefile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GOPATH=$(shell go env GOPATH)
2
+ GOLANGCI_LINT=$(GOPATH)/bin/golangci-lint
3
+
4
+ .PHONY: lint lint-fix lint-install lint-precommit
5
+
6
+ lint-install:
7
+ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
8
+
9
+ lint:
10
+ $(GOLANGCI_LINT) run ./...
11
+
12
+ lint-fix:
13
+ $(GOLANGCI_LINT) run --fix ./...
14
+
15
+ # Pre-commit hook
16
+ lint-precommit:
17
+ @echo "#!/bin/sh" > .git/hooks/pre-commit
18
+ @echo '$(GOLANGCI_LINT) run --fast ./...' >> .git/hooks/pre-commit
19
+ @chmod +x .git/hooks/pre-commit
20
+ @echo "Pre-commit hook installed"
internal/access/config_access/provider.go CHANGED
@@ -105,7 +105,7 @@ func extractBearerToken(header string) string {
105
  if len(parts) != 2 {
106
  return header
107
  }
108
- if strings.ToLower(parts[0]) != "bearer" {
109
  return header
110
  }
111
  return strings.TrimSpace(parts[1])
 
105
  if len(parts) != 2 {
106
  return header
107
  }
108
+ if !strings.EqualFold(parts[0], "bearer") {
109
  return header
110
  }
111
  return strings.TrimSpace(parts[1])
internal/api/handlers/management/auth_files.go CHANGED
@@ -1103,7 +1103,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
1103
 
1104
  // Create token storage (mirrors internal/auth/gemini createTokenStorage)
1105
  authHTTPClient := conf.Client(ctx, token)
1106
- req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
1107
  if errNewRequest != nil {
1108
  log.Errorf("Could not get user info: %v", errNewRequest)
1109
  SetOAuthSessionError(state, "Could not get user info")
@@ -2079,7 +2079,7 @@ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string
2079
  }
2080
 
2081
  func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
2082
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
2083
  if errRequest != nil {
2084
  return nil, fmt.Errorf("could not create project list request: %w", errRequest)
2085
  }
@@ -2114,7 +2114,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
2114
  }
2115
  for _, service := range requiredServices {
2116
  checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
2117
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)
2118
  if errRequest != nil {
2119
  return false, fmt.Errorf("failed to create request: %w", errRequest)
2120
  }
 
1103
 
1104
  // Create token storage (mirrors internal/auth/gemini createTokenStorage)
1105
  authHTTPClient := conf.Client(ctx, token)
1106
+ req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", http.NoBody)
1107
  if errNewRequest != nil {
1108
  log.Errorf("Could not get user info: %v", errNewRequest)
1109
  SetOAuthSessionError(state, "Could not get user info")
 
2079
  }
2080
 
2081
  func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
2082
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
2083
  if errRequest != nil {
2084
  return nil, fmt.Errorf("could not create project list request: %w", errRequest)
2085
  }
 
2114
  }
2115
  for _, service := range requiredServices {
2116
  checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
2117
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, http.NoBody)
2118
  if errRequest != nil {
2119
  return false, fmt.Errorf("failed to create request: %w", errRequest)
2120
  }
internal/api/handlers/management/config_basic.go CHANGED
@@ -49,7 +49,7 @@ func (h *Handler) GetLatestVersion(c *gin.Context) {
49
  util.SetProxy(sdkCfg, client)
50
  }
51
 
52
- req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
53
  if err != nil {
54
  c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
55
  return
 
49
  util.SetProxy(sdkCfg, client)
50
  }
51
 
52
+ req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, http.NoBody)
53
  if err != nil {
54
  c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
55
  return
internal/api/handlers/management/handler.go CHANGED
@@ -205,7 +205,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
205
  var provided string
206
  if ah := c.GetHeader("Authorization"); ah != "" {
207
  parts := strings.SplitN(ah, " ", 2)
208
- if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
209
  provided = parts[1]
210
  } else {
211
  provided = ah
 
205
  var provided string
206
  if ah := c.GetHeader("Authorization"); ah != "" {
207
  parts := strings.SplitN(ah, " ", 2)
208
+ if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
209
  provided = parts[1]
210
  } else {
211
  provided = ah
internal/api/middleware/correlation.go DELETED
@@ -1,54 +0,0 @@
1
- // Package middleware provides HTTP middleware components for the CLI Proxy API server.
2
- // This file contains correlation ID middleware for request tracing.
3
- package middleware
4
-
5
- import (
6
- "github.com/gin-gonic/gin"
7
- "github.com/google/uuid"
8
- )
9
-
10
- const (
11
- // CorrelationIDHeader is the HTTP header name for correlation IDs
12
- CorrelationIDHeader = "X-Correlation-ID"
13
- // CorrelationIDContextKey is the context key for correlation IDs
14
- CorrelationIDContextKey = "correlation_id"
15
- )
16
-
17
- // CorrelationIDMiddleware creates a Gin middleware that ensures every request
18
- // has a correlation ID for distributed tracing. It checks for an existing ID
19
- // in the request headers and generates a new one if not present.
20
- func CorrelationIDMiddleware() gin.HandlerFunc {
21
- return func(c *gin.Context) {
22
- // Check for existing correlation ID in header
23
- correlationID := c.GetHeader(CorrelationIDHeader)
24
-
25
- // Generate new ID if not present
26
- if correlationID == "" {
27
- correlationID = generateCorrelationID()
28
- }
29
-
30
- // Store in context
31
- c.Set(CorrelationIDContextKey, correlationID)
32
-
33
- // Add to response headers
34
- c.Header(CorrelationIDHeader, correlationID)
35
-
36
- c.Next()
37
- }
38
- }
39
-
40
- // GetCorrelationID retrieves the correlation ID from the Gin context.
41
- // Returns empty string if no correlation ID is found.
42
- func GetCorrelationID(c *gin.Context) string {
43
- if id, exists := c.Get(CorrelationIDContextKey); exists {
44
- if str, ok := id.(string); ok {
45
- return str
46
- }
47
- }
48
- return ""
49
- }
50
-
51
- // generateCorrelationID generates a new unique correlation ID.
52
- func generateCorrelationID() string {
53
- return uuid.New().String()
54
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/api/middleware/error_handler.go CHANGED
@@ -1,108 +1,80 @@
1
- // Package middleware provides HTTP middleware components for the CLI Proxy API server.
2
- // This file contains centralized error handling middleware for consistent API responses.
3
  package middleware
4
 
5
  import (
 
6
  "net/http"
7
 
8
  "github.com/gin-gonic/gin"
9
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors"
10
- )
11
-
12
- // ErrorResponse represents a standardized error response
13
- type ErrorResponse struct {
14
- Success bool `json:"success"`
15
- Error *APIError `json:"error,omitempty"`
16
- RequestID string `json:"request_id,omitempty"`
17
- }
18
 
19
- // APIError represents error details
20
- type APIError struct {
21
- Code string `json:"code"`
22
- Message string `json:"message"`
23
- Field string `json:"field,omitempty"`
24
- }
25
 
26
- // ErrorHandlerMiddleware creates a Gin middleware that provides centralized
27
- // error handling. It catches errors from the context and formats them into
28
- // standardized error responses.
29
- func ErrorHandlerMiddleware() gin.HandlerFunc {
30
  return func(c *gin.Context) {
31
  c.Next()
32
 
33
  // Check if there are any errors
34
- if len(c.Errors) > 0 {
35
- err := c.Errors.Last()
36
- handleError(c, err)
37
  }
38
- }
39
- }
40
 
41
- // handleError converts various error types to standardized HTTP responses
42
- func handleError(c *gin.Context, err *gin.Error) {
43
- requestID := GetCorrelationID(c)
44
-
45
- // Check if it's a domain error
46
- if domainErr, ok := err.Err.(*errors.DomainError); ok {
47
- statusCode := domainErr.HTTPStatusCode()
48
- apiErr := &APIError{
49
- Code: string(domainErr.Code),
50
- Message: domainErr.Message,
51
- }
52
- // Extract field from details if available
53
- if domainErr.Details != nil {
54
- if field, ok := domainErr.Details["field"].(string); ok {
55
- apiErr.Field = field
56
- }
57
  }
58
- response := ErrorResponse{
59
- Success: false,
60
- RequestID: requestID,
61
- Error: apiErr,
 
 
62
  }
63
- c.JSON(statusCode, response)
64
- return
65
- }
66
 
67
- // Handle common HTTP status errors
68
- switch err.Type {
69
- case gin.ErrorTypeBind:
70
- c.JSON(http.StatusBadRequest, ErrorResponse{
71
- Success: false,
72
- RequestID: requestID,
73
- Error: &APIError{
74
- Code: "INVALID_INPUT",
75
- Message: "Invalid request format: " + err.Err.Error(),
76
- },
77
- })
78
- case gin.ErrorTypeRender:
79
- c.JSON(http.StatusInternalServerError, ErrorResponse{
80
- Success: false,
81
- RequestID: requestID,
82
- Error: &APIError{
83
- Code: "RENDER_ERROR",
84
- Message: "Failed to render response",
85
- },
86
- })
87
- default:
88
- c.JSON(http.StatusInternalServerError, ErrorResponse{
89
  Success: false,
 
90
  RequestID: requestID,
91
- Error: &APIError{
92
- Code: "INTERNAL_ERROR",
93
- Message: "An internal error occurred",
94
- },
95
  })
96
  }
97
  }
98
 
99
- // AbortWithDomainError aborts the request with a domain error
100
- func AbortWithDomainError(c *gin.Context, err *errors.DomainError) {
101
- c.Error(err)
102
- c.Abort()
103
- }
 
 
 
 
 
 
 
 
 
104
 
105
- // RespondWithError adds an error to the context without aborting
106
- func RespondWithError(c *gin.Context, err error) {
107
- c.Error(err)
 
 
 
 
 
 
 
108
  }
 
 
 
1
  package middleware
2
 
3
  import (
4
+ "errors"
5
  "net/http"
6
 
7
  "github.com/gin-gonic/gin"
8
+ "github.com/sirupsen/logrus"
 
 
 
 
 
 
 
 
9
 
10
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/application/dto"
11
+ domainerrors "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors"
12
+ )
 
 
 
13
 
14
+ // ErrorHandler returns a middleware that handles domain errors
15
+ func ErrorHandler(logger *logrus.Logger) gin.HandlerFunc {
 
 
16
  return func(c *gin.Context) {
17
  c.Next()
18
 
19
  // Check if there are any errors
20
+ if len(c.Errors) == 0 {
21
+ return
 
22
  }
 
 
23
 
24
+ // Get the last error
25
+ err := c.Errors.Last().Err
26
+ requestID := c.GetString("request_id")
27
+
28
+ // Handle domain errors
29
+ if domainErr, ok := err.(*domainerrors.DomainError); ok {
30
+ handleDomainError(c, domainErr, requestID, logger)
31
+ return
 
 
 
 
 
 
 
 
32
  }
33
+
34
+ // Handle wrapped domain errors
35
+ var domainErr *domainerrors.DomainError
36
+ if errors.As(err, &domainErr) {
37
+ handleDomainError(c, domainErr, requestID, logger)
38
+ return
39
  }
 
 
 
40
 
41
+ // Unknown error - log full details but return generic message
42
+ logger.WithError(err).
43
+ WithField("request_id", requestID).
44
+ WithField("path", c.Request.URL.Path).
45
+ Error("Unhandled error")
46
+
47
+ c.JSON(http.StatusInternalServerError, dto.InternalErrorResponse{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  Success: false,
49
+ Error: "An unexpected error occurred",
50
  RequestID: requestID,
 
 
 
 
51
  })
52
  }
53
  }
54
 
55
+ func handleDomainError(c *gin.Context, err *domainerrors.DomainError, requestID string, logger *logrus.Logger) {
56
+ // Log with appropriate level based on status code
57
+ entry := logger.WithError(err).
58
+ WithField("request_id", requestID).
59
+ WithField("error_code", err.Code).
60
+ WithField("path", c.Request.URL.Path)
61
+
62
+ statusCode := err.HTTPStatusCode()
63
+
64
+ if statusCode >= 500 {
65
+ entry.Error("Server error")
66
+ } else if statusCode >= 400 {
67
+ entry.Warn("Client error")
68
+ }
69
 
70
+ // Return structured error response
71
+ c.JSON(statusCode, dto.ErrorResponse{
72
+ Success: false,
73
+ Error: dto.ErrorInfo{
74
+ Code: string(err.Code),
75
+ Message: err.Message,
76
+ Details: err.Details,
77
+ },
78
+ RequestID: requestID,
79
+ })
80
  }
internal/api/middleware/middleware_test.go CHANGED
@@ -9,6 +9,7 @@ import (
9
  "testing"
10
 
11
  "github.com/gin-gonic/gin"
 
12
  "github.com/stretchr/testify/assert"
13
  )
14
 
@@ -17,42 +18,42 @@ func setupTestRouter() *gin.Engine {
17
  return gin.New()
18
  }
19
 
20
- func TestCorrelationIDMiddleware(t *testing.T) {
21
  router := setupTestRouter()
22
- router.Use(CorrelationIDMiddleware())
23
  router.GET("/test", func(c *gin.Context) {
24
- id := GetCorrelationID(c)
25
- c.JSON(200, gin.H{"correlation_id": id})
26
  })
27
 
28
- t.Run("generates correlation ID when not provided", func(t *testing.T) {
29
  w := httptest.NewRecorder()
30
- req, _ := http.NewRequest("GET", "/test", nil)
31
  router.ServeHTTP(w, req)
32
 
33
  assert.Equal(t, 200, w.Code)
34
- // Check that response contains a correlation ID
35
- assert.Contains(t, w.Body.String(), "correlation_id")
36
- // Check that response header contains the correlation ID
37
- assert.NotEmpty(t, w.Header().Get(CorrelationIDHeader))
38
  })
39
 
40
- t.Run("uses existing correlation ID from header", func(t *testing.T) {
41
  w := httptest.NewRecorder()
42
- req, _ := http.NewRequest("GET", "/test", nil)
43
- req.Header.Set(CorrelationIDHeader, "test-correlation-id-123")
44
  router.ServeHTTP(w, req)
45
 
46
  assert.Equal(t, 200, w.Code)
47
- assert.Contains(t, w.Body.String(), "test-correlation-id-123")
48
- assert.Equal(t, "test-correlation-id-123", w.Header().Get(CorrelationIDHeader))
49
  })
50
  }
51
 
52
- func TestErrorHandlerMiddleware(t *testing.T) {
53
  router := setupTestRouter()
54
- router.Use(CorrelationIDMiddleware())
55
- router.Use(ErrorHandlerMiddleware())
56
 
57
  router.GET("/error", func(c *gin.Context) {
58
  c.Error(errors.New("test error"))
@@ -65,16 +66,16 @@ func TestErrorHandlerMiddleware(t *testing.T) {
65
 
66
  t.Run("handles errors gracefully", func(t *testing.T) {
67
  w := httptest.NewRecorder()
68
- req, _ := http.NewRequest("GET", "/error", nil)
69
  router.ServeHTTP(w, req)
70
 
71
  assert.Equal(t, 500, w.Code)
72
- assert.Contains(t, w.Body.String(), "INTERNAL_ERROR")
73
  })
74
 
75
  t.Run("passes through successful requests", func(t *testing.T) {
76
  w := httptest.NewRecorder()
77
- req, _ := http.NewRequest("GET", "/success", nil)
78
  router.ServeHTTP(w, req)
79
 
80
  assert.Equal(t, 200, w.Code)
@@ -85,8 +86,8 @@ func TestErrorHandlerMiddleware(t *testing.T) {
85
  func TestRecoveryMiddleware(t *testing.T) {
86
  router := setupTestRouter()
87
  router.Use(RecoveryMiddleware(nil))
88
- router.Use(CorrelationIDMiddleware())
89
- router.Use(ErrorHandlerMiddleware())
90
 
91
  router.GET("/panic", func(c *gin.Context) {
92
  panic("test panic")
@@ -98,16 +99,16 @@ func TestRecoveryMiddleware(t *testing.T) {
98
 
99
  t.Run("recovers from panic", func(t *testing.T) {
100
  w := httptest.NewRecorder()
101
- req, _ := http.NewRequest("GET", "/panic", nil)
102
  router.ServeHTTP(w, req)
103
 
104
  assert.Equal(t, 500, w.Code)
105
- assert.Contains(t, w.Body.String(), "INTERNAL_ERROR")
106
  })
107
 
108
  t.Run("normal requests work after panic recovery", func(t *testing.T) {
109
  w := httptest.NewRecorder()
110
- req, _ := http.NewRequest("GET", "/normal", nil)
111
  router.ServeHTTP(w, req)
112
 
113
  assert.Equal(t, 200, w.Code)
@@ -117,8 +118,8 @@ func TestRecoveryMiddleware(t *testing.T) {
117
 
118
  func TestSafeHandler(t *testing.T) {
119
  router := setupTestRouter()
120
- router.Use(CorrelationIDMiddleware())
121
- router.Use(ErrorHandlerMiddleware())
122
 
123
  router.GET("/safe-panic", SafeHandler(func(c *gin.Context) {
124
  panic("safe handler panic")
@@ -126,10 +127,10 @@ func TestSafeHandler(t *testing.T) {
126
 
127
  t.Run("safe handler recovers from panic", func(t *testing.T) {
128
  w := httptest.NewRecorder()
129
- req, _ := http.NewRequest("GET", "/safe-panic", nil)
130
  router.ServeHTTP(w, req)
131
 
132
  assert.Equal(t, 500, w.Code)
133
- assert.Contains(t, w.Body.String(), "INTERNAL_ERROR")
134
  })
135
  }
 
9
  "testing"
10
 
11
  "github.com/gin-gonic/gin"
12
+ "github.com/sirupsen/logrus"
13
  "github.com/stretchr/testify/assert"
14
  )
15
 
 
18
  return gin.New()
19
  }
20
 
21
+ func TestRequestIDMiddleware(t *testing.T) {
22
  router := setupTestRouter()
23
+ router.Use(RequestID())
24
  router.GET("/test", func(c *gin.Context) {
25
+ id := c.GetString("request_id")
26
+ c.JSON(200, gin.H{"request_id": id})
27
  })
28
 
29
+ t.Run("generates request ID when not provided", func(t *testing.T) {
30
  w := httptest.NewRecorder()
31
+ req, _ := http.NewRequest("GET", "/test", http.NoBody)
32
  router.ServeHTTP(w, req)
33
 
34
  assert.Equal(t, 200, w.Code)
35
+ // Check that response contains a request ID
36
+ assert.Contains(t, w.Body.String(), "request_id")
37
+ // Check that response header contains the request ID
38
+ assert.NotEmpty(t, w.Header().Get("X-Request-ID"))
39
  })
40
 
41
+ t.Run("uses existing request ID from header", func(t *testing.T) {
42
  w := httptest.NewRecorder()
43
+ req, _ := http.NewRequest("GET", "/test", http.NoBody)
44
+ req.Header.Set("X-Request-ID", "test-request-id-123")
45
  router.ServeHTTP(w, req)
46
 
47
  assert.Equal(t, 200, w.Code)
48
+ assert.Contains(t, w.Body.String(), "test-request-id-123")
49
+ assert.Equal(t, "test-request-id-123", w.Header().Get("X-Request-ID"))
50
  })
51
  }
52
 
53
+ func TestErrorHandler(t *testing.T) {
54
  router := setupTestRouter()
55
+ router.Use(RequestID())
56
+ router.Use(ErrorHandler(logrus.New()))
57
 
58
  router.GET("/error", func(c *gin.Context) {
59
  c.Error(errors.New("test error"))
 
66
 
67
  t.Run("handles errors gracefully", func(t *testing.T) {
68
  w := httptest.NewRecorder()
69
+ req, _ := http.NewRequest("GET", "/error", http.NoBody)
70
  router.ServeHTTP(w, req)
71
 
72
  assert.Equal(t, 500, w.Code)
73
+ assert.Contains(t, w.Body.String(), "An unexpected error occurred")
74
  })
75
 
76
  t.Run("passes through successful requests", func(t *testing.T) {
77
  w := httptest.NewRecorder()
78
+ req, _ := http.NewRequest("GET", "/success", http.NoBody)
79
  router.ServeHTTP(w, req)
80
 
81
  assert.Equal(t, 200, w.Code)
 
86
  func TestRecoveryMiddleware(t *testing.T) {
87
  router := setupTestRouter()
88
  router.Use(RecoveryMiddleware(nil))
89
+ router.Use(RequestID())
90
+ router.Use(ErrorHandler(logrus.New()))
91
 
92
  router.GET("/panic", func(c *gin.Context) {
93
  panic("test panic")
 
99
 
100
  t.Run("recovers from panic", func(t *testing.T) {
101
  w := httptest.NewRecorder()
102
+ req, _ := http.NewRequest("GET", "/panic", http.NoBody)
103
  router.ServeHTTP(w, req)
104
 
105
  assert.Equal(t, 500, w.Code)
106
+ assert.Contains(t, w.Body.String(), "An internal server error occurred")
107
  })
108
 
109
  t.Run("normal requests work after panic recovery", func(t *testing.T) {
110
  w := httptest.NewRecorder()
111
+ req, _ := http.NewRequest("GET", "/normal", http.NoBody)
112
  router.ServeHTTP(w, req)
113
 
114
  assert.Equal(t, 200, w.Code)
 
118
 
119
  func TestSafeHandler(t *testing.T) {
120
  router := setupTestRouter()
121
+ router.Use(RequestID())
122
+ router.Use(ErrorHandler(logrus.New()))
123
 
124
  router.GET("/safe-panic", SafeHandler(func(c *gin.Context) {
125
  panic("safe handler panic")
 
127
 
128
  t.Run("safe handler recovers from panic", func(t *testing.T) {
129
  w := httptest.NewRecorder()
130
+ req, _ := http.NewRequest("GET", "/safe-panic", http.NoBody)
131
  router.ServeHTTP(w, req)
132
 
133
  assert.Equal(t, 500, w.Code)
134
+ assert.Contains(t, w.Body.String(), "An internal server error occurred")
135
  })
136
  }
internal/api/middleware/rate_limit.go DELETED
@@ -1,148 +0,0 @@
1
- // Package middleware provides HTTP middleware components for the CLI Proxy API server.
2
- // This file contains the rate limiting middleware that integrates with the domain
3
- // rate limiting service to enforce request limits and block abusive clients.
4
- package middleware
5
-
6
- import (
7
- "net/http"
8
- "time"
9
-
10
- "github.com/gin-gonic/gin"
11
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
12
- )
13
-
14
- // RateLimitMiddleware creates a Gin middleware that enforces rate limiting
15
- // using the provided RateLimitService. It checks if the client IP is blocked
16
- // and records failed authentication attempts.
17
- type RateLimitMiddleware struct {
18
- service ports.RateLimitService
19
- }
20
-
21
- // NewRateLimitMiddleware creates a new rate limiting middleware instance.
22
- func NewRateLimitMiddleware(service ports.RateLimitService) *RateLimitMiddleware {
23
- return &RateLimitMiddleware{
24
- service: service,
25
- }
26
- }
27
-
28
- // Middleware returns the Gin middleware function that enforces rate limiting.
29
- // It should be used for management endpoints that require authentication.
30
- func (m *RateLimitMiddleware) Middleware() gin.HandlerFunc {
31
- return func(c *gin.Context) {
32
- if m.service == nil {
33
- c.Next()
34
- return
35
- }
36
-
37
- clientIP := c.ClientIP()
38
- ctx := c.Request.Context()
39
-
40
- // Check if client is blocked
41
- blocked, blockedUntil, err := m.service.IsBlocked(ctx, clientIP)
42
- if err != nil {
43
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
44
- "error": "rate limit check failed",
45
- })
46
- return
47
- }
48
- if blocked {
49
- remaining := time.Until(blockedUntil)
50
- if remaining < 0 {
51
- remaining = 0
52
- }
53
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
54
- "error": "IP banned due to too many failed attempts",
55
- "retry_after": remaining.String(),
56
- })
57
- return
58
- }
59
-
60
- // Check if request is allowed
61
- allowed, err := m.service.Allow(ctx, clientIP)
62
- if err != nil {
63
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
64
- "error": "rate limit check failed",
65
- })
66
- return
67
- }
68
- if !allowed {
69
- c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
70
- "error": "rate limit exceeded",
71
- })
72
- return
73
- }
74
-
75
- c.Next()
76
- }
77
- }
78
-
79
- // AuthMiddleware wraps the rate limiting middleware with authentication logic.
80
- // It records failed attempts when authentication fails.
81
- type AuthMiddleware struct {
82
- rateLimitService ports.RateLimitService
83
- getSecretHash func() string
84
- getEnvSecret func() string
85
- allowRemote func() bool
86
- }
87
-
88
- // NewAuthMiddleware creates a new authentication middleware with rate limiting.
89
- func NewAuthMiddleware(
90
- service ports.RateLimitService,
91
- getSecretHash func() string,
92
- getEnvSecret func() string,
93
- allowRemote func() bool,
94
- ) *AuthMiddleware {
95
- return &AuthMiddleware{
96
- rateLimitService: service,
97
- getSecretHash: getSecretHash,
98
- getEnvSecret: getEnvSecret,
99
- allowRemote: allowRemote,
100
- }
101
- }
102
-
103
- // OnAuthFailure should be called when authentication fails to record the attempt.
104
- func (m *AuthMiddleware) OnAuthFailure(c *gin.Context) {
105
- if m.rateLimitService == nil {
106
- return
107
- }
108
-
109
- clientIP := c.ClientIP()
110
- ctx := c.Request.Context()
111
-
112
- m.rateLimitService.RecordAttempt(ctx, clientIP, false)
113
- }
114
-
115
- // OnAuthSuccess should be called when authentication succeeds to reset attempts.
116
- func (m *AuthMiddleware) OnAuthSuccess(c *gin.Context) {
117
- if m.rateLimitService == nil {
118
- return
119
- }
120
-
121
- clientIP := c.ClientIP()
122
- ctx := c.Request.Context()
123
-
124
- m.rateLimitService.RecordAttempt(ctx, clientIP, true)
125
- }
126
-
127
- // GetRetryAfter returns the duration until the client can retry after being blocked.
128
- func (m *AuthMiddleware) GetRetryAfter(c *gin.Context) time.Duration {
129
- if m.rateLimitService == nil {
130
- return 0
131
- }
132
-
133
- clientIP := c.ClientIP()
134
- ctx := c.Request.Context()
135
-
136
- blocked, blockedUntil, err := m.rateLimitService.IsBlocked(ctx, clientIP)
137
- if err != nil {
138
- return 0
139
- }
140
- if blocked {
141
- remaining := time.Until(blockedUntil)
142
- if remaining > 0 {
143
- return remaining
144
- }
145
- }
146
-
147
- return 0
148
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/api/middleware/recovery.go CHANGED
@@ -9,6 +9,8 @@ import (
9
 
10
  "github.com/gin-gonic/gin"
11
  "github.com/sirupsen/logrus"
 
 
12
  )
13
 
14
  // RecoveryMiddleware creates a Gin middleware that recovers from panics
@@ -18,6 +20,7 @@ func RecoveryMiddleware(logger *logrus.Logger) gin.HandlerFunc {
18
  return func(c *gin.Context) {
19
  defer func() {
20
  if r := recover(); r != nil {
 
21
  // Log the panic with stack trace if logger is available
22
  if logger != nil {
23
  logger.WithFields(logrus.Fields{
@@ -26,19 +29,15 @@ func RecoveryMiddleware(logger *logrus.Logger) gin.HandlerFunc {
26
  "path": c.Request.URL.Path,
27
  "method": c.Request.Method,
28
  "client_ip": c.ClientIP(),
29
- "request_id": GetCorrelationID(c),
30
  }).Error("Panic recovered in HTTP handler")
31
  }
32
 
33
  // Return graceful error response
34
- requestID := GetCorrelationID(c)
35
- c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{
36
  Success: false,
37
  RequestID: requestID,
38
- Error: &APIError{
39
- Code: "INTERNAL_ERROR",
40
- Message: "An internal server error occurred",
41
- },
42
  })
43
  }
44
  }()
@@ -53,22 +52,19 @@ func SafeHandler(handler gin.HandlerFunc) gin.HandlerFunc {
53
  return func(c *gin.Context) {
54
  defer func() {
55
  if r := recover(); r != nil {
 
56
  // Log the panic
57
  logrus.WithFields(logrus.Fields{
58
  "panic": fmt.Sprintf("%v", r),
59
  "path": c.Request.URL.Path,
60
  "method": c.Request.Method,
61
- "request_id": GetCorrelationID(c),
62
  }).Error("Panic recovered in handler")
63
 
64
- requestID := GetCorrelationID(c)
65
- c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{
66
  Success: false,
67
  RequestID: requestID,
68
- Error: &APIError{
69
- Code: "INTERNAL_ERROR",
70
- Message: "An internal server error occurred",
71
- },
72
  })
73
  }
74
  }()
 
9
 
10
  "github.com/gin-gonic/gin"
11
  "github.com/sirupsen/logrus"
12
+
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/application/dto"
14
  )
15
 
16
  // RecoveryMiddleware creates a Gin middleware that recovers from panics
 
20
  return func(c *gin.Context) {
21
  defer func() {
22
  if r := recover(); r != nil {
23
+ requestID := c.GetString("request_id")
24
  // Log the panic with stack trace if logger is available
25
  if logger != nil {
26
  logger.WithFields(logrus.Fields{
 
29
  "path": c.Request.URL.Path,
30
  "method": c.Request.Method,
31
  "client_ip": c.ClientIP(),
32
+ "request_id": requestID,
33
  }).Error("Panic recovered in HTTP handler")
34
  }
35
 
36
  // Return graceful error response
37
+ c.AbortWithStatusJSON(http.StatusInternalServerError, dto.InternalErrorResponse{
 
38
  Success: false,
39
  RequestID: requestID,
40
+ Error: "An internal server error occurred",
 
 
 
41
  })
42
  }
43
  }()
 
52
  return func(c *gin.Context) {
53
  defer func() {
54
  if r := recover(); r != nil {
55
+ requestID := c.GetString("request_id")
56
  // Log the panic
57
  logrus.WithFields(logrus.Fields{
58
  "panic": fmt.Sprintf("%v", r),
59
  "path": c.Request.URL.Path,
60
  "method": c.Request.Method,
61
+ "request_id": requestID,
62
  }).Error("Panic recovered in handler")
63
 
64
+ c.AbortWithStatusJSON(http.StatusInternalServerError, dto.InternalErrorResponse{
 
65
  Success: false,
66
  RequestID: requestID,
67
+ Error: "An internal server error occurred",
 
 
 
68
  })
69
  }
70
  }()
internal/api/middleware/request_id.go ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package middleware
2
+
3
+ import (
4
+ "github.com/gin-gonic/gin"
5
+ "github.com/google/uuid"
6
+ )
7
+
8
+ func RequestID() gin.HandlerFunc {
9
+ return func(c *gin.Context) {
10
+ requestID := c.GetHeader("X-Request-ID")
11
+ if requestID == "" {
12
+ requestID = uuid.New().String()
13
+ }
14
+ c.Set("request_id", requestID)
15
+ c.Header("X-Request-ID", requestID)
16
+ c.Next()
17
+ }
18
+ }
internal/api/middleware/security.go ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package middleware
2
+
3
+ import (
4
+ "strings"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ func SecurityHeaders() gin.HandlerFunc {
10
+ return func(c *gin.Context) {
11
+ // Prevent clickjacking
12
+ c.Header("X-Frame-Options", "DENY")
13
+
14
+ // Prevent MIME type sniffing
15
+ c.Header("X-Content-Type-Options", "nosniff")
16
+
17
+ // XSS Protection
18
+ c.Header("X-XSS-Protection", "1; mode=block")
19
+
20
+ // Referrer Policy
21
+ c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
22
+
23
+ // Permissions Policy
24
+ c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
25
+
26
+ // HSTS (HTTPS only)
27
+ c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
28
+
29
+ // CSP for API (restrictive)
30
+ if !strings.HasPrefix(c.Request.URL.Path, "/management") {
31
+ c.Header("Content-Security-Policy", "default-src 'none'")
32
+ }
33
+
34
+ c.Next()
35
+ }
36
+ }
37
+
38
+ // CSP for Management UI
39
+ func ManagementCSP() gin.HandlerFunc {
40
+ return func(c *gin.Context) {
41
+ csp := strings.Join([]string{
42
+ "default-src 'self'",
43
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
44
+ "style-src 'self' 'unsafe-inline'",
45
+ "img-src 'self' data: https:",
46
+ "font-src 'self'",
47
+ "connect-src 'self'",
48
+ "frame-ancestors 'none'",
49
+ "base-uri 'self'",
50
+ "form-action 'self'",
51
+ }, "; ")
52
+
53
+ c.Header("Content-Security-Policy", csp)
54
+ c.Next()
55
+ }
56
+ }
internal/api/modules/amp/amp.go CHANGED
@@ -246,7 +246,6 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
246
  }
247
  }
248
  }
249
-
250
  }
251
 
252
  // Store current config for next comparison
 
246
  }
247
  }
248
  }
 
249
  }
250
 
251
  // Store current config for next comparison
internal/api/modules/amp/amp_test.go CHANGED
@@ -2,6 +2,7 @@ package amp
2
 
3
  import (
4
  "context"
 
5
  "net/http/httptest"
6
  "os"
7
  "path/filepath"
@@ -106,7 +107,7 @@ func TestAmpModule_Register_WithoutUpstream(t *testing.T) {
106
  }
107
 
108
  // But provider aliases should still be registered
109
- req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
110
  w := httptest.NewRecorder()
111
  r.ServeHTTP(w, req)
112
 
@@ -226,7 +227,7 @@ func TestAmpModule_AuthMiddleware_Fallback(t *testing.T) {
226
  c.String(200, "ok")
227
  })
228
 
229
- req := httptest.NewRequest("GET", "/test", nil)
230
  w := httptest.NewRecorder()
231
  r.ServeHTTP(w, req)
232
 
@@ -302,7 +303,7 @@ func TestAmpModule_ProviderAliasesAlwaysRegistered(t *testing.T) {
302
  }
303
 
304
  // Provider aliases should always be available
305
- req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
306
  w := httptest.NewRecorder()
307
  r.ServeHTTP(w, req)
308
 
 
2
 
3
  import (
4
  "context"
5
+ "net/http"
6
  "net/http/httptest"
7
  "os"
8
  "path/filepath"
 
107
  }
108
 
109
  // But provider aliases should still be registered
110
+ req := httptest.NewRequest("GET", "/api/provider/openai/models", http.NoBody)
111
  w := httptest.NewRecorder()
112
  r.ServeHTTP(w, req)
113
 
 
227
  c.String(200, "ok")
228
  })
229
 
230
+ req := httptest.NewRequest("GET", "/test", http.NoBody)
231
  w := httptest.NewRecorder()
232
  r.ServeHTTP(w, req)
233
 
 
303
  }
304
 
305
  // Provider aliases should always be available
306
+ req := httptest.NewRequest("GET", "/api/provider/openai/models", http.NoBody)
307
  w := httptest.NewRecorder()
308
  r.ServeHTTP(w, req)
309
 
internal/api/modules/amp/gemini_bridge_test.go CHANGED
@@ -58,7 +58,7 @@ func TestCreateGeminiBridgeHandler_ActionParameterExtraction(t *testing.T) {
58
  }
59
  r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
60
 
61
- req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1"+tt.path, nil)
62
  w := httptest.NewRecorder()
63
  r.ServeHTTP(w, req)
64
 
@@ -83,7 +83,7 @@ func TestCreateGeminiBridgeHandler_InvalidPath(t *testing.T) {
83
  r := gin.New()
84
  r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
85
 
86
- req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1/invalid/path", nil)
87
  w := httptest.NewRecorder()
88
  r.ServeHTTP(w, req)
89
 
 
58
  }
59
  r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
60
 
61
+ req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1"+tt.path, http.NoBody)
62
  w := httptest.NewRecorder()
63
  r.ServeHTTP(w, req)
64
 
 
83
  r := gin.New()
84
  r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
85
 
86
+ req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1/invalid/path", http.NoBody)
87
  w := httptest.NewRecorder()
88
  r.ServeHTTP(w, req)
89
 
internal/api/modules/amp/proxy_test.go CHANGED
@@ -335,7 +335,7 @@ func TestReverseProxy_StripsClientCredentialsFromHeadersAndQuery(t *testing.T) {
335
  }))
336
  defer srv.Close()
337
 
338
- req, err := http.NewRequest(http.MethodGet, srv.URL+"/test?key=client-key&key=keep&auth_token=client-key&foo=bar", nil)
339
  if err != nil {
340
  t.Fatal(err)
341
  }
 
335
  }))
336
  defer srv.Close()
337
 
338
+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/test?key=client-key&key=keep&auth_token=client-key&foo=bar", http.NoBody)
339
  if err != nil {
340
  t.Fatal(err)
341
  }
internal/api/modules/amp/response_rewriter_test.go CHANGED
@@ -106,7 +106,7 @@ func TestResponseRewriter_SplitJSONTokensAcrossChunks(t *testing.T) {
106
 
107
  // Simulate streaming response
108
  for _, chunk := range tt.chunks {
109
- rw.Write([]byte(chunk))
110
  }
111
  rw.Flush()
112
 
@@ -176,7 +176,7 @@ func TestResponseRewriter_InvalidMalformedJSON(t *testing.T) {
176
  rw := NewResponseRewriter(mock, tt.originalModel)
177
 
178
  // For non-streaming, we buffer and flush
179
- rw.Write([]byte(tt.input))
180
  rw.Flush()
181
 
182
  result := mock.body.String()
@@ -256,7 +256,7 @@ func TestResponseRewriter_MixedContentTypes(t *testing.T) {
256
  rw := NewResponseRewriter(mock, tt.originalModel)
257
 
258
  // First write triggers streaming detection
259
- rw.Write([]byte(tt.input))
260
 
261
  if rw.isStreaming != tt.isStreaming {
262
  t.Errorf("isStreaming = %v, want %v", rw.isStreaming, tt.isStreaming)
@@ -320,7 +320,7 @@ func TestResponseRewriter_FallbackStrategy(t *testing.T) {
320
 
321
  // Write all chunks
322
  for _, chunk := range tt.input {
323
- _, err := rw.Write([]byte(chunk))
324
  if err != nil && !tt.expectError {
325
  t.Errorf("unexpected error: %v", err)
326
  }
@@ -403,7 +403,7 @@ func TestResponseRewriter_SSEEdgeCases(t *testing.T) {
403
  rw := NewResponseRewriter(mock, tt.originalModel)
404
 
405
  for _, chunk := range tt.chunks {
406
- rw.Write([]byte(chunk))
407
  }
408
 
409
  result := mock.body.String()
@@ -456,7 +456,7 @@ func TestResponseRewriter_ThinkingBlockSuppression(t *testing.T) {
456
  mock := newMockResponseWriter()
457
  rw := NewResponseRewriter(mock, tt.originalModel)
458
 
459
- rw.Write([]byte(tt.input))
460
  rw.Flush()
461
 
462
  result := mock.body.String()
@@ -495,7 +495,7 @@ func TestResponseRewriter_ConcurrentWrites(t *testing.T) {
495
  }
496
 
497
  for _, chunk := range chunks {
498
- rw.Write([]byte(chunk))
499
  }
500
  rw.Flush()
501
 
@@ -515,7 +515,7 @@ func TestResponseRewriter_LargeResponse(t *testing.T) {
515
  largeContent := strings.Repeat("a", 100000)
516
  input := `{"model": "mapped-model", "content": "` + largeContent + `"}`
517
 
518
- rw.Write([]byte(input))
519
  rw.Flush()
520
 
521
  result := mock.body.String()
@@ -594,27 +594,27 @@ func TestNewResponseRewriter(t *testing.T) {
594
  // TestResponseRewriter_FlushBehavior tests Flush method behavior
595
  func TestResponseRewriter_FlushBehavior(t *testing.T) {
596
  tests := []struct {
597
- name string
598
  isStreaming bool
599
- writeData string
600
  expectFlush bool
601
  }{
602
  {
603
- name: "flush non-streaming",
604
  isStreaming: false,
605
- writeData: `{"model": "mapped"}`,
606
  expectFlush: true,
607
  },
608
  {
609
- name: "flush streaming",
610
  isStreaming: true,
611
- writeData: `data: {"model": "mapped"}`,
612
  expectFlush: true,
613
  },
614
  {
615
- name: "flush empty body",
616
  isStreaming: false,
617
- writeData: "",
618
  expectFlush: false,
619
  },
620
  }
@@ -628,7 +628,7 @@ func TestResponseRewriter_FlushBehavior(t *testing.T) {
628
  rw := NewResponseRewriter(mock, "original")
629
 
630
  if tt.writeData != "" {
631
- rw.Write([]byte(tt.writeData))
632
  }
633
 
634
  // Reset flushed flag
 
106
 
107
  // Simulate streaming response
108
  for _, chunk := range tt.chunks {
109
+ rw.WriteString(chunk)
110
  }
111
  rw.Flush()
112
 
 
176
  rw := NewResponseRewriter(mock, tt.originalModel)
177
 
178
  // For non-streaming, we buffer and flush
179
+ rw.WriteString(tt.input)
180
  rw.Flush()
181
 
182
  result := mock.body.String()
 
256
  rw := NewResponseRewriter(mock, tt.originalModel)
257
 
258
  // First write triggers streaming detection
259
+ rw.WriteString(tt.input)
260
 
261
  if rw.isStreaming != tt.isStreaming {
262
  t.Errorf("isStreaming = %v, want %v", rw.isStreaming, tt.isStreaming)
 
320
 
321
  // Write all chunks
322
  for _, chunk := range tt.input {
323
+ _, err := rw.WriteString(chunk)
324
  if err != nil && !tt.expectError {
325
  t.Errorf("unexpected error: %v", err)
326
  }
 
403
  rw := NewResponseRewriter(mock, tt.originalModel)
404
 
405
  for _, chunk := range tt.chunks {
406
+ rw.WriteString(chunk)
407
  }
408
 
409
  result := mock.body.String()
 
456
  mock := newMockResponseWriter()
457
  rw := NewResponseRewriter(mock, tt.originalModel)
458
 
459
+ rw.WriteString(tt.input)
460
  rw.Flush()
461
 
462
  result := mock.body.String()
 
495
  }
496
 
497
  for _, chunk := range chunks {
498
+ rw.WriteString(chunk)
499
  }
500
  rw.Flush()
501
 
 
515
  largeContent := strings.Repeat("a", 100000)
516
  input := `{"model": "mapped-model", "content": "` + largeContent + `"}`
517
 
518
+ rw.WriteString(input)
519
  rw.Flush()
520
 
521
  result := mock.body.String()
 
594
  // TestResponseRewriter_FlushBehavior tests Flush method behavior
595
  func TestResponseRewriter_FlushBehavior(t *testing.T) {
596
  tests := []struct {
597
+ name string
598
  isStreaming bool
599
+ writeData string
600
  expectFlush bool
601
  }{
602
  {
603
+ name: "flush non-streaming",
604
  isStreaming: false,
605
+ writeData: `{"model": "mapped"}`,
606
  expectFlush: true,
607
  },
608
  {
609
+ name: "flush streaming",
610
  isStreaming: true,
611
+ writeData: `data: {"model": "mapped"}`,
612
  expectFlush: true,
613
  },
614
  {
615
+ name: "flush empty body",
616
  isStreaming: false,
617
+ writeData: "",
618
  expectFlush: false,
619
  },
620
  }
 
628
  rw := NewResponseRewriter(mock, "original")
629
 
630
  if tt.writeData != "" {
631
+ rw.WriteString(tt.writeData)
632
  }
633
 
634
  // Reset flushed flag
internal/api/modules/amp/routes_test.go CHANGED
@@ -65,7 +65,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
65
  for _, path := range managementPaths {
66
  t.Run(path.path, func(t *testing.T) {
67
  proxyCalled = false
68
- req, err := http.NewRequest(path.method, srv.URL+path.path, nil)
69
  if err != nil {
70
  t.Fatalf("failed to build request: %v", err)
71
  }
@@ -120,7 +120,7 @@ func TestRegisterProviderAliases_AllProvidersRegistered(t *testing.T) {
120
  for _, tc := range paths {
121
  t.Run(tc.path, func(t *testing.T) {
122
  authCalled = false
123
- req := httptest.NewRequest(tc.method, tc.path, nil)
124
  w := httptest.NewRecorder()
125
  r.ServeHTTP(w, req)
126
 
@@ -151,7 +151,7 @@ func TestRegisterProviderAliases_DynamicModelsHandler(t *testing.T) {
151
  for _, provider := range providers {
152
  t.Run(provider, func(t *testing.T) {
153
  path := "/api/provider/" + provider + "/models"
154
- req := httptest.NewRequest(http.MethodGet, path, nil)
155
  w := httptest.NewRecorder()
156
  r.ServeHTTP(w, req)
157
 
@@ -185,7 +185,7 @@ func TestRegisterProviderAliases_V1Routes(t *testing.T) {
185
 
186
  for _, tc := range v1Paths {
187
  t.Run(tc.path, func(t *testing.T) {
188
- req := httptest.NewRequest(tc.method, tc.path, nil)
189
  w := httptest.NewRecorder()
190
  r.ServeHTTP(w, req)
191
 
@@ -215,7 +215,7 @@ func TestRegisterProviderAliases_V1BetaRoutes(t *testing.T) {
215
 
216
  for _, tc := range v1betaPaths {
217
  t.Run(tc.path, func(t *testing.T) {
218
- req := httptest.NewRequest(tc.method, tc.path, nil)
219
  w := httptest.NewRecorder()
220
  r.ServeHTTP(w, req)
221
 
@@ -236,7 +236,7 @@ func TestRegisterProviderAliases_NoAuthMiddleware(t *testing.T) {
236
  m := &AmpModule{authMiddleware_: nil} // No auth middleware
237
  m.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })
238
 
239
- req := httptest.NewRequest(http.MethodGet, "/api/provider/openai/models", nil)
240
  w := httptest.NewRecorder()
241
  r.ServeHTTP(w, req)
242
 
@@ -314,7 +314,7 @@ func TestLocalhostOnlyMiddleware_PreventsSpoofing(t *testing.T) {
314
 
315
  for _, tt := range tests {
316
  t.Run(tt.name, func(t *testing.T) {
317
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
318
  req.RemoteAddr = tt.remoteAddr
319
  if tt.forwardedFor != "" {
320
  req.Header.Set("X-Forwarded-For", tt.forwardedFor)
@@ -346,7 +346,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
346
  })
347
 
348
  // Test 1: Remote IP should be blocked when restriction is enabled
349
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
350
  req.RemoteAddr = "192.168.1.100:12345"
351
  w := httptest.NewRecorder()
352
  r.ServeHTTP(w, req)
@@ -358,7 +358,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
358
  // Test 2: Hot-reload - disable restriction
359
  m.setRestrictToLocalhost(false)
360
 
361
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
362
  req.RemoteAddr = "192.168.1.100:12345"
363
  w = httptest.NewRecorder()
364
  r.ServeHTTP(w, req)
@@ -370,7 +370,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
370
  // Test 3: Hot-reload - re-enable restriction
371
  m.setRestrictToLocalhost(true)
372
 
373
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
374
  req.RemoteAddr = "192.168.1.100:12345"
375
  w = httptest.NewRecorder()
376
  r.ServeHTTP(w, req)
 
65
  for _, path := range managementPaths {
66
  t.Run(path.path, func(t *testing.T) {
67
  proxyCalled = false
68
+ req, err := http.NewRequest(path.method, srv.URL+path.path, http.NoBody)
69
  if err != nil {
70
  t.Fatalf("failed to build request: %v", err)
71
  }
 
120
  for _, tc := range paths {
121
  t.Run(tc.path, func(t *testing.T) {
122
  authCalled = false
123
+ req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
124
  w := httptest.NewRecorder()
125
  r.ServeHTTP(w, req)
126
 
 
151
  for _, provider := range providers {
152
  t.Run(provider, func(t *testing.T) {
153
  path := "/api/provider/" + provider + "/models"
154
+ req := httptest.NewRequest(http.MethodGet, path, http.NoBody)
155
  w := httptest.NewRecorder()
156
  r.ServeHTTP(w, req)
157
 
 
185
 
186
  for _, tc := range v1Paths {
187
  t.Run(tc.path, func(t *testing.T) {
188
+ req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
189
  w := httptest.NewRecorder()
190
  r.ServeHTTP(w, req)
191
 
 
215
 
216
  for _, tc := range v1betaPaths {
217
  t.Run(tc.path, func(t *testing.T) {
218
+ req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
219
  w := httptest.NewRecorder()
220
  r.ServeHTTP(w, req)
221
 
 
236
  m := &AmpModule{authMiddleware_: nil} // No auth middleware
237
  m.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })
238
 
239
+ req := httptest.NewRequest(http.MethodGet, "/api/provider/openai/models", http.NoBody)
240
  w := httptest.NewRecorder()
241
  r.ServeHTTP(w, req)
242
 
 
314
 
315
  for _, tt := range tests {
316
  t.Run(tt.name, func(t *testing.T) {
317
+ req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
318
  req.RemoteAddr = tt.remoteAddr
319
  if tt.forwardedFor != "" {
320
  req.Header.Set("X-Forwarded-For", tt.forwardedFor)
 
346
  })
347
 
348
  // Test 1: Remote IP should be blocked when restriction is enabled
349
+ req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
350
  req.RemoteAddr = "192.168.1.100:12345"
351
  w := httptest.NewRecorder()
352
  r.ServeHTTP(w, req)
 
358
  // Test 2: Hot-reload - disable restriction
359
  m.setRestrictToLocalhost(false)
360
 
361
+ req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
362
  req.RemoteAddr = "192.168.1.100:12345"
363
  w = httptest.NewRecorder()
364
  r.ServeHTTP(w, req)
 
370
  // Test 3: Hot-reload - re-enable restriction
371
  m.setRestrictToLocalhost(true)
372
 
373
+ req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
374
  req.RemoteAddr = "192.168.1.100:12345"
375
  w = httptest.NewRecorder()
376
  r.ServeHTTP(w, req)
internal/api/server.go CHANGED
@@ -201,6 +201,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
201
  }
202
 
203
  // Add middleware
 
 
 
204
  engine.Use(logging.GinLogrusLogger())
205
  engine.Use(logging.GinLogrusRecovery())
206
  for _, mw := range optionState.extraMiddleware {
 
201
  }
202
 
203
  // Add middleware
204
+ engine.Use(middleware.RequestID())
205
+ engine.Use(middleware.SecurityHeaders())
206
+ engine.Use(middleware.ErrorHandler(log.StandardLogger()))
207
  engine.Use(logging.GinLogrusLogger())
208
  engine.Use(logging.GinLogrusRecovery())
209
  for _, mw := range optionState.extraMiddleware {
internal/api/server_test.go CHANGED
@@ -94,7 +94,7 @@ func TestAmpProviderModelRoutes(t *testing.T) {
94
  t.Run(tc.name, func(t *testing.T) {
95
  server := newTestServer(t)
96
 
97
- req := httptest.NewRequest(http.MethodGet, tc.path, nil)
98
  req.Header.Set("Authorization", "Bearer test-key")
99
 
100
  rr := httptest.NewRecorder()
 
94
  t.Run(tc.name, func(t *testing.T) {
95
  server := newTestServer(t)
96
 
97
+ req := httptest.NewRequest(http.MethodGet, tc.path, http.NoBody)
98
  req.Header.Set("Authorization", "Bearer test-key")
99
 
100
  rr := httptest.NewRecorder()
internal/application/dto/config_dto.go CHANGED
@@ -7,29 +7,29 @@ import (
7
 
8
  // ConfigResponse represents a configuration response
9
  type ConfigResponse struct {
10
- Debug bool `json:"debug"`
11
- UsageStatisticsEnabled bool `json:"usage_statistics_enabled"`
12
- LoggingToFile bool `json:"logging_to_file"`
13
- LogsMaxTotalSizeMB int `json:"logs_max_total_size_mb"`
14
- RequestLog bool `json:"request_log"`
15
- WebsocketAuth bool `json:"websocket_auth"`
16
- RequestRetry int `json:"request_retry"`
17
- MaxRetryInterval int `json:"max_retry_interval"`
18
- ForceModelPrefix bool `json:"force_model_prefix"`
19
- ProxyURL string `json:"proxy_url,omitempty"`
20
- Routing RoutingConfig `json:"routing"`
21
- RemoteManagement RemoteManagementConfig `json:"remote_management"`
22
- QuotaExceeded QuotaExceededConfig `json:"quota_exceeded"`
23
- APIKeys []string `json:"api_keys,omitempty"`
24
- GeminiKey []config.GeminiKey `json:"gemini_key,omitempty"`
25
- ClaudeKey []config.ClaudeKey `json:"claude_key,omitempty"`
26
- CodexKey []config.CodexKey `json:"codex_key,omitempty"`
27
- OpenAICompatibility []config.OpenAICompatibility `json:"openai_compatibility,omitempty"`
28
- VertexCompatAPIKey []config.VertexCompatKey `json:"vertex_compat_api_key,omitempty"`
29
- KiroKey []config.KiroKey `json:"kiro_key,omitempty"`
30
- OAuthExcludedModels map[string][]string `json:"oauth_excluded_models,omitempty"`
31
  OAuthModelAlias map[string][]config.OAuthModelAlias `json:"oauth_model_alias,omitempty"`
32
- AmpCode config.AmpCode `json:"amp_code"`
33
  }
34
 
35
  // RoutingConfig represents routing configuration
@@ -149,4 +149,4 @@ type BoolFieldUpdateRequest struct {
149
  // IntFieldUpdateRequest represents a request to update an int field
150
  type IntFieldUpdateRequest struct {
151
  Value *int `json:"value"`
152
- }
 
7
 
8
  // ConfigResponse represents a configuration response
9
  type ConfigResponse struct {
10
+ Debug bool `json:"debug"`
11
+ UsageStatisticsEnabled bool `json:"usage_statistics_enabled"`
12
+ LoggingToFile bool `json:"logging_to_file"`
13
+ LogsMaxTotalSizeMB int `json:"logs_max_total_size_mb"`
14
+ RequestLog bool `json:"request_log"`
15
+ WebsocketAuth bool `json:"websocket_auth"`
16
+ RequestRetry int `json:"request_retry"`
17
+ MaxRetryInterval int `json:"max_retry_interval"`
18
+ ForceModelPrefix bool `json:"force_model_prefix"`
19
+ ProxyURL string `json:"proxy_url,omitempty"`
20
+ Routing RoutingConfig `json:"routing"`
21
+ RemoteManagement RemoteManagementConfig `json:"remote_management"`
22
+ QuotaExceeded QuotaExceededConfig `json:"quota_exceeded"`
23
+ APIKeys []string `json:"api_keys,omitempty"`
24
+ GeminiKey []config.GeminiKey `json:"gemini_key,omitempty"`
25
+ ClaudeKey []config.ClaudeKey `json:"claude_key,omitempty"`
26
+ CodexKey []config.CodexKey `json:"codex_key,omitempty"`
27
+ OpenAICompatibility []config.OpenAICompatibility `json:"openai_compatibility,omitempty"`
28
+ VertexCompatAPIKey []config.VertexCompatKey `json:"vertex_compat_api_key,omitempty"`
29
+ KiroKey []config.KiroKey `json:"kiro_key,omitempty"`
30
+ OAuthExcludedModels map[string][]string `json:"oauth_excluded_models,omitempty"`
31
  OAuthModelAlias map[string][]config.OAuthModelAlias `json:"oauth_model_alias,omitempty"`
32
+ AmpCode config.AmpCode `json:"amp_code"`
33
  }
34
 
35
  // RoutingConfig represents routing configuration
 
149
  // IntFieldUpdateRequest represents a request to update an int field
150
  type IntFieldUpdateRequest struct {
151
  Value *int `json:"value"`
152
+ }
internal/application/dto/error.go ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package dto
2
+
3
+ // ErrorResponse is the standardized error response for clients
4
+ type ErrorResponse struct {
5
+ Success bool `json:"success" example:"false"`
6
+ Error ErrorInfo `json:"error"`
7
+ RequestID string `json:"request_id,omitempty"`
8
+ }
9
+
10
+ type ErrorInfo struct {
11
+ Code string `json:"code" example:"AUTH_INVALID_CREDENTIALS"`
12
+ Message string `json:"message" example:"Invalid credentials provided"`
13
+ Details map[string]interface{} `json:"details,omitempty"`
14
+ }
15
+
16
+ // InternalErrorResponse is returned for 500 errors (no sensitive info)
17
+ type InternalErrorResponse struct {
18
+ Success bool `json:"success"`
19
+ Error string `json:"error"`
20
+ RequestID string `json:"request_id"`
21
+ }
internal/application/mapper/config_mapper.go CHANGED
@@ -103,4 +103,4 @@ func ToConfigFromResponse(resp *dto.ConfigResponse) *config.Config {
103
  cfg.SDKConfig.APIKeys = resp.APIKeys
104
 
105
  return cfg
106
- }
 
103
  cfg.SDKConfig.APIKeys = resp.APIKeys
104
 
105
  return cfg
106
+ }
internal/application/usecase/config_usecase.go CHANGED
@@ -537,4 +537,4 @@ func (uc *ConfigUseCase) GetLatestVersion(ctx context.Context) (*dto.VersionResp
537
  return &dto.VersionResponse{
538
  LatestVersion: version,
539
  }, nil
540
- }
 
537
  return &dto.VersionResponse{
538
  LatestVersion: version,
539
  }, nil
540
+ }
internal/auth/antigravity/auth.go CHANGED
@@ -113,7 +113,7 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string)
113
  if accessToken == "" {
114
  return "", fmt.Errorf("antigravity userinfo: missing access token")
115
  }
116
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint, nil)
117
  if err != nil {
118
  return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
119
  }
 
113
  if accessToken == "" {
114
  return "", fmt.Errorf("antigravity userinfo: missing access token")
115
  }
116
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint, http.NoBody)
117
  if err != nil {
118
  return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
119
  }
internal/auth/codex/token.go CHANGED
@@ -62,5 +62,4 @@ func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
62
  return fmt.Errorf("failed to write token to file: %w", err)
63
  }
64
  return nil
65
-
66
  }
 
62
  return fmt.Errorf("failed to write token to file: %w", err)
63
  }
64
  return nil
 
65
  }
internal/auth/gemini/gemini_auth.go CHANGED
@@ -161,7 +161,7 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
161
  // - error: An error if the token storage creation fails, nil otherwise
162
  func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
163
  httpClient := config.Client(ctx, token)
164
- req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
165
  if err != nil {
166
  return nil, fmt.Errorf("could not get user info: %v", err)
167
  }
 
161
  // - error: An error if the token storage creation fails, nil otherwise
162
  func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
163
  httpClient := config.Client(ctx, token)
164
+ req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", http.NoBody)
165
  if err != nil {
166
  return nil, fmt.Errorf("could not get user info: %v", err)
167
  }
internal/auth/iflow/iflow_auth.go CHANGED
@@ -173,7 +173,7 @@ func (ia *IFlowAuth) FetchUserInfo(ctx context.Context, accessToken string) (*us
173
  }
174
 
175
  endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))
176
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
177
  if err != nil {
178
  return nil, fmt.Errorf("iflow api key: create request failed: %w", err)
179
  }
@@ -334,7 +334,7 @@ func (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string)
334
 
335
  // fetchAPIKeyInfo retrieves API key information using GET request with cookie
336
  func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) {
337
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, nil)
338
  if err != nil {
339
  return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err)
340
  }
 
173
  }
174
 
175
  endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))
176
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
177
  if err != nil {
178
  return nil, fmt.Errorf("iflow api key: create request failed: %w", err)
179
  }
 
334
 
335
  // fetchAPIKeyInfo retrieves API key information using GET request with cookie
336
  func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) {
337
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, http.NoBody)
338
  if err != nil {
339
  return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err)
340
  }
internal/cmd/login.go CHANGED
@@ -375,7 +375,7 @@ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string
375
  }
376
 
377
  func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
378
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
379
  if errRequest != nil {
380
  return nil, fmt.Errorf("could not create project list request: %w", errRequest)
381
  }
@@ -559,7 +559,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
559
  }
560
  for _, service := range requiredServices {
561
  checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
562
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, nil)
563
  if errRequest != nil {
564
  return false, fmt.Errorf("failed to create request: %w", errRequest)
565
  }
 
375
  }
376
 
377
  func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
378
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
379
  if errRequest != nil {
380
  return nil, fmt.Errorf("could not create project list request: %w", errRequest)
381
  }
 
559
  }
560
  for _, service := range requiredServices {
561
  checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
562
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, http.NoBody)
563
  if errRequest != nil {
564
  return false, fmt.Errorf("failed to create request: %w", errRequest)
565
  }
internal/domain/errors/errors.go CHANGED
@@ -32,6 +32,37 @@ const (
32
  ValidationFailed ErrorCode = "VALIDATION_FAILED"
33
  // AlreadyExists indicates a resource already exists
34
  AlreadyExists ErrorCode = "ALREADY_EXISTS"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  )
36
 
37
  // DomainError is the base error type for all domain errors.
@@ -40,8 +71,9 @@ const (
40
  type DomainError struct {
41
  Code ErrorCode
42
  Message string
43
- Cause error
44
  Details map[string]interface{}
 
 
45
  }
46
 
47
  // Error implements the error interface
@@ -184,6 +216,9 @@ func NewTimeoutError(operation string) *DomainError {
184
 
185
  // HTTPStatusCode returns the appropriate HTTP status code for the error
186
  func (e *DomainError) HTTPStatusCode() int {
 
 
 
187
  switch e.Code {
188
  case NotFound:
189
  return 404
@@ -216,26 +251,76 @@ func (e *DomainError) ToResponse() map[string]interface{} {
216
  return response
217
  }
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  // Common error instances for reuse
220
  var (
221
  // ErrConfigNotFound is returned when configuration is not found
222
  ErrConfigNotFound = New(NotFound, "configuration not found")
223
-
224
  // ErrAuthFileNotFound is returned when an auth file is not found
225
  ErrAuthFileNotFound = New(NotFound, "auth file not found")
226
-
227
  // ErrInvalidConfig is returned when configuration is invalid
228
  ErrInvalidConfig = New(ValidationFailed, "invalid configuration")
229
-
230
  // ErrAuthManagerUnavailable is returned when the auth manager is not available
231
  ErrAuthManagerUnavailable = New(ServiceUnavailable, "auth manager unavailable")
232
-
233
  // ErrTokenStoreUnavailable is returned when the token store is not available
234
  ErrTokenStoreUnavailable = New(ServiceUnavailable, "token store unavailable")
235
-
236
  // ErrLogDirectoryNotConfigured is returned when log directory is not set
237
  ErrLogDirectoryNotConfigured = New(InternalError, "log directory not configured")
238
-
239
  // ErrLoggingDisabled is returned when logging to file is disabled
240
  ErrLoggingDisabled = New(ServiceUnavailable, "logging to file disabled")
241
- )
 
32
  ValidationFailed ErrorCode = "VALIDATION_FAILED"
33
  // AlreadyExists indicates a resource already exists
34
  AlreadyExists ErrorCode = "ALREADY_EXISTS"
35
+
36
+ // New Error Codes (Standardization Plan)
37
+ // Authentication errors
38
+ ErrCodeInvalidCredentials ErrorCode = "AUTH_INVALID_CREDENTIALS"
39
+ ErrCodeTokenExpired ErrorCode = "AUTH_TOKEN_EXPIRED"
40
+ ErrCodeTokenInvalid ErrorCode = "AUTH_TOKEN_INVALID"
41
+ ErrCodeUnauthorized ErrorCode = "AUTH_UNAUTHORIZED"
42
+
43
+ // Configuration errors
44
+ ErrCodeConfigNotFound ErrorCode = "CONFIG_NOT_FOUND"
45
+ ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID"
46
+ ErrCodeConfigValidation ErrorCode = "CONFIG_VALIDATION_FAILED"
47
+
48
+ // Provider errors
49
+ ErrCodeProviderNotFound ErrorCode = "PROVIDER_NOT_FOUND"
50
+ ErrCodeProviderUnavailable ErrorCode = "PROVIDER_UNAVAILABLE"
51
+ ErrCodeProviderRateLimited ErrorCode = "PROVIDER_RATE_LIMITED"
52
+
53
+ // Request errors
54
+ ErrCodeInvalidRequest ErrorCode = "REQUEST_INVALID"
55
+ ErrCodeMissingField ErrorCode = "REQUEST_MISSING_FIELD"
56
+ ErrCodeInvalidFormat ErrorCode = "REQUEST_INVALID_FORMAT"
57
+
58
+ // Storage errors
59
+ ErrCodeStorageFailure ErrorCode = "STORAGE_FAILURE"
60
+ ErrCodeNotFound ErrorCode = "RESOURCE_NOT_FOUND"
61
+ ErrCodeConflict ErrorCode = "RESOURCE_CONFLICT"
62
+
63
+ // Internal errors
64
+ ErrCodeInternal ErrorCode = "INTERNAL_ERROR"
65
+ ErrCodeNotImplemented ErrorCode = "NOT_IMPLEMENTED"
66
  )
67
 
68
  // DomainError is the base error type for all domain errors.
 
71
  type DomainError struct {
72
  Code ErrorCode
73
  Message string
 
74
  Details map[string]interface{}
75
+ Cause error
76
+ HTTPStatus int
77
  }
78
 
79
  // Error implements the error interface
 
216
 
217
  // HTTPStatusCode returns the appropriate HTTP status code for the error
218
  func (e *DomainError) HTTPStatusCode() int {
219
+ if e.HTTPStatus > 0 {
220
+ return e.HTTPStatus
221
+ }
222
  switch e.Code {
223
  case NotFound:
224
  return 404
 
251
  return response
252
  }
253
 
254
+ // NewInvalidCredentials creates an AUTH_INVALID_CREDENTIALS error
255
+ func NewInvalidCredentials(msg string) *DomainError {
256
+ return &DomainError{
257
+ Code: ErrCodeInvalidCredentials,
258
+ Message: msg,
259
+ HTTPStatus: 401,
260
+ }
261
+ }
262
+
263
+ // NewConfigNotFound creates a CONFIG_NOT_FOUND error
264
+ func NewConfigNotFound(resource string) *DomainError {
265
+ return &DomainError{
266
+ Code: ErrCodeConfigNotFound,
267
+ Message: fmt.Sprintf("configuration not found: %s", resource),
268
+ HTTPStatus: 404,
269
+ Details: map[string]interface{}{"resource": resource},
270
+ }
271
+ }
272
+
273
+ // NewProviderUnavailable creates a PROVIDER_UNAVAILABLE error
274
+ func NewProviderUnavailable(provider string, cause error) *DomainError {
275
+ return &DomainError{
276
+ Code: ErrCodeProviderUnavailable,
277
+ Message: fmt.Sprintf("provider %s is unavailable", provider),
278
+ HTTPStatus: 503,
279
+ Cause: cause,
280
+ Details: map[string]interface{}{"provider": provider},
281
+ }
282
+ }
283
+
284
+ // NewConfigValidationError creates a CONFIG_VALIDATION_FAILED error
285
+ func NewConfigValidationError(field string, msg string) *DomainError {
286
+ return &DomainError{
287
+ Code: ErrCodeConfigValidation,
288
+ Message: fmt.Sprintf("validation failed for %s: %s", field, msg),
289
+ HTTPStatus: 400,
290
+ Details: map[string]interface{}{"field": field},
291
+ }
292
+ }
293
+
294
+ // NewInternalErrorWrapped creates an INTERNAL_ERROR
295
+ func NewInternalErrorWrapped(cause error) *DomainError {
296
+ return &DomainError{
297
+ Code: ErrCodeInternal,
298
+ Message: "an internal error occurred",
299
+ HTTPStatus: 500,
300
+ Cause: cause,
301
+ }
302
+ }
303
+
304
  // Common error instances for reuse
305
  var (
306
  // ErrConfigNotFound is returned when configuration is not found
307
  ErrConfigNotFound = New(NotFound, "configuration not found")
308
+
309
  // ErrAuthFileNotFound is returned when an auth file is not found
310
  ErrAuthFileNotFound = New(NotFound, "auth file not found")
311
+
312
  // ErrInvalidConfig is returned when configuration is invalid
313
  ErrInvalidConfig = New(ValidationFailed, "invalid configuration")
314
+
315
  // ErrAuthManagerUnavailable is returned when the auth manager is not available
316
  ErrAuthManagerUnavailable = New(ServiceUnavailable, "auth manager unavailable")
317
+
318
  // ErrTokenStoreUnavailable is returned when the token store is not available
319
  ErrTokenStoreUnavailable = New(ServiceUnavailable, "token store unavailable")
320
+
321
  // ErrLogDirectoryNotConfigured is returned when log directory is not set
322
  ErrLogDirectoryNotConfigured = New(InternalError, "log directory not configured")
323
+
324
  // ErrLoggingDisabled is returned when logging to file is disabled
325
  ErrLoggingDisabled = New(ServiceUnavailable, "logging to file disabled")
326
+ )
internal/domain/ports/rate_limit.go DELETED
@@ -1,43 +0,0 @@
1
- package ports
2
-
3
- import (
4
- "context"
5
- "time"
6
- )
7
-
8
- // RateLimitEntry represents the state of a rate limit for a key (e.g., IP address).
9
- type RateLimitEntry struct {
10
- Key string
11
- Count int // Current count of attempts or tokens used
12
- LastAttempt time.Time // Timestamp of the last attempt
13
- BlockedUntil time.Time // Time until which the key is blocked (zero time if not blocked)
14
- }
15
-
16
- // RateLimitRepository defines the interface for persisting rate limit data.
17
- type RateLimitRepository interface {
18
- // Get retrieves the rate limit entry for a given key.
19
- Get(ctx context.Context, key string) (*RateLimitEntry, error)
20
-
21
- // Set saves the rate limit entry for a given key with an expiration.
22
- Set(ctx context.Context, key string, entry *RateLimitEntry, expiration time.Duration) error
23
-
24
- // Cleanup removes entries older than the specified time.
25
- Cleanup(ctx context.Context, olderThan time.Time) error
26
- }
27
-
28
- // RateLimitService defines the interface for the rate limiting logic.
29
- type RateLimitService interface {
30
- // Allow checks if a request from the given key is allowed based on the rate limit policy.
31
- // It basically checks if the key is currently blocked.
32
- Allow(ctx context.Context, key string) (bool, error)
33
-
34
- // RecordAttempt records a request or action attempt for the given key.
35
- // success: indicates if the attempt was successful.
36
- // If success is true, it might reset the failure count.
37
- // If success is false, it increments the failure count and might block the key.
38
- RecordAttempt(ctx context.Context, key string, success bool) error
39
-
40
- // IsBlocked checks if the key is currently blocked and returns the blockage details.
41
- // Returns true if blocked, the time until it's blocked, and any error.
42
- IsBlocked(ctx context.Context, key string) (bool, time.Time, error)
43
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/domain/ports/repositories.go CHANGED
@@ -15,67 +15,67 @@ import (
15
  type ConfigRepository interface {
16
  // Load retrieves the current configuration
17
  Load(ctx context.Context) (*config.Config, error)
18
-
19
  // Save persists the configuration
20
  Save(ctx context.Context, cfg *config.Config) error
21
-
22
  // SaveWithPath persists the configuration to a specific path
23
  SaveWithPath(ctx context.Context, path string, cfg *config.Config) error
24
-
25
  // Validate validates the configuration without saving
26
  Validate(ctx context.Context, cfg *config.Config) error
27
-
28
  // GetConfigPath returns the current configuration file path
29
  GetConfigPath() string
30
  }
31
 
32
  // AuthFile represents an authentication file in the domain
33
  type AuthFile struct {
34
- ID string
35
- Provider string
36
- FileName string
37
- Label string
38
- Email string
39
- Status string
40
- StatusMessage string
41
- Disabled bool
42
- Unavailable bool
43
- RuntimeOnly bool
44
- Path string
45
- Size int64
46
- CreatedAt time.Time
47
- UpdatedAt time.Time
48
  LastRefreshedAt time.Time
49
- Metadata map[string]interface{}
50
- Attributes map[string]string
51
  }
52
 
53
  // AuthRepository defines the interface for authentication file persistence
54
  type AuthRepository interface {
55
  // List retrieves all authentication files
56
  List(ctx context.Context) ([]*AuthFile, error)
57
-
58
  // GetByID retrieves an authentication file by its ID
59
  GetByID(ctx context.Context, id string) (*AuthFile, error)
60
-
61
  // GetByName retrieves an authentication file by its filename
62
  GetByName(ctx context.Context, name string) (*AuthFile, error)
63
-
64
  // Save persists an authentication file
65
  Save(ctx context.Context, file *AuthFile) error
66
-
67
  // Delete removes an authentication file
68
  Delete(ctx context.Context, id string) error
69
-
70
  // DeleteAll removes all authentication files
71
  DeleteAll(ctx context.Context) (int, error)
72
-
73
  // Disable marks an authentication file as disabled
74
  Disable(ctx context.Context, id string, reason string) error
75
-
76
  // Enable marks an authentication file as enabled
77
  Enable(ctx context.Context, id string) error
78
-
79
  // GetAuthDir returns the authentication directory path
80
  GetAuthDir() string
81
  }
@@ -94,25 +94,25 @@ type LogEntry struct {
94
  type LogRepository interface {
95
  // ListLogFiles retrieves all log files
96
  ListLogFiles(ctx context.Context) ([]*LogFileInfo, error)
97
-
98
  // ReadLogFile reads a log file with optional filtering
99
  ReadLogFile(ctx context.Context, filename string, after int64, limit int) (*LogContent, error)
100
-
101
  // DeleteLogFiles removes all log files and truncates the active log
102
  DeleteLogFiles(ctx context.Context) (*DeleteLogResult, error)
103
-
104
  // GetRequestErrorLogs retrieves error request log files
105
  GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
106
-
107
  // GetRequestLogByID retrieves a specific request log by ID
108
  GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
109
-
110
  // DownloadRequestErrorLog downloads a specific error log file
111
  DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
112
-
113
  // GetLogDirectory returns the log directory path
114
  GetLogDirectory() string
115
-
116
  // IsLoggingEnabled returns whether logging to file is enabled
117
  IsLoggingEnabled() bool
118
  }
@@ -155,35 +155,35 @@ type TokenRecord struct {
155
  type TokenStore interface {
156
  // Save persists a token record
157
  Save(ctx context.Context, record *TokenRecord) (string, error)
158
-
159
  // Delete removes a token record
160
  Delete(ctx context.Context, path string) error
161
-
162
  // Get retrieves a token record by path
163
  Get(ctx context.Context, path string) (*TokenRecord, error)
164
-
165
  // List retrieves all token records
166
  List(ctx context.Context) ([]*TokenRecord, error)
167
  }
168
 
169
  // UsageStatistics represents usage statistics data
170
  type UsageStatistics struct {
171
- TotalRequests int64
172
- FailureCount int64
173
- RequestCount int64
174
- TokenCount int64
175
- LastUpdated time.Time
176
- Data map[string]interface{}
177
  }
178
 
179
  // UsageRepository defines the interface for usage statistics persistence
180
  type UsageRepository interface {
181
  // GetStatistics retrieves current usage statistics
182
  GetStatistics(ctx context.Context) (*UsageStatistics, error)
183
-
184
  // ExportStatistics exports statistics for backup
185
  ExportStatistics(ctx context.Context) (*UsageStatistics, error)
186
-
187
  // ImportStatistics imports statistics from backup
188
  ImportStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
189
  }
@@ -197,34 +197,34 @@ type ImportResult struct {
197
 
198
  // OAuthSession represents an OAuth session
199
  type OAuthSession struct {
200
- State string
201
- Provider string
202
- Status string
203
- Error string
204
- CreatedAt time.Time
205
- ExpiresAt time.Time
206
  }
207
 
208
  // OAuthSessionRepository defines the interface for OAuth session management
209
  type OAuthSessionRepository interface {
210
  // Create creates a new OAuth session
211
  Create(ctx context.Context, session *OAuthSession) error
212
-
213
  // Get retrieves an OAuth session by state
214
  Get(ctx context.Context, state string) (*OAuthSession, error)
215
-
216
  // Update updates an OAuth session
217
  Update(ctx context.Context, session *OAuthSession) error
218
-
219
  // Complete marks an OAuth session as complete
220
  Complete(ctx context.Context, state string) error
221
-
222
  // SetError sets an error on an OAuth session
223
  SetError(ctx context.Context, state string, err string) error
224
-
225
  // IsPending checks if a session is pending
226
  IsPending(ctx context.Context, state string) bool
227
-
228
  // Cleanup removes expired sessions
229
  Cleanup(ctx context.Context) error
230
- }
 
15
  type ConfigRepository interface {
16
  // Load retrieves the current configuration
17
  Load(ctx context.Context) (*config.Config, error)
18
+
19
  // Save persists the configuration
20
  Save(ctx context.Context, cfg *config.Config) error
21
+
22
  // SaveWithPath persists the configuration to a specific path
23
  SaveWithPath(ctx context.Context, path string, cfg *config.Config) error
24
+
25
  // Validate validates the configuration without saving
26
  Validate(ctx context.Context, cfg *config.Config) error
27
+
28
  // GetConfigPath returns the current configuration file path
29
  GetConfigPath() string
30
  }
31
 
32
  // AuthFile represents an authentication file in the domain
33
  type AuthFile struct {
34
+ ID string
35
+ Provider string
36
+ FileName string
37
+ Label string
38
+ Email string
39
+ Status string
40
+ StatusMessage string
41
+ Disabled bool
42
+ Unavailable bool
43
+ RuntimeOnly bool
44
+ Path string
45
+ Size int64
46
+ CreatedAt time.Time
47
+ UpdatedAt time.Time
48
  LastRefreshedAt time.Time
49
+ Metadata map[string]interface{}
50
+ Attributes map[string]string
51
  }
52
 
53
  // AuthRepository defines the interface for authentication file persistence
54
  type AuthRepository interface {
55
  // List retrieves all authentication files
56
  List(ctx context.Context) ([]*AuthFile, error)
57
+
58
  // GetByID retrieves an authentication file by its ID
59
  GetByID(ctx context.Context, id string) (*AuthFile, error)
60
+
61
  // GetByName retrieves an authentication file by its filename
62
  GetByName(ctx context.Context, name string) (*AuthFile, error)
63
+
64
  // Save persists an authentication file
65
  Save(ctx context.Context, file *AuthFile) error
66
+
67
  // Delete removes an authentication file
68
  Delete(ctx context.Context, id string) error
69
+
70
  // DeleteAll removes all authentication files
71
  DeleteAll(ctx context.Context) (int, error)
72
+
73
  // Disable marks an authentication file as disabled
74
  Disable(ctx context.Context, id string, reason string) error
75
+
76
  // Enable marks an authentication file as enabled
77
  Enable(ctx context.Context, id string) error
78
+
79
  // GetAuthDir returns the authentication directory path
80
  GetAuthDir() string
81
  }
 
94
  type LogRepository interface {
95
  // ListLogFiles retrieves all log files
96
  ListLogFiles(ctx context.Context) ([]*LogFileInfo, error)
97
+
98
  // ReadLogFile reads a log file with optional filtering
99
  ReadLogFile(ctx context.Context, filename string, after int64, limit int) (*LogContent, error)
100
+
101
  // DeleteLogFiles removes all log files and truncates the active log
102
  DeleteLogFiles(ctx context.Context) (*DeleteLogResult, error)
103
+
104
  // GetRequestErrorLogs retrieves error request log files
105
  GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
106
+
107
  // GetRequestLogByID retrieves a specific request log by ID
108
  GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
109
+
110
  // DownloadRequestErrorLog downloads a specific error log file
111
  DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
112
+
113
  // GetLogDirectory returns the log directory path
114
  GetLogDirectory() string
115
+
116
  // IsLoggingEnabled returns whether logging to file is enabled
117
  IsLoggingEnabled() bool
118
  }
 
155
  type TokenStore interface {
156
  // Save persists a token record
157
  Save(ctx context.Context, record *TokenRecord) (string, error)
158
+
159
  // Delete removes a token record
160
  Delete(ctx context.Context, path string) error
161
+
162
  // Get retrieves a token record by path
163
  Get(ctx context.Context, path string) (*TokenRecord, error)
164
+
165
  // List retrieves all token records
166
  List(ctx context.Context) ([]*TokenRecord, error)
167
  }
168
 
169
  // UsageStatistics represents usage statistics data
170
  type UsageStatistics struct {
171
+ TotalRequests int64
172
+ FailureCount int64
173
+ RequestCount int64
174
+ TokenCount int64
175
+ LastUpdated time.Time
176
+ Data map[string]interface{}
177
  }
178
 
179
  // UsageRepository defines the interface for usage statistics persistence
180
  type UsageRepository interface {
181
  // GetStatistics retrieves current usage statistics
182
  GetStatistics(ctx context.Context) (*UsageStatistics, error)
183
+
184
  // ExportStatistics exports statistics for backup
185
  ExportStatistics(ctx context.Context) (*UsageStatistics, error)
186
+
187
  // ImportStatistics imports statistics from backup
188
  ImportStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
189
  }
 
197
 
198
  // OAuthSession represents an OAuth session
199
  type OAuthSession struct {
200
+ State string
201
+ Provider string
202
+ Status string
203
+ Error string
204
+ CreatedAt time.Time
205
+ ExpiresAt time.Time
206
  }
207
 
208
  // OAuthSessionRepository defines the interface for OAuth session management
209
  type OAuthSessionRepository interface {
210
  // Create creates a new OAuth session
211
  Create(ctx context.Context, session *OAuthSession) error
212
+
213
  // Get retrieves an OAuth session by state
214
  Get(ctx context.Context, state string) (*OAuthSession, error)
215
+
216
  // Update updates an OAuth session
217
  Update(ctx context.Context, session *OAuthSession) error
218
+
219
  // Complete marks an OAuth session as complete
220
  Complete(ctx context.Context, state string) error
221
+
222
  // SetError sets an error on an OAuth session
223
  SetError(ctx context.Context, state string, err string) error
224
+
225
  // IsPending checks if a session is pending
226
  IsPending(ctx context.Context, state string) bool
227
+
228
  // Cleanup removes expired sessions
229
  Cleanup(ctx context.Context) error
230
+ }
internal/domain/ports/services.go CHANGED
@@ -13,97 +13,97 @@ import (
13
  type ConfigService interface {
14
  // GetConfig retrieves the current configuration
15
  GetConfig(ctx context.Context) (*config.Config, error)
16
-
17
  // UpdateConfig updates the entire configuration
18
  UpdateConfig(ctx context.Context, cfg *config.Config) error
19
-
20
  // UpdateField updates a single configuration field
21
  UpdateField(ctx context.Context, field string, value interface{}) error
22
-
23
  // UpdateAPIKeys updates the API keys list
24
  UpdateAPIKeys(ctx context.Context, keys []string) error
25
-
26
  // UpdateGeminiKeys updates the Gemini keys list
27
  UpdateGeminiKeys(ctx context.Context, keys []config.GeminiKey) error
28
-
29
  // UpdateClaudeKeys updates the Claude keys list
30
  UpdateClaudeKeys(ctx context.Context, keys []config.ClaudeKey) error
31
-
32
  // UpdateCodexKeys updates the Codex keys list
33
  UpdateCodexKeys(ctx context.Context, keys []config.CodexKey) error
34
-
35
  // UpdateOpenAICompatibility updates the OpenAI compatibility entries
36
  UpdateOpenAICompatibility(ctx context.Context, entries []config.OpenAICompatibility) error
37
-
38
  // UpdateVertexCompatKeys updates the Vertex compatibility keys
39
  UpdateVertexCompatKeys(ctx context.Context, keys []config.VertexCompatKey) error
40
-
41
  // UpdateKiroKeys updates the Kiro keys list
42
  UpdateKiroKeys(ctx context.Context, keys []config.KiroKey) error
43
-
44
  // UpdateOAuthExcludedModels updates OAuth excluded models
45
  UpdateOAuthExcludedModels(ctx context.Context, models map[string][]string) error
46
-
47
  // UpdateOAuthModelAlias updates OAuth model aliases
48
  UpdateOAuthModelAlias(ctx context.Context, aliases map[string][]config.OAuthModelAlias) error
49
-
50
  // UpdateAmpCode updates the AmpCode configuration
51
  UpdateAmpCode(ctx context.Context, ampCode config.AmpCode) error
52
-
53
  // UpdateAmpUpstreamURL updates the Amp upstream URL
54
  UpdateAmpUpstreamURL(ctx context.Context, url string) error
55
-
56
  // UpdateAmpModelMappings updates Amp model mappings
57
  UpdateAmpModelMappings(ctx context.Context, mappings []config.AmpModelMapping) error
58
-
59
  // UpdateAmpUpstreamAPIKeys updates Amp upstream API keys
60
  UpdateAmpUpstreamAPIKeys(ctx context.Context, keys []config.AmpUpstreamAPIKeyEntry) error
61
-
62
  // UpdateDebug updates the debug setting
63
  UpdateDebug(ctx context.Context, enabled bool) error
64
-
65
  // UpdateUsageStatisticsEnabled updates the usage statistics enabled setting
66
  UpdateUsageStatisticsEnabled(ctx context.Context, enabled bool) error
67
-
68
  // UpdateLoggingToFile updates the logging to file setting
69
  UpdateLoggingToFile(ctx context.Context, enabled bool) error
70
-
71
  // UpdateLogsMaxTotalSizeMB updates the max log size
72
  UpdateLogsMaxTotalSizeMB(ctx context.Context, sizeMB int) error
73
-
74
  // UpdateRequestLog updates the request log setting
75
  UpdateRequestLog(ctx context.Context, enabled bool) error
76
-
77
  // UpdateWebsocketAuth updates the websocket auth setting
78
  UpdateWebsocketAuth(ctx context.Context, enabled bool) error
79
-
80
  // UpdateRequestRetry updates the request retry count
81
  UpdateRequestRetry(ctx context.Context, retry int) error
82
-
83
  // UpdateMaxRetryInterval updates the max retry interval
84
  UpdateMaxRetryInterval(ctx context.Context, interval int) error
85
-
86
  // UpdateForceModelPrefix updates the force model prefix setting
87
  UpdateForceModelPrefix(ctx context.Context, enabled bool) error
88
-
89
  // UpdateRoutingStrategy updates the routing strategy
90
  UpdateRoutingStrategy(ctx context.Context, strategy string) error
91
-
92
  // UpdateProxyURL updates the proxy URL
93
  UpdateProxyURL(ctx context.Context, url string) error
94
-
95
  // UpdateRemoteManagement updates the remote management settings
96
  UpdateRemoteManagement(ctx context.Context, allowRemote bool, secretHash string) error
97
-
98
  // UpdateQuotaExceeded updates the quota exceeded settings
99
  UpdateQuotaExceeded(ctx context.Context, switchProject, switchPreviewModel bool) error
100
-
101
  // Validate validates the current configuration
102
  Validate(ctx context.Context) error
103
-
104
  // ValidateConfig validates a specific configuration
105
  ValidateConfig(ctx context.Context, cfg *config.Config) error
106
-
107
  // GetLatestVersion retrieves the latest version from GitHub
108
  GetLatestVersion(ctx context.Context) (string, error)
109
  }
@@ -112,31 +112,31 @@ type ConfigService interface {
112
  type AuthFileService interface {
113
  // ListAuthFiles retrieves all authentication files
114
  ListAuthFiles(ctx context.Context) ([]*AuthFile, error)
115
-
116
  // GetAuthFile retrieves a single authentication file by ID
117
  GetAuthFile(ctx context.Context, id string) (*AuthFile, error)
118
-
119
  // GetAuthFileModels retrieves models supported by an auth file
120
  GetAuthFileModels(ctx context.Context, id string) ([]*AuthFileModel, error)
121
-
122
  // UploadAuthFile uploads a new authentication file
123
  UploadAuthFile(ctx context.Context, filename string, data []byte) (*AuthFile, error)
124
-
125
  // DownloadAuthFile retrieves the raw content of an auth file
126
  DownloadAuthFile(ctx context.Context, id string) ([]byte, error)
127
-
128
  // DeleteAuthFile deletes an authentication file
129
  DeleteAuthFile(ctx context.Context, id string) error
130
-
131
  // DeleteAllAuthFiles deletes all authentication files
132
  DeleteAllAuthFiles(ctx context.Context) (int, error)
133
-
134
  // DisableAuthFile disables an authentication file
135
  DisableAuthFile(ctx context.Context, id string) error
136
-
137
  // EnableAuthFile enables an authentication file
138
  EnableAuthFile(ctx context.Context, id string) error
139
-
140
  // RefreshAuthToken refreshes the token for an auth file
141
  RefreshAuthToken(ctx context.Context, id string) error
142
  }
@@ -153,16 +153,16 @@ type AuthFileModel struct {
153
  type LogService interface {
154
  // GetLogs retrieves log entries with optional filtering
155
  GetLogs(ctx context.Context, after int64, limit int) (*LogContent, error)
156
-
157
  // DeleteLogs removes all log files
158
  DeleteLogs(ctx context.Context) (*DeleteLogResult, error)
159
-
160
  // GetRequestErrorLogs retrieves error request log files
161
  GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
162
-
163
  // GetRequestLogByID retrieves a specific request log by ID
164
  GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
165
-
166
  // DownloadRequestErrorLog downloads a specific error log file
167
  DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
168
  }
@@ -171,10 +171,10 @@ type LogService interface {
171
  type UsageService interface {
172
  // GetUsageStatistics retrieves current usage statistics
173
  GetUsageStatistics(ctx context.Context) (*UsageStatistics, error)
174
-
175
  // ExportUsageStatistics exports statistics for backup
176
  ExportUsageStatistics(ctx context.Context) (*UsageStatistics, error)
177
-
178
  // ImportUsageStatistics imports statistics from backup
179
  ImportUsageStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
180
  }
@@ -183,13 +183,13 @@ type UsageService interface {
183
  type OAuthService interface {
184
  // InitiateAuth initiates OAuth authentication for a provider
185
  InitiateAuth(ctx context.Context, provider string, options *OAuthOptions) (*OAuthInitResult, error)
186
-
187
  // CompleteAuth completes OAuth authentication with a code
188
  CompleteAuth(ctx context.Context, state string, code string) (*AuthFile, error)
189
-
190
  // GetAuthStatus retrieves the status of an OAuth session
191
  GetAuthStatus(ctx context.Context, state string) (*OAuthSessionStatus, error)
192
-
193
  // CancelAuth cancels an ongoing OAuth session
194
  CancelAuth(ctx context.Context, state string) error
195
  }
@@ -209,26 +209,26 @@ type OAuthInitResult struct {
209
 
210
  // OAuthSessionStatus represents the status of an OAuth session
211
  type OAuthSessionStatus struct {
212
- State string
213
- Status string // "pending", "complete", "error"
214
- Error string
215
- Provider string
216
  }
217
 
218
  // ManagementService defines operations for management functionality
219
  type ManagementService interface {
220
  // VerifyManagementKey verifies a management key
221
  VerifyManagementKey(ctx context.Context, key string, clientIP string) error
222
-
223
  // IsRemoteAllowed checks if remote management is allowed for a client
224
  IsRemoteAllowed(ctx context.Context, clientIP string) bool
225
-
226
  // RecordFailedAttempt records a failed authentication attempt
227
  RecordFailedAttempt(ctx context.Context, clientIP string)
228
-
229
  // IsBlocked checks if a client IP is blocked
230
  IsBlocked(ctx context.Context, clientIP string) (bool, string)
231
-
232
  // GetVersionInfo retrieves version information
233
  GetVersionInfo(ctx context.Context) (*VersionInfo, error)
234
  }
@@ -244,18 +244,18 @@ type VersionInfo struct {
244
  type APICallService interface {
245
  // MakeAPICall makes a generic HTTP API call
246
  MakeAPICall(ctx context.Context, req *APICallRequest) (*APICallResponse, error)
247
-
248
  // ResolveToken resolves a token for an auth index
249
  ResolveToken(ctx context.Context, authIndex string) (string, error)
250
  }
251
 
252
  // APICallRequest contains parameters for an API call
253
  type APICallRequest struct {
254
- AuthIndex string
255
- Method string
256
- URL string
257
- Headers map[string]string
258
- Body string
259
  }
260
 
261
  // APICallResponse contains the response from an API call
@@ -263,4 +263,4 @@ type APICallResponse struct {
263
  StatusCode int
264
  Headers map[string][]string
265
  Body string
266
- }
 
13
  type ConfigService interface {
14
  // GetConfig retrieves the current configuration
15
  GetConfig(ctx context.Context) (*config.Config, error)
16
+
17
  // UpdateConfig updates the entire configuration
18
  UpdateConfig(ctx context.Context, cfg *config.Config) error
19
+
20
  // UpdateField updates a single configuration field
21
  UpdateField(ctx context.Context, field string, value interface{}) error
22
+
23
  // UpdateAPIKeys updates the API keys list
24
  UpdateAPIKeys(ctx context.Context, keys []string) error
25
+
26
  // UpdateGeminiKeys updates the Gemini keys list
27
  UpdateGeminiKeys(ctx context.Context, keys []config.GeminiKey) error
28
+
29
  // UpdateClaudeKeys updates the Claude keys list
30
  UpdateClaudeKeys(ctx context.Context, keys []config.ClaudeKey) error
31
+
32
  // UpdateCodexKeys updates the Codex keys list
33
  UpdateCodexKeys(ctx context.Context, keys []config.CodexKey) error
34
+
35
  // UpdateOpenAICompatibility updates the OpenAI compatibility entries
36
  UpdateOpenAICompatibility(ctx context.Context, entries []config.OpenAICompatibility) error
37
+
38
  // UpdateVertexCompatKeys updates the Vertex compatibility keys
39
  UpdateVertexCompatKeys(ctx context.Context, keys []config.VertexCompatKey) error
40
+
41
  // UpdateKiroKeys updates the Kiro keys list
42
  UpdateKiroKeys(ctx context.Context, keys []config.KiroKey) error
43
+
44
  // UpdateOAuthExcludedModels updates OAuth excluded models
45
  UpdateOAuthExcludedModels(ctx context.Context, models map[string][]string) error
46
+
47
  // UpdateOAuthModelAlias updates OAuth model aliases
48
  UpdateOAuthModelAlias(ctx context.Context, aliases map[string][]config.OAuthModelAlias) error
49
+
50
  // UpdateAmpCode updates the AmpCode configuration
51
  UpdateAmpCode(ctx context.Context, ampCode config.AmpCode) error
52
+
53
  // UpdateAmpUpstreamURL updates the Amp upstream URL
54
  UpdateAmpUpstreamURL(ctx context.Context, url string) error
55
+
56
  // UpdateAmpModelMappings updates Amp model mappings
57
  UpdateAmpModelMappings(ctx context.Context, mappings []config.AmpModelMapping) error
58
+
59
  // UpdateAmpUpstreamAPIKeys updates Amp upstream API keys
60
  UpdateAmpUpstreamAPIKeys(ctx context.Context, keys []config.AmpUpstreamAPIKeyEntry) error
61
+
62
  // UpdateDebug updates the debug setting
63
  UpdateDebug(ctx context.Context, enabled bool) error
64
+
65
  // UpdateUsageStatisticsEnabled updates the usage statistics enabled setting
66
  UpdateUsageStatisticsEnabled(ctx context.Context, enabled bool) error
67
+
68
  // UpdateLoggingToFile updates the logging to file setting
69
  UpdateLoggingToFile(ctx context.Context, enabled bool) error
70
+
71
  // UpdateLogsMaxTotalSizeMB updates the max log size
72
  UpdateLogsMaxTotalSizeMB(ctx context.Context, sizeMB int) error
73
+
74
  // UpdateRequestLog updates the request log setting
75
  UpdateRequestLog(ctx context.Context, enabled bool) error
76
+
77
  // UpdateWebsocketAuth updates the websocket auth setting
78
  UpdateWebsocketAuth(ctx context.Context, enabled bool) error
79
+
80
  // UpdateRequestRetry updates the request retry count
81
  UpdateRequestRetry(ctx context.Context, retry int) error
82
+
83
  // UpdateMaxRetryInterval updates the max retry interval
84
  UpdateMaxRetryInterval(ctx context.Context, interval int) error
85
+
86
  // UpdateForceModelPrefix updates the force model prefix setting
87
  UpdateForceModelPrefix(ctx context.Context, enabled bool) error
88
+
89
  // UpdateRoutingStrategy updates the routing strategy
90
  UpdateRoutingStrategy(ctx context.Context, strategy string) error
91
+
92
  // UpdateProxyURL updates the proxy URL
93
  UpdateProxyURL(ctx context.Context, url string) error
94
+
95
  // UpdateRemoteManagement updates the remote management settings
96
  UpdateRemoteManagement(ctx context.Context, allowRemote bool, secretHash string) error
97
+
98
  // UpdateQuotaExceeded updates the quota exceeded settings
99
  UpdateQuotaExceeded(ctx context.Context, switchProject, switchPreviewModel bool) error
100
+
101
  // Validate validates the current configuration
102
  Validate(ctx context.Context) error
103
+
104
  // ValidateConfig validates a specific configuration
105
  ValidateConfig(ctx context.Context, cfg *config.Config) error
106
+
107
  // GetLatestVersion retrieves the latest version from GitHub
108
  GetLatestVersion(ctx context.Context) (string, error)
109
  }
 
112
  type AuthFileService interface {
113
  // ListAuthFiles retrieves all authentication files
114
  ListAuthFiles(ctx context.Context) ([]*AuthFile, error)
115
+
116
  // GetAuthFile retrieves a single authentication file by ID
117
  GetAuthFile(ctx context.Context, id string) (*AuthFile, error)
118
+
119
  // GetAuthFileModels retrieves models supported by an auth file
120
  GetAuthFileModels(ctx context.Context, id string) ([]*AuthFileModel, error)
121
+
122
  // UploadAuthFile uploads a new authentication file
123
  UploadAuthFile(ctx context.Context, filename string, data []byte) (*AuthFile, error)
124
+
125
  // DownloadAuthFile retrieves the raw content of an auth file
126
  DownloadAuthFile(ctx context.Context, id string) ([]byte, error)
127
+
128
  // DeleteAuthFile deletes an authentication file
129
  DeleteAuthFile(ctx context.Context, id string) error
130
+
131
  // DeleteAllAuthFiles deletes all authentication files
132
  DeleteAllAuthFiles(ctx context.Context) (int, error)
133
+
134
  // DisableAuthFile disables an authentication file
135
  DisableAuthFile(ctx context.Context, id string) error
136
+
137
  // EnableAuthFile enables an authentication file
138
  EnableAuthFile(ctx context.Context, id string) error
139
+
140
  // RefreshAuthToken refreshes the token for an auth file
141
  RefreshAuthToken(ctx context.Context, id string) error
142
  }
 
153
  type LogService interface {
154
  // GetLogs retrieves log entries with optional filtering
155
  GetLogs(ctx context.Context, after int64, limit int) (*LogContent, error)
156
+
157
  // DeleteLogs removes all log files
158
  DeleteLogs(ctx context.Context) (*DeleteLogResult, error)
159
+
160
  // GetRequestErrorLogs retrieves error request log files
161
  GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
162
+
163
  // GetRequestLogByID retrieves a specific request log by ID
164
  GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
165
+
166
  // DownloadRequestErrorLog downloads a specific error log file
167
  DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
168
  }
 
171
  type UsageService interface {
172
  // GetUsageStatistics retrieves current usage statistics
173
  GetUsageStatistics(ctx context.Context) (*UsageStatistics, error)
174
+
175
  // ExportUsageStatistics exports statistics for backup
176
  ExportUsageStatistics(ctx context.Context) (*UsageStatistics, error)
177
+
178
  // ImportUsageStatistics imports statistics from backup
179
  ImportUsageStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
180
  }
 
183
  type OAuthService interface {
184
  // InitiateAuth initiates OAuth authentication for a provider
185
  InitiateAuth(ctx context.Context, provider string, options *OAuthOptions) (*OAuthInitResult, error)
186
+
187
  // CompleteAuth completes OAuth authentication with a code
188
  CompleteAuth(ctx context.Context, state string, code string) (*AuthFile, error)
189
+
190
  // GetAuthStatus retrieves the status of an OAuth session
191
  GetAuthStatus(ctx context.Context, state string) (*OAuthSessionStatus, error)
192
+
193
  // CancelAuth cancels an ongoing OAuth session
194
  CancelAuth(ctx context.Context, state string) error
195
  }
 
209
 
210
  // OAuthSessionStatus represents the status of an OAuth session
211
  type OAuthSessionStatus struct {
212
+ State string
213
+ Status string // "pending", "complete", "error"
214
+ Error string
215
+ Provider string
216
  }
217
 
218
  // ManagementService defines operations for management functionality
219
  type ManagementService interface {
220
  // VerifyManagementKey verifies a management key
221
  VerifyManagementKey(ctx context.Context, key string, clientIP string) error
222
+
223
  // IsRemoteAllowed checks if remote management is allowed for a client
224
  IsRemoteAllowed(ctx context.Context, clientIP string) bool
225
+
226
  // RecordFailedAttempt records a failed authentication attempt
227
  RecordFailedAttempt(ctx context.Context, clientIP string)
228
+
229
  // IsBlocked checks if a client IP is blocked
230
  IsBlocked(ctx context.Context, clientIP string) (bool, string)
231
+
232
  // GetVersionInfo retrieves version information
233
  GetVersionInfo(ctx context.Context) (*VersionInfo, error)
234
  }
 
244
  type APICallService interface {
245
  // MakeAPICall makes a generic HTTP API call
246
  MakeAPICall(ctx context.Context, req *APICallRequest) (*APICallResponse, error)
247
+
248
  // ResolveToken resolves a token for an auth index
249
  ResolveToken(ctx context.Context, authIndex string) (string, error)
250
  }
251
 
252
  // APICallRequest contains parameters for an API call
253
  type APICallRequest struct {
254
+ AuthIndex string
255
+ Method string
256
+ URL string
257
+ Headers map[string]string
258
+ Body string
259
  }
260
 
261
  // APICallResponse contains the response from an API call
 
263
  StatusCode int
264
  Headers map[string][]string
265
  Body string
266
+ }
internal/domain/services/auth_service.go CHANGED
@@ -319,4 +319,4 @@ func (s *AuthService) RefreshAuthToken(ctx context.Context, id string) error {
319
  }
320
 
321
  // Ensure AuthService implements the interface
322
- var _ ports.AuthFileService = (*AuthService)(nil)
 
319
  }
320
 
321
  // Ensure AuthService implements the interface
322
+ var _ ports.AuthFileService = (*AuthService)(nil)
internal/domain/services/config_service.go CHANGED
@@ -508,7 +508,7 @@ func (s *ConfigService) GetLatestVersion(ctx context.Context) (string, error) {
508
  util.SetProxy(sdkCfg, client)
509
  }
510
 
511
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
512
  if err != nil {
513
  return "", errors.Wrap(errors.InternalError, "failed to create request", err)
514
  }
@@ -674,4 +674,4 @@ func toInt(v interface{}) (int, bool) {
674
  }
675
 
676
  // Ensure ConfigService implements the interface
677
- var _ ports.ConfigService = (*ConfigService)(nil)
 
508
  util.SetProxy(sdkCfg, client)
509
  }
510
 
511
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, http.NoBody)
512
  if err != nil {
513
  return "", errors.Wrap(errors.InternalError, "failed to create request", err)
514
  }
 
674
  }
675
 
676
  // Ensure ConfigService implements the interface
677
+ var _ ports.ConfigService = (*ConfigService)(nil)
internal/domain/services/log_service.go CHANGED
@@ -128,4 +128,4 @@ func (s *LogService) DownloadRequestErrorLog(ctx context.Context, filename strin
128
  }
129
 
130
  // Ensure LogService implements the interface
131
- var _ ports.LogService = (*LogService)(nil)
 
128
  }
129
 
130
  // Ensure LogService implements the interface
131
+ var _ ports.LogService = (*LogService)(nil)
internal/domain/services/rate_limit_service.go DELETED
@@ -1,153 +0,0 @@
1
- package services
2
-
3
- import (
4
- "context"
5
- "time"
6
-
7
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
8
- )
9
-
10
- // RateLimitConfig holds the configuration for the RateLimitService.
11
- type RateLimitConfig struct {
12
- MaxFailures int // Maximum number of failures allowed before blocking
13
- FailureDecayInterval time.Duration // Time duration to forgive one failure (leak rate)
14
- BlockDuration time.Duration // Duration to block the key after MaxFailures is reached
15
- }
16
-
17
- // RateLimitService implements the RateLimitService interface.
18
- type RateLimitService struct {
19
- repo ports.RateLimitRepository
20
- config RateLimitConfig
21
- // now returns the current time. It is a field to allow mocking in tests.
22
- now func() time.Time
23
- }
24
-
25
- // NewRateLimitService creates a new instance of RateLimitService.
26
- func NewRateLimitService(repo ports.RateLimitRepository, config RateLimitConfig) *RateLimitService {
27
- return &RateLimitService{
28
- repo: repo,
29
- config: config,
30
- now: time.Now,
31
- }
32
- }
33
-
34
- // Allow checks if the request is allowed.
35
- func (s *RateLimitService) Allow(ctx context.Context, key string) (bool, error) {
36
- blocked, _, err := s.IsBlocked(ctx, key)
37
- if err != nil {
38
- return false, err
39
- }
40
- return !blocked, nil
41
- }
42
-
43
- // IsBlocked checks if the key is currently blocked.
44
- func (s *RateLimitService) IsBlocked(ctx context.Context, key string) (bool, time.Time, error) {
45
- entry, err := s.repo.Get(ctx, key)
46
- if err != nil {
47
- return false, time.Time{}, err
48
- }
49
- if entry == nil {
50
- return false, time.Time{}, nil
51
- }
52
-
53
- // Check if blocked
54
- if !entry.BlockedUntil.IsZero() {
55
- if s.now().After(entry.BlockedUntil) {
56
- // Block has expired.
57
- // Ideally, we should clear the block state in the repo, but "Get" is read-only.
58
- // The next RecordAttempt will clean it up or we can lazily accept it as allowed.
59
- return false, time.Time{}, nil
60
- }
61
- return true, entry.BlockedUntil, nil
62
- }
63
-
64
- return false, time.Time{}, nil
65
- }
66
-
67
- // RecordAttempt records the result of an action.
68
- func (s *RateLimitService) RecordAttempt(ctx context.Context, key string, success bool) error {
69
- entry, err := s.repo.Get(ctx, key)
70
- if err != nil {
71
- return err
72
- }
73
-
74
- now := s.now()
75
-
76
- if entry == nil {
77
- entry = &ports.RateLimitEntry{
78
- Key: key,
79
- LastAttempt: now,
80
- }
81
- }
82
-
83
- // If the block has expired, reset the state
84
- if !entry.BlockedUntil.IsZero() && now.After(entry.BlockedUntil) {
85
- entry.BlockedUntil = time.Time{}
86
- entry.Count = 0 // Reset count after block expiry
87
- entry.LastAttempt = now
88
- }
89
-
90
- // If currently blocked, we might choose to extend or just return.
91
- // For now, if blocked, we don't count further failures (or we could).
92
- // Let's assume we don't process attempts while blocked (caller should have checked Allow).
93
- if !entry.BlockedUntil.IsZero() && now.Before(entry.BlockedUntil) {
94
- // Still blocked, nothing to update?
95
- // Or should we extend? Let's just keep the existing block.
96
- return nil
97
- }
98
-
99
- // Apply Leaky Bucket Logic (Decay)
100
- if s.config.FailureDecayInterval > 0 {
101
- elapsed := now.Sub(entry.LastAttempt)
102
- decay := int(elapsed / s.config.FailureDecayInterval)
103
- if decay > 0 {
104
- entry.Count -= decay
105
- if entry.Count < 0 {
106
- entry.Count = 0
107
- }
108
- // We effectively used up the time for these decays.
109
- // To be precise with remaining time, we could adjust LastAttempt,
110
- // but for simplicity, we'll just set LastAttempt to now at the end if we update.
111
- }
112
- }
113
-
114
- if success {
115
- // On success, we generally don't increase failure count.
116
- // We could decrease it (reward) or just let time decay it.
117
- // Let's just update LastAttempt to keep the record alive and accurate for decay calculation.
118
- // Actually, if we update LastAttempt without reducing count (via decay), we stop the decay from happening?
119
- // Wait.
120
- // T0: Count=5. Last=T0.
121
- // T10 (Decay=10s): Success. Elapsed=10s. Decay=1. Count=4. Last=T10.
122
- // This works. We applied the decay that happened during the interval.
123
- // So yes, we should run the decay logic and update LastAttempt even on success.
124
- entry.LastAttempt = now
125
- } else {
126
- // Failure
127
- entry.Count++
128
- entry.LastAttempt = now
129
-
130
- if entry.Count >= s.config.MaxFailures {
131
- entry.BlockedUntil = now.Add(s.config.BlockDuration)
132
- // Reset count or keep it at max?
133
- // Often helpful to keep it at max or 0.
134
- // If we keep it at max, future failures after unblock will immediately reblock?
135
- // That depends on if we reset on unblock (handled above).
136
- }
137
- }
138
-
139
-
140
- // Calculate TTL for the storage entry
141
- // It should survive at least until block expires OR until count decays to 0.
142
- ttl := s.config.BlockDuration
143
- if s.config.FailureDecayInterval > 0 {
144
- decayTTL := time.Duration(entry.Count) * s.config.FailureDecayInterval
145
- if decayTTL > ttl {
146
- ttl = decayTTL
147
- }
148
- }
149
- // Add a buffer to TTL
150
- ttl += time.Minute
151
-
152
- return s.repo.Set(ctx, key, entry, ttl)
153
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/domain/services/rate_limit_service_test.go DELETED
@@ -1,200 +0,0 @@
1
- package services
2
-
3
- import (
4
- "context"
5
- "testing"
6
- "time"
7
-
8
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
9
- "github.com/router-for-me/CLIProxyAPI/v6/internal/infrastructure/persistence"
10
- "github.com/stretchr/testify/assert"
11
- )
12
-
13
- func TestRateLimitService(t *testing.T) {
14
- repo := persistence.NewInMemoryRateLimitRepository()
15
- config := RateLimitConfig{
16
- MaxFailures: 3,
17
- FailureDecayInterval: time.Minute,
18
- BlockDuration: 10 * time.Minute,
19
- }
20
-
21
- service := NewRateLimitService(repo, config)
22
-
23
- // Mock time
24
- currentTime := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)
25
- service.now = func() time.Time {
26
- return currentTime
27
- }
28
-
29
- ctx := context.Background()
30
- key := "127.0.0.1"
31
-
32
- // 1. Initial State: Allowed
33
- allowed, err := service.Allow(ctx, key)
34
- assert.NoError(t, err)
35
- assert.True(t, allowed, "Initial state should be allowed")
36
-
37
- // 2. Record 2 Failures
38
- err = service.RecordAttempt(ctx, key, false)
39
- assert.NoError(t, err)
40
- err = service.RecordAttempt(ctx, key, false)
41
- assert.NoError(t, err)
42
-
43
- // Still allowed
44
- allowed, err = service.Allow(ctx, key)
45
- assert.NoError(t, err)
46
- assert.True(t, allowed, "Should be allowed after 2 failures (max 3)")
47
-
48
- // Check underlying state (optional, white-box testing)
49
- entry, _ := repo.Get(ctx, key)
50
- assert.Equal(t, 2, entry.Count)
51
-
52
- // 3. Record 3rd Failure -> Blocked
53
- err = service.RecordAttempt(ctx, key, false)
54
- assert.NoError(t, err)
55
-
56
- allowed, err = service.Allow(ctx, key)
57
- assert.NoError(t, err)
58
- assert.False(t, allowed, "Should be blocked after 3 failures")
59
-
60
- isBlocked, until, err := service.IsBlocked(ctx, key)
61
- assert.NoError(t, err)
62
- assert.True(t, isBlocked)
63
- assert.Equal(t, currentTime.Add(config.BlockDuration), until)
64
-
65
- // 4. Advance time past block duration
66
- currentTime = currentTime.Add(config.BlockDuration).Add(time.Second)
67
-
68
- allowed, err = service.Allow(ctx, key)
69
- assert.NoError(t, err)
70
- assert.True(t, allowed, "Should be allowed after block expires")
71
-
72
- // Record an attempt after expiry - should reset logic
73
- err = service.RecordAttempt(ctx, key, true) // Success attempt
74
- assert.NoError(t, err)
75
-
76
- entry, _ = repo.Get(ctx, key)
77
- assert.Equal(t, 0, entry.Count, "Count should be reset after block expiry")
78
-
79
- // 5. Test Decay
80
- // Reset
81
- currentTime = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
82
- repo = persistence.NewInMemoryRateLimitRepository()
83
- service = NewRateLimitService(repo, config)
84
- service.now = func() time.Time { return currentTime }
85
-
86
- // 2 failures
87
- service.RecordAttempt(ctx, key, false)
88
- service.RecordAttempt(ctx, key, false)
89
-
90
- entry, _ = repo.Get(ctx, key)
91
- assert.Equal(t, 2, entry.Count)
92
-
93
- // Advance time by 1 minute (1 decay interval)
94
- currentTime = currentTime.Add(time.Minute)
95
-
96
- // Record another failure.
97
- // Before record: elapsed=1m, decay=1. Count becomes 1.
98
- // After record: Count becomes 2.
99
- service.RecordAttempt(ctx, key, false)
100
-
101
- entry, _ = repo.Get(ctx, key)
102
- assert.Equal(t, 2, entry.Count, "Count should be 2 (2 decayed to 1, then +1)")
103
-
104
- allowed, err = service.Allow(ctx, key)
105
- assert.True(t, allowed, "Should still be allowed")
106
-
107
- // 6. Test Success resets nothing but updates time (Leaky Bucket Standard)
108
- // Reset
109
- currentTime = time.Date(2023, 1, 1, 13, 0, 0, 0, time.UTC)
110
- repo = persistence.NewInMemoryRateLimitRepository()
111
- service = NewRateLimitService(repo, config)
112
- service.now = func() time.Time { return currentTime }
113
-
114
- service.RecordAttempt(ctx, key, false) // Count 1
115
- currentTime = currentTime.Add(30 * time.Second) // 0.5 decay
116
- service.RecordAttempt(ctx, key, true) // Success. Should update LastAttempt.
117
-
118
- entry, _ = repo.Get(ctx, key)
119
- assert.Equal(t, 1, entry.Count)
120
- assert.Equal(t, currentTime, entry.LastAttempt)
121
-
122
- currentTime = currentTime.Add(30 * time.Second) // Another 0.5 decay. Total 1 min since start.
123
- // But LastAttempt was updated at 30s. So elapsed is 30s. Decay = 0.
124
- // This is the "Leaky Bucket" behavior where consistent activity keeps it full?
125
- // Wait. If I update LastAttempt on success without reducing count, I am resetting the decay timer.
126
- // If I have 0.9 decay pending, and I succeed, I reset timer to 0 decay pending.
127
- // This penalizes frequent successful requests if they happen faster than decay rate?
128
- // No, because success doesn't add to count.
129
- // But it does delay the decay of existing failures.
130
- // If I fail once, then spam successes every second, the failure will never decay because elapsed < interval always.
131
- // This might be unintended.
132
- // FIX: We should accumulate partial decay or NOT update LastAttempt on success if we want purely time-based decay regardless of activity.
133
- // However, usually Rate Limiters *do* care about activity.
134
- // But for "Failure Rate Limiting", success shouldn't prevent failure decay.
135
- // Implementation choice:
136
- // A) Update LastAttempt on success: Active users keep their "failure score" longer. (Strict)
137
- // B) Don't update LastAttempt on success: Failures decay based on absolute time since last failure (or last check).
138
- // My implementation does (A).
139
-
140
- // Let's verify behavior A is what we have.
141
- service.RecordAttempt(ctx, key, false) // Count 1 + 0 (decay) = 2.
142
- // If behavior B (don't update on success), elapsed would be 30s from last failure check? No, LastAttempt was updated on success.
143
- // So we expect Count to be 2.
144
- // If we hadn't updated on success, elapsed would be 60s from first failure. Decay 1. Count would be 1.
145
-
146
- assert.Equal(t, 2, entry.Count + 1) // Logic check, wait.
147
-
148
- // Let's not assert on implementation detail of success-decay interaction unless specified.
149
- // I'll stick to asserting the "failures trigger block" and "time decays failures" basics.
150
- }
151
-
152
- func TestRateLimitService_Cleanup(t *testing.T) {
153
- repo := persistence.NewInMemoryRateLimitRepository()
154
- config := RateLimitConfig{
155
- MaxFailures: 3,
156
- BlockDuration: time.Minute,
157
- }
158
- service := NewRateLimitService(repo, config)
159
-
160
- // Mock time
161
- currentTime := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)
162
- service.now = func() time.Time { return currentTime }
163
-
164
- ctx := context.Background()
165
-
166
- // Add entry
167
- service.RecordAttempt(ctx, "key1", false)
168
-
169
- // Verify exists
170
- entry, _ := repo.Get(ctx, "key1")
171
- assert.NotNil(t, entry)
172
-
173
- // Advance time past expiration (Repo sets TTL = BlockDuration + buffer)
174
- // TTL logic: BlockDuration (1m) + 1m buffer = 2m.
175
- // Wait, code says `ttl += time.Minute`.
176
-
177
- // Manually invoke cleanup on repo?
178
- // Persistence layer relies on expiration check in Get() or manual Cleanup().
179
- // Let's test manual cleanup.
180
-
181
- // Add old entry directly to repo to test Cleanup logic
182
- oldTime := currentTime.Add(-24 * time.Hour)
183
- repo.Set(ctx, "old_key", &ports.RateLimitEntry{
184
- Key: "old_key",
185
- LastAttempt: oldTime,
186
- }, time.Hour)
187
-
188
- // We need to advance "real" time for `Get` to see it as expired?
189
- // `Get` uses `time.Now()`, not the service mocked time.
190
- // Ah, the repository implementation uses `time.Now()` directly!
191
- // My mock only affects the Service.
192
- // Testing expiration in `Get` relies on system time.
193
-
194
- // To test Cleanup properly, we should probably mock time in Repo too, or just test logic that doesn't rely on `time.Now()` inside Repo for this specific unit test,
195
- // OR just trust the logic I wrote:
196
- // `if now.After(item.expiresAt) { delete }`
197
-
198
- // Since I cannot mock time in the Repo (it uses `time.Now`), I will skip strict expiration tests that rely on waiting,
199
- // or assume `Cleanup` works as implemented.
200
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/infrastructure/logging/structured.go CHANGED
@@ -20,7 +20,7 @@ type contextKey string
20
 
21
  const (
22
  // CorrelationIDKey is the context key for correlation IDs
23
- CorrelationIDKey contextKey = "correlation_id"
24
  // ServiceKey is the context key for service name
25
  ServiceKey contextKey = "service_name"
26
  // OperationKey is the context key for operation name
@@ -79,7 +79,7 @@ func (l *StructuredLogger) Configure(cfg *config.Config) error {
79
  }
80
 
81
  logPath := filepath.Join(logDir, "main.log")
82
-
83
  if l.logWriter != nil {
84
  _ = l.logWriter.Close()
85
  }
@@ -232,6 +232,11 @@ func GetCorrelationID(ctx context.Context) string {
232
  if ctx == nil {
233
  return ""
234
  }
 
 
 
 
 
235
  if id, ok := ctx.Value(CorrelationIDKey).(string); ok {
236
  return id
237
  }
@@ -292,4 +297,4 @@ func GetLogger() *StructuredLogger {
292
  // SetLogger sets the global structured logger instance
293
  func SetLogger(logger *StructuredLogger) {
294
  globalLogger = logger
295
- }
 
20
 
21
  const (
22
  // CorrelationIDKey is the context key for correlation IDs
23
+ CorrelationIDKey contextKey = "request_id"
24
  // ServiceKey is the context key for service name
25
  ServiceKey contextKey = "service_name"
26
  // OperationKey is the context key for operation name
 
79
  }
80
 
81
  logPath := filepath.Join(logDir, "main.log")
82
+
83
  if l.logWriter != nil {
84
  _ = l.logWriter.Close()
85
  }
 
232
  if ctx == nil {
233
  return ""
234
  }
235
+ // Try string key first (from Gin middleware)
236
+ if id, ok := ctx.Value("request_id").(string); ok {
237
+ return id
238
+ }
239
+ // Try typed key
240
  if id, ok := ctx.Value(CorrelationIDKey).(string); ok {
241
  return id
242
  }
 
297
  // SetLogger sets the global structured logger instance
298
  func SetLogger(logger *StructuredLogger) {
299
  globalLogger = logger
300
+ }
internal/infrastructure/persistence/auth_repository.go CHANGED
@@ -17,9 +17,9 @@ import (
17
 
18
  // AuthRepository implements the ports.AuthRepository interface
19
  type AuthRepository struct {
20
- authDir string
21
  authManager *coreauth.Manager
22
- mu sync.RWMutex
23
  }
24
 
25
  // NewAuthRepository creates a new AuthRepository
@@ -207,7 +207,7 @@ func (r *AuthRepository) Delete(ctx context.Context, id string) error {
207
  defer r.mu.Unlock()
208
 
209
  fullPath := filepath.Join(r.authDir, id)
210
-
211
  if !strings.HasSuffix(fullPath, ".json") {
212
  fullPath += ".json"
213
  }
@@ -260,7 +260,7 @@ func (r *AuthRepository) DeleteAll(ctx context.Context) (int, error) {
260
  fullPath := filepath.Join(r.authDir, name)
261
  if err := os.Remove(fullPath); err == nil {
262
  deleted++
263
-
264
  // Disable in auth manager
265
  if r.authManager != nil {
266
  if auth, ok := r.authManager.GetByID(name); ok {
@@ -407,17 +407,17 @@ func (r *AuthRepository) mapFileToAuth(file *ports.AuthFile) *coreauth.Auth {
407
  }
408
 
409
  return &coreauth.Auth{
410
- ID: file.ID,
411
- Provider: file.Provider,
412
- FileName: file.FileName,
413
- Label: file.Label,
414
- Status: coreauth.Status(file.Status),
415
- Disabled: file.Disabled,
416
  Unavailable: file.Unavailable,
417
- Metadata: file.Metadata,
418
- Attributes: file.Attributes,
419
- CreatedAt: file.CreatedAt,
420
- UpdatedAt: file.UpdatedAt,
421
  }
422
  }
423
 
@@ -445,4 +445,4 @@ func (r *AuthRepository) SetAuthManager(manager *coreauth.Manager) {
445
  }
446
 
447
  // Ensure AuthRepository implements the interface
448
- var _ ports.AuthRepository = (*AuthRepository)(nil)
 
17
 
18
  // AuthRepository implements the ports.AuthRepository interface
19
  type AuthRepository struct {
20
+ authDir string
21
  authManager *coreauth.Manager
22
+ mu sync.RWMutex
23
  }
24
 
25
  // NewAuthRepository creates a new AuthRepository
 
207
  defer r.mu.Unlock()
208
 
209
  fullPath := filepath.Join(r.authDir, id)
210
+
211
  if !strings.HasSuffix(fullPath, ".json") {
212
  fullPath += ".json"
213
  }
 
260
  fullPath := filepath.Join(r.authDir, name)
261
  if err := os.Remove(fullPath); err == nil {
262
  deleted++
263
+
264
  // Disable in auth manager
265
  if r.authManager != nil {
266
  if auth, ok := r.authManager.GetByID(name); ok {
 
407
  }
408
 
409
  return &coreauth.Auth{
410
+ ID: file.ID,
411
+ Provider: file.Provider,
412
+ FileName: file.FileName,
413
+ Label: file.Label,
414
+ Status: coreauth.Status(file.Status),
415
+ Disabled: file.Disabled,
416
  Unavailable: file.Unavailable,
417
+ Metadata: file.Metadata,
418
+ Attributes: file.Attributes,
419
+ CreatedAt: file.CreatedAt,
420
+ UpdatedAt: file.UpdatedAt,
421
  }
422
  }
423
 
 
445
  }
446
 
447
  // Ensure AuthRepository implements the interface
448
+ var _ ports.AuthRepository = (*AuthRepository)(nil)
internal/infrastructure/persistence/config_repository.go CHANGED
@@ -100,7 +100,7 @@ func (r *ConfigRepository) Validate(ctx context.Context, cfg *config.Config) err
100
  return errors.Wrap(errors.InternalError, "failed to create temp file for validation", err)
101
  }
102
  tmpPath := tmpFile.Name()
103
-
104
  // Cleanup
105
  _ = tmpFile.Close()
106
  defer os.Remove(tmpPath)
@@ -129,4 +129,4 @@ func (r *ConfigRepository) SetConfigPath(path string) {
129
  }
130
 
131
  // Ensure ConfigRepository implements the interface
132
- var _ ports.ConfigRepository = (*ConfigRepository)(nil)
 
100
  return errors.Wrap(errors.InternalError, "failed to create temp file for validation", err)
101
  }
102
  tmpPath := tmpFile.Name()
103
+
104
  // Cleanup
105
  _ = tmpFile.Close()
106
  defer os.Remove(tmpPath)
 
129
  }
130
 
131
  // Ensure ConfigRepository implements the interface
132
+ var _ ports.ConfigRepository = (*ConfigRepository)(nil)
internal/infrastructure/persistence/log_index.go CHANGED
@@ -21,15 +21,15 @@ import (
21
 
22
  // LogIndexEntry represents a single entry in the log index
23
  type LogIndexEntry struct {
24
- RequestID string `json:"request_id"`
25
- Filename string `json:"filename"`
26
- Timestamp time.Time `json:"timestamp"`
27
- Method string `json:"method"`
28
- URL string `json:"url"`
29
- StatusCode int `json:"status_code"`
30
- Size int64 `json:"size"`
31
- Offset int64 `json:"offset"` // Byte offset in file for O(1) access
32
- Tags map[string]string `json:"tags"` // Optional tags for filtering
33
  }
34
 
35
  // LogIndex provides O(1) lookup for log entries by various criteria
@@ -557,10 +557,10 @@ func (r *IndexedLogRepository) GetIndexStats() map[string]interface{} {
557
  defer r.mu.RUnlock()
558
 
559
  return map[string]interface{}{
560
- "total_entries": r.index.Size(),
561
- "is_dirty": r.index.IsDirty(),
562
- "index_path": r.indexPath,
563
- "last_persisted": r.index.lastPersisted,
564
  }
565
  }
566
 
 
21
 
22
  // LogIndexEntry represents a single entry in the log index
23
  type LogIndexEntry struct {
24
+ RequestID string `json:"request_id"`
25
+ Filename string `json:"filename"`
26
+ Timestamp time.Time `json:"timestamp"`
27
+ Method string `json:"method"`
28
+ URL string `json:"url"`
29
+ StatusCode int `json:"status_code"`
30
+ Size int64 `json:"size"`
31
+ Offset int64 `json:"offset"` // Byte offset in file for O(1) access
32
+ Tags map[string]string `json:"tags"` // Optional tags for filtering
33
  }
34
 
35
  // LogIndex provides O(1) lookup for log entries by various criteria
 
557
  defer r.mu.RUnlock()
558
 
559
  return map[string]interface{}{
560
+ "total_entries": r.index.Size(),
561
+ "is_dirty": r.index.IsDirty(),
562
+ "index_path": r.indexPath,
563
+ "last_persisted": r.index.lastPersisted,
564
  }
565
  }
566
 
internal/infrastructure/persistence/log_repository.go CHANGED
@@ -377,15 +377,15 @@ func (r *LogRepository) DownloadRequestErrorLog(ctx context.Context, filename st
377
  func (r *LogRepository) GetLogDirectory() string {
378
  r.mu.RLock()
379
  defer r.mu.RUnlock()
380
-
381
  if r.logDir != "" {
382
  return r.logDir
383
  }
384
-
385
  if r.cfg != nil {
386
  return logging.ResolveLogDirectory(r.cfg)
387
  }
388
-
389
  return ""
390
  }
391
 
@@ -393,7 +393,7 @@ func (r *LogRepository) GetLogDirectory() string {
393
  func (r *LogRepository) IsLoggingEnabled() bool {
394
  r.mu.RLock()
395
  defer r.mu.RUnlock()
396
-
397
  if r.cfg == nil {
398
  return false
399
  }
@@ -631,4 +631,4 @@ func parseTimestamp(line string) int64 {
631
  }
632
 
633
  // Ensure LogRepository implements the interface
634
- var _ ports.LogRepository = (*LogRepository)(nil)
 
377
  func (r *LogRepository) GetLogDirectory() string {
378
  r.mu.RLock()
379
  defer r.mu.RUnlock()
380
+
381
  if r.logDir != "" {
382
  return r.logDir
383
  }
384
+
385
  if r.cfg != nil {
386
  return logging.ResolveLogDirectory(r.cfg)
387
  }
388
+
389
  return ""
390
  }
391
 
 
393
  func (r *LogRepository) IsLoggingEnabled() bool {
394
  r.mu.RLock()
395
  defer r.mu.RUnlock()
396
+
397
  if r.cfg == nil {
398
  return false
399
  }
 
631
  }
632
 
633
  // Ensure LogRepository implements the interface
634
+ var _ ports.LogRepository = (*LogRepository)(nil)