codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,16 @@
1
+ """Authentication module for CodeFRAME."""
2
+ from codeframe.auth.models import User
3
+ from codeframe.auth.schemas import UserRead, UserCreate, UserUpdate
4
+ from codeframe.auth.manager import fastapi_users, auth_backend, current_active_user
5
+ from codeframe.auth.dependencies import get_current_user
6
+
7
+ __all__ = [
8
+ "User",
9
+ "UserRead",
10
+ "UserCreate",
11
+ "UserUpdate",
12
+ "fastapi_users",
13
+ "auth_backend",
14
+ "current_active_user",
15
+ "get_current_user",
16
+ ]
@@ -0,0 +1,238 @@
1
+ """API key management endpoints.
2
+
3
+ Provides endpoints for creating, listing, and revoking API keys.
4
+ API key creation requires JWT authentication (not API key auth) to prevent
5
+ privilege escalation attacks.
6
+
7
+ Uses core/api_key_service.py for business logic (shared with CLI).
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from datetime import datetime, timezone
13
+ from typing import List, Optional
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
16
+ from pydantic import BaseModel, field_validator
17
+
18
+ from codeframe.auth.dependencies import get_current_user, require_auth
19
+ from codeframe.auth.models import User
20
+ from codeframe.auth.api_keys import (
21
+ validate_scopes,
22
+ SCOPE_READ,
23
+ SCOPE_WRITE,
24
+ )
25
+ from codeframe.core.api_key_service import ApiKeyService
26
+ from codeframe.platform_store.database import Database
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ router = APIRouter(prefix="/api/auth/api-keys", tags=["auth", "api-keys"])
31
+
32
+
33
+ # =============================================================================
34
+ # Pydantic Models
35
+ # =============================================================================
36
+
37
+
38
+ class CreateApiKeyRequest(BaseModel):
39
+ """Request body for creating an API key."""
40
+
41
+ name: str
42
+ scopes: List[str] = [SCOPE_READ, SCOPE_WRITE]
43
+ expires_at: Optional[datetime] = None
44
+
45
+ @field_validator("scopes")
46
+ @classmethod
47
+ def validate_scopes_field(cls, v: List[str]) -> List[str]:
48
+ if not validate_scopes(v):
49
+ raise ValueError(f"Invalid scopes: {v}. Valid scopes are: read, write, admin")
50
+ # Deduplicate while preserving order
51
+ seen = set()
52
+ deduped = [s for s in v if not (s in seen or seen.add(s))]
53
+ if not deduped:
54
+ raise ValueError("At least one scope is required")
55
+ return deduped
56
+
57
+ @field_validator("expires_at")
58
+ @classmethod
59
+ def validate_expires_at(cls, v: Optional[datetime]) -> Optional[datetime]:
60
+ if v is not None:
61
+ now = datetime.now(timezone.utc)
62
+ # Handle naive datetime by assuming UTC
63
+ if v.tzinfo is None:
64
+ v = v.replace(tzinfo=timezone.utc)
65
+ if v <= now:
66
+ raise ValueError("expires_at must be in the future")
67
+ return v
68
+
69
+
70
+ class CreateApiKeyResponse(BaseModel):
71
+ """Response body for API key creation."""
72
+
73
+ key: str # Full key - shown only once
74
+ id: str
75
+ prefix: str
76
+ created_at: str
77
+
78
+
79
+ class ApiKeyInfoResponse(BaseModel):
80
+ """API key information (without sensitive data)."""
81
+
82
+ id: str
83
+ name: str
84
+ prefix: str
85
+ scopes: List[str]
86
+ created_at: str
87
+ last_used_at: Optional[str]
88
+ expires_at: Optional[str]
89
+ is_active: bool
90
+
91
+
92
+ class RevokeApiKeyResponse(BaseModel):
93
+ """Response body for API key revocation."""
94
+
95
+ id: str
96
+ revoked: bool
97
+
98
+
99
+ # =============================================================================
100
+ # Database Helper
101
+ # =============================================================================
102
+
103
+
104
+ def get_db(request: Request) -> Database:
105
+ """Get database instance from app state (singleton managed by lifespan handler).
106
+
107
+ Uses the app-scoped database to avoid per-request connection leaks.
108
+ Falls back to DATABASE_PATH env var if app.state.db not available.
109
+ """
110
+ # Prefer app-scoped singleton (set by lifespan handler in server.py)
111
+ db = getattr(request.app.state, "db", None)
112
+ if db is not None:
113
+ return db
114
+
115
+ # Fallback for tests or standalone usage
116
+ db = getattr(request.state, "db", None)
117
+ if db is None:
118
+ logger.warning("No db in app.state, creating fallback connection")
119
+ db_path = os.getenv(
120
+ "DATABASE_PATH",
121
+ os.path.join(os.getcwd(), ".codeframe", "state.db")
122
+ )
123
+ db = Database(db_path)
124
+ db.initialize()
125
+ request.state.db = db
126
+ return db
127
+
128
+
129
+ def get_api_key_service(request: Request) -> ApiKeyService:
130
+ """Get API key service instance."""
131
+ db = get_db(request)
132
+ return ApiKeyService(db)
133
+
134
+
135
+ # =============================================================================
136
+ # Endpoints
137
+ # =============================================================================
138
+
139
+
140
+ @router.post("", status_code=status.HTTP_201_CREATED, response_model=CreateApiKeyResponse)
141
+ async def create_api_key(
142
+ request: Request,
143
+ body: CreateApiKeyRequest,
144
+ current_user: User = Depends(get_current_user), # JWT required, not API key
145
+ ):
146
+ """Create a new API key.
147
+
148
+ **Important**: This endpoint requires JWT authentication. API keys cannot
149
+ be used to create new API keys (prevents privilege escalation).
150
+
151
+ The full API key is returned only once. Store it securely - it cannot
152
+ be retrieved again.
153
+
154
+ Args:
155
+ body: API key configuration (name, scopes, optional expiration)
156
+
157
+ Returns:
158
+ Created API key details including the full key (shown once)
159
+ """
160
+ service = get_api_key_service(request)
161
+
162
+ result = service.create_api_key(
163
+ user_id=current_user.id,
164
+ name=body.name,
165
+ scopes=body.scopes,
166
+ expires_at=body.expires_at,
167
+ )
168
+
169
+ return CreateApiKeyResponse(
170
+ key=result.key,
171
+ id=result.id,
172
+ prefix=result.prefix,
173
+ created_at=result.created_at,
174
+ )
175
+
176
+
177
+ @router.get("", response_model=List[ApiKeyInfoResponse])
178
+ async def list_api_keys(
179
+ request: Request,
180
+ auth: dict = Depends(require_auth), # Either JWT or API key
181
+ ):
182
+ """List all API keys for the authenticated user.
183
+
184
+ Does not expose the key hash or full key. Shows prefix for identification.
185
+
186
+ Returns:
187
+ List of API key information
188
+ """
189
+ service = get_api_key_service(request)
190
+
191
+ keys = service.list_api_keys(user_id=auth["user_id"])
192
+
193
+ return [
194
+ ApiKeyInfoResponse(
195
+ id=k.id,
196
+ name=k.name,
197
+ prefix=k.prefix,
198
+ scopes=k.scopes,
199
+ created_at=k.created_at,
200
+ last_used_at=k.last_used_at,
201
+ expires_at=k.expires_at,
202
+ is_active=k.is_active,
203
+ )
204
+ for k in keys
205
+ ]
206
+
207
+
208
+ @router.delete("/{key_id}", response_model=RevokeApiKeyResponse)
209
+ async def revoke_api_key(
210
+ request: Request,
211
+ key_id: str,
212
+ auth: dict = Depends(require_auth), # Either JWT or API key
213
+ ):
214
+ """Revoke an API key.
215
+
216
+ The key will be marked as inactive and can no longer be used for
217
+ authentication.
218
+
219
+ Args:
220
+ key_id: The API key ID to revoke
221
+
222
+ Returns:
223
+ Confirmation of revocation
224
+
225
+ Raises:
226
+ 404: If key not found or not owned by user
227
+ """
228
+ service = get_api_key_service(request)
229
+
230
+ success = service.revoke_api_key(key_id, user_id=auth["user_id"])
231
+
232
+ if not success:
233
+ raise HTTPException(
234
+ status_code=status.HTTP_404_NOT_FOUND,
235
+ detail="API key not found",
236
+ )
237
+
238
+ return RevokeApiKeyResponse(id=key_id, revoked=True)
@@ -0,0 +1,156 @@
1
+ """API key generation, verification, and utility functions.
2
+
3
+ Provides utilities for creating and validating API keys for server authentication.
4
+ API keys follow the format: cf_{environment}_{32_hex_chars}
5
+
6
+ Security considerations:
7
+ - Keys are hashed using SHA256 (industry standard for high-entropy API keys)
8
+ - API keys have 128 bits of entropy from secrets.token_hex(16)
9
+ - SHA256 is appropriate here (vs bcrypt for passwords) because API keys
10
+ are already high-entropy random strings, not human-chosen passwords
11
+ - Only the prefix is stored for efficient lookup
12
+ - Full key is shown only once during creation
13
+
14
+ This approach matches GitHub, Stripe, and other API key implementations.
15
+ """
16
+
17
+ import hashlib
18
+ import hmac
19
+ import secrets
20
+ import logging
21
+ from typing import Tuple
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Scope constants for API key permissions
26
+ SCOPE_READ = "read"
27
+ SCOPE_WRITE = "write"
28
+ SCOPE_ADMIN = "admin"
29
+
30
+ # All valid scopes
31
+ VALID_SCOPES = frozenset({SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN})
32
+
33
+ # Key format constants
34
+ # PREFIX_LENGTH=12 captures "cf_live_" (8) + 4 random chars for efficient DB lookup
35
+ # Example: "cf_live_1abc" from "cf_live_1abc2b2f78d93788b9fa00e383832be3"
36
+ KEY_PREFIX_LENGTH = 12
37
+ KEY_RANDOM_BYTES = 16 # 16 bytes = 32 hex characters
38
+
39
+
40
+ def generate_api_key(environment: str = "live") -> Tuple[str, str, str]:
41
+ """Generate a new API key with SHA256 hash.
42
+
43
+ Args:
44
+ environment: Environment identifier ('live' or 'test')
45
+
46
+ Returns:
47
+ Tuple of (full_key, key_hash, prefix):
48
+ - full_key: The complete API key to give to the user (shown once)
49
+ - key_hash: SHA256 hash of the key for storage (bcrypt-like format)
50
+ - prefix: First 12 characters for efficient lookup
51
+
52
+ Example:
53
+ >>> key, hash, prefix = generate_api_key()
54
+ >>> key
55
+ 'cf_live_a1b2c3d4e5f6789012345678abcdef01'
56
+ >>> prefix
57
+ 'cf_live_a1b2'
58
+ """
59
+ # Generate 16 random bytes = 32 hex characters (128 bits entropy)
60
+ random_part = secrets.token_hex(KEY_RANDOM_BYTES)
61
+
62
+ # Construct the full key: cf_{environment}_{32_hex}
63
+ full_key = f"cf_{environment}_{random_part}"
64
+
65
+ # Hash using SHA256 - appropriate for high-entropy API keys
66
+ # Format: $sha256${hex_digest} to distinguish from bcrypt hashes
67
+ digest = hashlib.sha256(full_key.encode()).hexdigest()
68
+ key_hash = f"$sha256${digest}"
69
+
70
+ # Extract prefix for database lookup optimization
71
+ prefix = full_key[:KEY_PREFIX_LENGTH]
72
+
73
+ logger.debug(f"Generated API key with prefix {prefix}")
74
+
75
+ return full_key, key_hash, prefix
76
+
77
+
78
+ def verify_api_key(key: str, key_hash: str) -> bool:
79
+ """Verify an API key against its stored hash.
80
+
81
+ Supports both SHA256 (preferred for API keys) and bcrypt hashes.
82
+
83
+ Args:
84
+ key: The full API key provided by the user
85
+ key_hash: The stored hash (SHA256 or bcrypt format)
86
+
87
+ Returns:
88
+ True if the key matches the hash, False otherwise
89
+
90
+ Note:
91
+ Uses constant-time comparison to prevent timing attacks.
92
+ Returns False (not exception) for invalid inputs.
93
+ """
94
+ if not key or not key_hash:
95
+ return False
96
+
97
+ try:
98
+ if key_hash.startswith("$sha256$"):
99
+ # SHA256 hash verification with constant-time comparison
100
+ expected_digest = key_hash[8:] # Remove "$sha256$" prefix
101
+ actual_digest = hashlib.sha256(key.encode()).hexdigest()
102
+ return hmac.compare_digest(expected_digest, actual_digest)
103
+
104
+ elif key_hash.startswith("$2"):
105
+ # Bcrypt hash (legacy or if we switch back)
106
+ # Import here to avoid startup issues with bcrypt compatibility
107
+ from passlib.context import CryptContext
108
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
109
+ return pwd_context.verify(key, key_hash)
110
+
111
+ else:
112
+ # Unknown hash format
113
+ logger.warning(f"Unknown hash format: {key_hash[:10]}...")
114
+ return False
115
+
116
+ except Exception as e:
117
+ # Handle malformed hashes gracefully
118
+ logger.debug(f"API key verification failed: {e}")
119
+ return False
120
+
121
+
122
+ def extract_prefix(key: str) -> str:
123
+ """Extract the prefix from a full API key for database lookup.
124
+
125
+ The prefix is the first 12 characters (cf_{env}_xxxx) which provides
126
+ enough entropy for efficient indexed lookup while avoiding full key
127
+ comparison in the database.
128
+
129
+ Args:
130
+ key: The full API key
131
+
132
+ Returns:
133
+ The 12-character prefix
134
+
135
+ Raises:
136
+ ValueError: If key is shorter than 12 characters
137
+ """
138
+ if len(key) < KEY_PREFIX_LENGTH:
139
+ raise ValueError(f"API key too short: expected at least {KEY_PREFIX_LENGTH} chars")
140
+
141
+ return key[:KEY_PREFIX_LENGTH]
142
+
143
+
144
+ def validate_scopes(scopes: list[str]) -> bool:
145
+ """Validate that all scopes in the list are valid.
146
+
147
+ Args:
148
+ scopes: List of scope strings to validate
149
+
150
+ Returns:
151
+ True if all scopes are valid and list is non-empty, False otherwise
152
+ """
153
+ if not scopes:
154
+ return False
155
+
156
+ return all(scope in VALID_SCOPES for scope in scopes)