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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
a2a_server/user_auth.py
ADDED
|
@@ -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
|