codetether 1.2.2__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 (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. ui/monitor.js +2662 -0
@@ -0,0 +1,721 @@
1
+ """
2
+ Self-service user authentication for mid-market/consumer users.
3
+
4
+ This module provides email/password authentication that bypasses Keycloak,
5
+ designed for self-service signups from the Zapier/ClickFunnels market.
6
+
7
+ Features:
8
+ - Email/password registration with verification
9
+ - JWT token-based sessions
10
+ - Password reset flow
11
+ - API key generation
12
+ - Usage tracking/limits
13
+ """
14
+
15
+ import os
16
+ import uuid
17
+ import hashlib
18
+ import secrets
19
+ import logging
20
+ from datetime import datetime, timedelta
21
+ from typing import Optional, Dict, Any, List
22
+
23
+ import bcrypt
24
+ from jose import jwt, JWTError
25
+ from pydantic import BaseModel, EmailStr, Field
26
+ from fastapi import APIRouter, HTTPException, Depends, Request, BackgroundTasks
27
+ from fastapi.security import (
28
+ HTTPBearer,
29
+ HTTPAuthorizationCredentials,
30
+ OAuth2PasswordRequestForm,
31
+ )
32
+
33
+ from .database import get_pool
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # JWT Configuration
38
+ JWT_SECRET = os.environ.get('JWT_SECRET', secrets.token_urlsafe(32))
39
+ JWT_ALGORITHM = 'HS256'
40
+ JWT_EXPIRATION_HOURS = int(os.environ.get('JWT_EXPIRATION_HOURS', '24'))
41
+
42
+ # Security
43
+ security = HTTPBearer(auto_error=False)
44
+
45
+ router = APIRouter(prefix='/v1/users', tags=['User Authentication'])
46
+
47
+
48
+ # ========================================
49
+ # Request/Response Models
50
+ # ========================================
51
+
52
+
53
+ class RegisterRequest(BaseModel):
54
+ """User registration request."""
55
+
56
+ email: EmailStr
57
+ password: str = Field(..., min_length=8)
58
+ first_name: Optional[str] = None
59
+ last_name: Optional[str] = None
60
+ referral_source: Optional[str] = None
61
+
62
+
63
+ class RegisterResponse(BaseModel):
64
+ """Registration response."""
65
+
66
+ user_id: str
67
+ email: str
68
+ message: str
69
+
70
+
71
+ class LoginRequest(BaseModel):
72
+ """Login request."""
73
+
74
+ email: EmailStr
75
+ password: str
76
+
77
+
78
+ class LoginResponse(BaseModel):
79
+ """Login response with JWT tokens."""
80
+
81
+ access_token: str
82
+ token_type: str = 'bearer'
83
+ expires_at: str
84
+ user: Dict[str, Any]
85
+
86
+
87
+ class UserResponse(BaseModel):
88
+ """User profile response."""
89
+
90
+ id: str
91
+ email: str
92
+ first_name: Optional[str]
93
+ last_name: Optional[str]
94
+ status: str
95
+ email_verified: bool
96
+ tasks_used_this_month: int
97
+ tasks_limit: int
98
+ created_at: str
99
+
100
+
101
+ class PasswordResetRequest(BaseModel):
102
+ """Request password reset."""
103
+
104
+ email: EmailStr
105
+
106
+
107
+ class PasswordResetConfirm(BaseModel):
108
+ """Confirm password reset with token."""
109
+
110
+ token: str
111
+ new_password: str = Field(..., min_length=8)
112
+
113
+
114
+ class CreateApiKeyRequest(BaseModel):
115
+ """Create API key request."""
116
+
117
+ name: str
118
+ scopes: Optional[List[str]] = None
119
+ expires_in_days: Optional[int] = None # None = never expires
120
+
121
+
122
+ class ApiKeyResponse(BaseModel):
123
+ """API key response (key only shown once!)."""
124
+
125
+ id: str
126
+ name: str
127
+ key: str # Only returned on creation
128
+ key_prefix: str
129
+ scopes: List[str]
130
+ created_at: str
131
+ expires_at: Optional[str]
132
+
133
+
134
+ # ========================================
135
+ # Password Hashing
136
+ # ========================================
137
+
138
+
139
+ def hash_password(password: str) -> str:
140
+ """Hash a password using bcrypt."""
141
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode(
142
+ 'utf-8'
143
+ )
144
+
145
+
146
+ def verify_password(password: str, password_hash: str) -> bool:
147
+ """Verify a password against its hash."""
148
+ return bcrypt.checkpw(
149
+ password.encode('utf-8'), password_hash.encode('utf-8')
150
+ )
151
+
152
+
153
+ def hash_api_key(key: str) -> str:
154
+ """Hash an API key using SHA256."""
155
+ return hashlib.sha256(key.encode('utf-8')).hexdigest()
156
+
157
+
158
+ # ========================================
159
+ # JWT Token Management
160
+ # ========================================
161
+
162
+
163
+ def create_access_token(
164
+ user_id: str, email: str, expires_delta: Optional[timedelta] = None
165
+ ) -> tuple[str, datetime]:
166
+ """Create a JWT access token."""
167
+ if expires_delta is None:
168
+ expires_delta = timedelta(hours=JWT_EXPIRATION_HOURS)
169
+
170
+ expires_at = datetime.utcnow() + expires_delta
171
+
172
+ payload = {
173
+ 'sub': user_id,
174
+ 'email': email,
175
+ 'type': 'access',
176
+ 'exp': expires_at,
177
+ 'iat': datetime.utcnow(),
178
+ }
179
+
180
+ token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
181
+ return token, expires_at
182
+
183
+
184
+ def decode_access_token(token: str) -> Dict[str, Any]:
185
+ """Decode and validate a JWT access token."""
186
+ try:
187
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
188
+ if payload.get('type') != 'access':
189
+ raise HTTPException(status_code=401, detail='Invalid token type')
190
+ return payload
191
+ except JWTError as e:
192
+ raise HTTPException(status_code=401, detail=f'Invalid token: {str(e)}')
193
+
194
+
195
+ # ========================================
196
+ # Current User Dependency
197
+ # ========================================
198
+
199
+
200
+ async def get_current_user(
201
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
202
+ ) -> Optional[Dict[str, Any]]:
203
+ """Get current authenticated user from JWT or API key."""
204
+ if not credentials:
205
+ return None
206
+
207
+ token = credentials.credentials
208
+
209
+ # Check if it's an API key (starts with 'ct_')
210
+ if token.startswith('ct_'):
211
+ return await _get_user_from_api_key(token)
212
+
213
+ # Otherwise treat as JWT
214
+ try:
215
+ payload = decode_access_token(token)
216
+ user = await get_user_by_id(payload['sub'])
217
+ if not user:
218
+ raise HTTPException(status_code=401, detail='User not found')
219
+ if user['status'] != 'active':
220
+ raise HTTPException(status_code=401, detail='Account is not active')
221
+ return user
222
+ except HTTPException:
223
+ raise
224
+ except Exception as e:
225
+ logger.error(f'Token validation error: {e}')
226
+ raise HTTPException(status_code=401, detail='Invalid token')
227
+
228
+
229
+ async def require_user(
230
+ user: Optional[Dict[str, Any]] = Depends(get_current_user),
231
+ ) -> Dict[str, Any]:
232
+ """Require authenticated user."""
233
+ if not user:
234
+ raise HTTPException(status_code=401, detail='Authentication required')
235
+ return user
236
+
237
+
238
+ async def _get_user_from_api_key(api_key: str) -> Dict[str, Any]:
239
+ """Validate API key and return associated user."""
240
+ pool = await get_pool()
241
+ if not pool:
242
+ raise HTTPException(status_code=503, detail='Database unavailable')
243
+
244
+ key_hash = hash_api_key(api_key)
245
+
246
+ async with pool.acquire() as conn:
247
+ row = await conn.fetchrow(
248
+ """
249
+ SELECT ak.*, u.*
250
+ FROM api_keys ak
251
+ JOIN users u ON ak.user_id = u.id
252
+ WHERE ak.key_hash = $1 AND ak.status = 'active'
253
+ """,
254
+ key_hash,
255
+ )
256
+
257
+ if not row:
258
+ raise HTTPException(status_code=401, detail='Invalid API key')
259
+
260
+ # Check expiration
261
+ if row['expires_at'] and row['expires_at'] < datetime.utcnow():
262
+ raise HTTPException(status_code=401, detail='API key expired')
263
+
264
+ # Update last used
265
+ await conn.execute(
266
+ 'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1', row['id']
267
+ )
268
+
269
+ return {
270
+ 'id': row['user_id'],
271
+ 'email': row['email'],
272
+ 'first_name': row['first_name'],
273
+ 'last_name': row['last_name'],
274
+ 'status': row['status'],
275
+ 'tasks_used_this_month': row['tasks_used_this_month'],
276
+ 'tasks_limit': row['tasks_limit'],
277
+ 'api_key_scopes': row['scopes'],
278
+ }
279
+
280
+
281
+ # ========================================
282
+ # Database Operations
283
+ # ========================================
284
+
285
+
286
+ async def get_user_by_email(email: str) -> Optional[Dict[str, Any]]:
287
+ """Get user by email."""
288
+ pool = await get_pool()
289
+ if not pool:
290
+ return None
291
+
292
+ async with pool.acquire() as conn:
293
+ row = await conn.fetchrow(
294
+ 'SELECT * FROM users WHERE email = $1', email.lower()
295
+ )
296
+ return dict(row) if row else None
297
+
298
+
299
+ async def get_user_by_id(user_id: str) -> Optional[Dict[str, Any]]:
300
+ """Get user by ID."""
301
+ pool = await get_pool()
302
+ if not pool:
303
+ return None
304
+
305
+ async with pool.acquire() as conn:
306
+ row = await conn.fetchrow('SELECT * FROM users WHERE id = $1', user_id)
307
+ return dict(row) if row else None
308
+
309
+
310
+ async def create_user(
311
+ email: str,
312
+ password: str,
313
+ first_name: Optional[str] = None,
314
+ last_name: Optional[str] = None,
315
+ referral_source: Optional[str] = None,
316
+ ) -> Dict[str, Any]:
317
+ """Create a new user."""
318
+ pool = await get_pool()
319
+ if not pool:
320
+ raise HTTPException(status_code=503, detail='Database unavailable')
321
+
322
+ user_id = f'user_{uuid.uuid4().hex[:16]}'
323
+ password_hash = hash_password(password)
324
+ verification_token = secrets.token_urlsafe(32)
325
+ verification_expires = datetime.utcnow() + timedelta(hours=24)
326
+
327
+ async with pool.acquire() as conn:
328
+ # Check if email exists
329
+ existing = await conn.fetchval(
330
+ 'SELECT id FROM users WHERE email = $1', email.lower()
331
+ )
332
+ if existing:
333
+ raise HTTPException(
334
+ status_code=400, detail='Email already registered'
335
+ )
336
+
337
+ # Create user
338
+ await conn.execute(
339
+ """
340
+ INSERT INTO users (
341
+ id, email, password_hash, first_name, last_name,
342
+ status, email_verified, email_verification_token, email_verification_expires,
343
+ referral_source, tasks_limit
344
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
345
+ """,
346
+ user_id,
347
+ email.lower(),
348
+ password_hash,
349
+ first_name,
350
+ last_name,
351
+ 'active', # Skip email verification for MVP - activate immediately
352
+ True, # Mark as verified for MVP
353
+ verification_token,
354
+ verification_expires,
355
+ referral_source,
356
+ 10, # Free tier: 10 tasks/month
357
+ )
358
+
359
+ return {
360
+ 'id': user_id,
361
+ 'email': email.lower(),
362
+ 'first_name': first_name,
363
+ 'last_name': last_name,
364
+ 'verification_token': verification_token,
365
+ }
366
+
367
+
368
+ async def update_user_login(user_id: str) -> None:
369
+ """Update user's last login timestamp."""
370
+ pool = await get_pool()
371
+ if not pool:
372
+ return
373
+
374
+ async with pool.acquire() as conn:
375
+ await conn.execute(
376
+ 'UPDATE users SET last_login_at = NOW() WHERE id = $1', user_id
377
+ )
378
+
379
+
380
+ async def increment_task_usage(user_id: str) -> bool:
381
+ """Increment user's task usage. Returns True if within limit."""
382
+ pool = await get_pool()
383
+ if not pool:
384
+ return False
385
+
386
+ async with pool.acquire() as conn:
387
+ # Atomic increment and check
388
+ result = await conn.fetchrow(
389
+ """
390
+ UPDATE users
391
+ SET tasks_used_this_month = tasks_used_this_month + 1,
392
+ updated_at = NOW()
393
+ WHERE id = $1
394
+ RETURNING tasks_used_this_month, tasks_limit
395
+ """,
396
+ user_id,
397
+ )
398
+
399
+ if result:
400
+ return result['tasks_used_this_month'] <= result['tasks_limit']
401
+ return False
402
+
403
+
404
+ # ========================================
405
+ # API Endpoints
406
+ # ========================================
407
+
408
+
409
+ @router.post('/register', response_model=RegisterResponse)
410
+ async def register(request: RegisterRequest, background_tasks: BackgroundTasks):
411
+ """
412
+ Register a new user account.
413
+
414
+ Creates a new user with email/password authentication.
415
+ For MVP, accounts are activated immediately without email verification.
416
+ """
417
+ try:
418
+ user = await create_user(
419
+ email=request.email,
420
+ password=request.password,
421
+ first_name=request.first_name,
422
+ last_name=request.last_name,
423
+ referral_source=request.referral_source,
424
+ )
425
+
426
+ # TODO: Send welcome email in background
427
+ # background_tasks.add_task(send_welcome_email, user['email'], user['first_name'])
428
+
429
+ logger.info(f'New user registered: {user["email"]}')
430
+
431
+ return RegisterResponse(
432
+ user_id=user['id'],
433
+ email=user['email'],
434
+ message='Account created successfully. You can now log in.',
435
+ )
436
+
437
+ except HTTPException:
438
+ raise
439
+ except Exception as e:
440
+ logger.error(f'Registration error: {e}')
441
+ raise HTTPException(status_code=500, detail='Registration failed')
442
+
443
+
444
+ @router.post('/login', response_model=LoginResponse)
445
+ async def login(request: LoginRequest):
446
+ """
447
+ Login with email and password.
448
+
449
+ Returns a JWT access token for subsequent API calls.
450
+ """
451
+ user = await get_user_by_email(request.email)
452
+
453
+ if not user:
454
+ raise HTTPException(status_code=401, detail='Invalid email or password')
455
+
456
+ if not verify_password(request.password, user['password_hash']):
457
+ raise HTTPException(status_code=401, detail='Invalid email or password')
458
+
459
+ if user['status'] != 'active':
460
+ if user['status'] == 'pending_verification':
461
+ raise HTTPException(
462
+ status_code=401, detail='Please verify your email first'
463
+ )
464
+ raise HTTPException(status_code=401, detail='Account is not active')
465
+
466
+ # Create access token
467
+ access_token, expires_at = create_access_token(user['id'], user['email'])
468
+
469
+ # Update last login
470
+ await update_user_login(user['id'])
471
+
472
+ logger.info(f'User logged in: {user["email"]}')
473
+
474
+ return LoginResponse(
475
+ access_token=access_token,
476
+ expires_at=expires_at.isoformat(),
477
+ user={
478
+ 'id': user['id'],
479
+ 'email': user['email'],
480
+ 'first_name': user['first_name'],
481
+ 'last_name': user['last_name'],
482
+ 'tasks_used_this_month': user['tasks_used_this_month'],
483
+ 'tasks_limit': user['tasks_limit'],
484
+ },
485
+ )
486
+
487
+
488
+ @router.get('/me', response_model=UserResponse)
489
+ async def get_me(user: Dict[str, Any] = Depends(require_user)):
490
+ """Get current user profile."""
491
+ return UserResponse(
492
+ id=user['id'],
493
+ email=user['email'],
494
+ first_name=user.get('first_name'),
495
+ last_name=user.get('last_name'),
496
+ status=user['status'],
497
+ email_verified=user.get('email_verified', False),
498
+ tasks_used_this_month=user['tasks_used_this_month'],
499
+ tasks_limit=user['tasks_limit'],
500
+ created_at=user['created_at'].isoformat()
501
+ if user.get('created_at')
502
+ else '',
503
+ )
504
+
505
+
506
+ @router.post('/password-reset/request')
507
+ async def request_password_reset(
508
+ request: PasswordResetRequest, background_tasks: BackgroundTasks
509
+ ):
510
+ """
511
+ Request a password reset email.
512
+
513
+ Always returns success to prevent email enumeration.
514
+ """
515
+ user = await get_user_by_email(request.email)
516
+
517
+ if user:
518
+ pool = await get_pool()
519
+ if pool:
520
+ reset_token = secrets.token_urlsafe(32)
521
+ reset_expires = datetime.utcnow() + timedelta(hours=1)
522
+
523
+ async with pool.acquire() as conn:
524
+ await conn.execute(
525
+ """
526
+ UPDATE users
527
+ SET password_reset_token = $1, password_reset_expires = $2
528
+ WHERE id = $3
529
+ """,
530
+ reset_token,
531
+ reset_expires,
532
+ user['id'],
533
+ )
534
+
535
+ # TODO: Send password reset email
536
+ # background_tasks.add_task(send_password_reset_email, user['email'], reset_token)
537
+ logger.info(f'Password reset requested for: {user["email"]}')
538
+
539
+ return {
540
+ 'message': 'If that email exists, a password reset link has been sent.'
541
+ }
542
+
543
+
544
+ @router.post('/password-reset/confirm')
545
+ async def confirm_password_reset(request: PasswordResetConfirm):
546
+ """Confirm password reset with token."""
547
+ pool = await get_pool()
548
+ if not pool:
549
+ raise HTTPException(status_code=503, detail='Database unavailable')
550
+
551
+ async with pool.acquire() as conn:
552
+ user = await conn.fetchrow(
553
+ """
554
+ SELECT id FROM users
555
+ WHERE password_reset_token = $1
556
+ AND password_reset_expires > NOW()
557
+ """,
558
+ request.token,
559
+ )
560
+
561
+ if not user:
562
+ raise HTTPException(
563
+ status_code=400, detail='Invalid or expired reset token'
564
+ )
565
+
566
+ # Update password
567
+ new_hash = hash_password(request.new_password)
568
+ await conn.execute(
569
+ """
570
+ UPDATE users
571
+ SET password_hash = $1,
572
+ password_reset_token = NULL,
573
+ password_reset_expires = NULL,
574
+ updated_at = NOW()
575
+ WHERE id = $2
576
+ """,
577
+ new_hash,
578
+ user['id'],
579
+ )
580
+
581
+ return {'message': 'Password updated successfully'}
582
+
583
+
584
+ @router.post('/api-keys', response_model=ApiKeyResponse)
585
+ async def create_api_key(
586
+ request: CreateApiKeyRequest,
587
+ user: Dict[str, Any] = Depends(require_user),
588
+ ):
589
+ """
590
+ Create a new API key for programmatic access.
591
+
592
+ The full key is only shown once! Store it securely.
593
+ """
594
+ pool = await get_pool()
595
+ if not pool:
596
+ raise HTTPException(status_code=503, detail='Database unavailable')
597
+
598
+ # Generate API key
599
+ key_id = f'key_{uuid.uuid4().hex[:12]}'
600
+ raw_key = f'ct_{secrets.token_urlsafe(32)}'
601
+ key_hash = hash_api_key(raw_key)
602
+ key_prefix = raw_key[:11] # 'ct_' + 8 chars
603
+
604
+ scopes = request.scopes or ['tasks:read', 'tasks:write']
605
+ expires_at = None
606
+ if request.expires_in_days:
607
+ expires_at = datetime.utcnow() + timedelta(days=request.expires_in_days)
608
+
609
+ async with pool.acquire() as conn:
610
+ await conn.execute(
611
+ """
612
+ INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, scopes, expires_at)
613
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
614
+ """,
615
+ key_id,
616
+ user['id'],
617
+ request.name,
618
+ key_hash,
619
+ key_prefix,
620
+ scopes,
621
+ expires_at,
622
+ )
623
+
624
+ logger.info(f'API key created for user {user["id"]}: {key_prefix}...')
625
+
626
+ return ApiKeyResponse(
627
+ id=key_id,
628
+ name=request.name,
629
+ key=raw_key, # Only time the full key is shown!
630
+ key_prefix=key_prefix,
631
+ scopes=scopes,
632
+ created_at=datetime.utcnow().isoformat(),
633
+ expires_at=expires_at.isoformat() if expires_at else None,
634
+ )
635
+
636
+
637
+ @router.get('/api-keys')
638
+ async def list_api_keys(user: Dict[str, Any] = Depends(require_user)):
639
+ """List all API keys for the current user."""
640
+ pool = await get_pool()
641
+ if not pool:
642
+ raise HTTPException(status_code=503, detail='Database unavailable')
643
+
644
+ async with pool.acquire() as conn:
645
+ rows = await conn.fetch(
646
+ """
647
+ SELECT id, name, key_prefix, scopes, status, created_at, expires_at, last_used_at
648
+ FROM api_keys
649
+ WHERE user_id = $1
650
+ ORDER BY created_at DESC
651
+ """,
652
+ user['id'],
653
+ )
654
+
655
+ return [
656
+ {
657
+ 'id': row['id'],
658
+ 'name': row['name'],
659
+ 'key_prefix': row['key_prefix'],
660
+ 'scopes': row['scopes'],
661
+ 'status': row['status'],
662
+ 'created_at': row['created_at'].isoformat()
663
+ if row['created_at']
664
+ else None,
665
+ 'expires_at': row['expires_at'].isoformat()
666
+ if row['expires_at']
667
+ else None,
668
+ 'last_used_at': row['last_used_at'].isoformat()
669
+ if row['last_used_at']
670
+ else None,
671
+ }
672
+ for row in rows
673
+ ]
674
+
675
+
676
+ @router.delete('/api-keys/{key_id}')
677
+ async def revoke_api_key(
678
+ key_id: str, user: Dict[str, Any] = Depends(require_user)
679
+ ):
680
+ """Revoke an API key."""
681
+ pool = await get_pool()
682
+ if not pool:
683
+ raise HTTPException(status_code=503, detail='Database unavailable')
684
+
685
+ async with pool.acquire() as conn:
686
+ result = await conn.execute(
687
+ """
688
+ UPDATE api_keys
689
+ SET status = 'revoked'
690
+ WHERE id = $1 AND user_id = $2
691
+ """,
692
+ key_id,
693
+ user['id'],
694
+ )
695
+
696
+ if 'UPDATE 0' in result:
697
+ raise HTTPException(status_code=404, detail='API key not found')
698
+
699
+ return {'message': 'API key revoked'}
700
+
701
+
702
+ # ========================================
703
+ # Usage Tracking Middleware
704
+ # ========================================
705
+
706
+
707
+ async def check_usage_limit(
708
+ user: Dict[str, Any] = Depends(require_user),
709
+ ) -> Dict[str, Any]:
710
+ """Check if user is within their usage limit."""
711
+ if user['tasks_used_this_month'] >= user['tasks_limit']:
712
+ raise HTTPException(
713
+ status_code=429,
714
+ detail={
715
+ 'error': 'Usage limit exceeded',
716
+ 'used': user['tasks_used_this_month'],
717
+ 'limit': user['tasks_limit'],
718
+ 'message': 'Upgrade to Pro for unlimited tasks',
719
+ },
720
+ )
721
+ return user