codeshift 0.3.6__py3-none-any.whl → 0.4.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.
- codeshift/__init__.py +2 -2
- codeshift/cli/__init__.py +1 -1
- codeshift/cli/commands/__init__.py +1 -1
- codeshift/cli/commands/auth.py +5 -5
- codeshift/cli/commands/scan.py +2 -5
- codeshift/cli/commands/upgrade.py +2 -7
- codeshift/cli/commands/upgrade_all.py +1 -1
- codeshift/cli/main.py +2 -2
- codeshift/migrator/llm_migrator.py +8 -12
- codeshift/utils/__init__.py +1 -1
- codeshift/utils/api_client.py +11 -11
- codeshift/utils/cache.py +1 -1
- {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/METADATA +2 -18
- {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/RECORD +18 -34
- {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/licenses/LICENSE +1 -1
- codeshift/api/__init__.py +0 -1
- codeshift/api/auth.py +0 -182
- codeshift/api/config.py +0 -73
- codeshift/api/database.py +0 -215
- codeshift/api/main.py +0 -103
- codeshift/api/models/__init__.py +0 -55
- codeshift/api/models/auth.py +0 -108
- codeshift/api/models/billing.py +0 -92
- codeshift/api/models/migrate.py +0 -42
- codeshift/api/models/usage.py +0 -116
- codeshift/api/routers/__init__.py +0 -5
- codeshift/api/routers/auth.py +0 -440
- codeshift/api/routers/billing.py +0 -395
- codeshift/api/routers/migrate.py +0 -304
- codeshift/api/routers/usage.py +0 -291
- codeshift/api/routers/webhooks.py +0 -289
- {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/WHEEL +0 -0
- {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/entry_points.txt +0 -0
- {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/top_level.txt +0 -0
codeshift/api/models/usage.py
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
"""Usage tracking models for the PyResolve API."""
|
|
2
|
-
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from pydantic import BaseModel, Field
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class UsageEventCreate(BaseModel):
|
|
10
|
-
"""Request to record a usage event."""
|
|
11
|
-
|
|
12
|
-
event_type: str = Field(..., pattern="^(file_migrated|llm_call|scan|apply)$")
|
|
13
|
-
library: str | None = None
|
|
14
|
-
quantity: int = Field(default=1, ge=1)
|
|
15
|
-
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class UsageEvent(BaseModel):
|
|
19
|
-
"""A recorded usage event."""
|
|
20
|
-
|
|
21
|
-
id: str
|
|
22
|
-
user_id: str
|
|
23
|
-
event_type: str
|
|
24
|
-
library: str | None = None
|
|
25
|
-
quantity: int
|
|
26
|
-
metadata: dict[str, Any]
|
|
27
|
-
billing_period: str
|
|
28
|
-
created_at: datetime
|
|
29
|
-
|
|
30
|
-
class Config:
|
|
31
|
-
from_attributes = True
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class UsageSummary(BaseModel):
|
|
35
|
-
"""Summary of usage for a billing period."""
|
|
36
|
-
|
|
37
|
-
billing_period: str
|
|
38
|
-
files_migrated: int = 0
|
|
39
|
-
llm_calls: int = 0
|
|
40
|
-
scans: int = 0
|
|
41
|
-
applies: int = 0
|
|
42
|
-
total_events: int = 0
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class QuotaInfo(BaseModel):
|
|
46
|
-
"""Current quota information for a user."""
|
|
47
|
-
|
|
48
|
-
tier: str
|
|
49
|
-
billing_period: str
|
|
50
|
-
|
|
51
|
-
# Current usage
|
|
52
|
-
files_migrated: int = 0
|
|
53
|
-
llm_calls: int = 0
|
|
54
|
-
|
|
55
|
-
# Limits
|
|
56
|
-
files_limit: int
|
|
57
|
-
llm_calls_limit: int
|
|
58
|
-
|
|
59
|
-
# Calculated fields
|
|
60
|
-
files_remaining: int
|
|
61
|
-
llm_calls_remaining: int
|
|
62
|
-
files_percentage: float
|
|
63
|
-
llm_calls_percentage: float
|
|
64
|
-
|
|
65
|
-
@classmethod
|
|
66
|
-
def from_usage(
|
|
67
|
-
cls,
|
|
68
|
-
tier: str,
|
|
69
|
-
billing_period: str,
|
|
70
|
-
files_migrated: int,
|
|
71
|
-
llm_calls: int,
|
|
72
|
-
files_limit: int,
|
|
73
|
-
llm_calls_limit: int,
|
|
74
|
-
) -> "QuotaInfo":
|
|
75
|
-
"""Create QuotaInfo from usage data."""
|
|
76
|
-
files_remaining = max(0, files_limit - files_migrated)
|
|
77
|
-
llm_calls_remaining = max(0, llm_calls_limit - llm_calls)
|
|
78
|
-
|
|
79
|
-
return cls(
|
|
80
|
-
tier=tier,
|
|
81
|
-
billing_period=billing_period,
|
|
82
|
-
files_migrated=files_migrated,
|
|
83
|
-
llm_calls=llm_calls,
|
|
84
|
-
files_limit=files_limit,
|
|
85
|
-
llm_calls_limit=llm_calls_limit,
|
|
86
|
-
files_remaining=files_remaining,
|
|
87
|
-
llm_calls_remaining=llm_calls_remaining,
|
|
88
|
-
files_percentage=round(files_migrated / files_limit * 100, 1) if files_limit > 0 else 0,
|
|
89
|
-
llm_calls_percentage=(
|
|
90
|
-
round(llm_calls / llm_calls_limit * 100, 1) if llm_calls_limit > 0 else 0
|
|
91
|
-
),
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
class UsageResponse(BaseModel):
|
|
96
|
-
"""Response for usage queries."""
|
|
97
|
-
|
|
98
|
-
quota: QuotaInfo
|
|
99
|
-
recent_events: list[UsageEvent] = Field(default_factory=list)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
class QuotaCheckRequest(BaseModel):
|
|
103
|
-
"""Request to check if an operation is within quota."""
|
|
104
|
-
|
|
105
|
-
event_type: str = Field(..., pattern="^(file_migrated|llm_call|scan|apply)$")
|
|
106
|
-
quantity: int = Field(default=1, ge=1)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
class QuotaCheckResponse(BaseModel):
|
|
110
|
-
"""Response for quota check."""
|
|
111
|
-
|
|
112
|
-
allowed: bool
|
|
113
|
-
current_usage: int
|
|
114
|
-
limit: int
|
|
115
|
-
remaining: int
|
|
116
|
-
message: str | None = None
|
codeshift/api/routers/auth.py
DELETED
|
@@ -1,440 +0,0 @@
|
|
|
1
|
-
"""Authentication router for the PyResolve API."""
|
|
2
|
-
|
|
3
|
-
import secrets
|
|
4
|
-
from datetime import datetime, timedelta, timezone
|
|
5
|
-
|
|
6
|
-
from fastapi import APIRouter, HTTPException, status
|
|
7
|
-
|
|
8
|
-
from codeshift.api.auth import (
|
|
9
|
-
CurrentUser,
|
|
10
|
-
generate_api_key,
|
|
11
|
-
)
|
|
12
|
-
from codeshift.api.database import get_database
|
|
13
|
-
from codeshift.api.models.auth import (
|
|
14
|
-
APIKey,
|
|
15
|
-
APIKeyCreate,
|
|
16
|
-
APIKeyResponse,
|
|
17
|
-
DeviceCodeRequest,
|
|
18
|
-
DeviceCodeResponse,
|
|
19
|
-
DeviceTokenRequest,
|
|
20
|
-
LoginRequest,
|
|
21
|
-
LoginResponse,
|
|
22
|
-
RegisterRequest,
|
|
23
|
-
UserInfo,
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
router = APIRouter()
|
|
27
|
-
|
|
28
|
-
# In-memory storage for device codes (in production, use Redis)
|
|
29
|
-
_device_codes: dict[str, dict] = {}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@router.get("/me", response_model=UserInfo)
|
|
33
|
-
async def get_current_user_info(user: CurrentUser) -> UserInfo:
|
|
34
|
-
"""Get current authenticated user information."""
|
|
35
|
-
db = get_database()
|
|
36
|
-
profile = db.get_profile_by_id(user.user_id)
|
|
37
|
-
|
|
38
|
-
if not profile:
|
|
39
|
-
raise HTTPException(
|
|
40
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
41
|
-
detail="User profile not found",
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
return UserInfo(
|
|
45
|
-
id=profile["id"],
|
|
46
|
-
email=profile["email"],
|
|
47
|
-
full_name=profile.get("full_name"),
|
|
48
|
-
tier=profile.get("tier", "free"),
|
|
49
|
-
stripe_customer_id=profile.get("stripe_customer_id"),
|
|
50
|
-
created_at=profile["created_at"],
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@router.post("/login", response_model=LoginResponse)
|
|
55
|
-
async def login(request: LoginRequest) -> LoginResponse:
|
|
56
|
-
"""Login with email and password, returns an API key.
|
|
57
|
-
|
|
58
|
-
This endpoint authenticates against Supabase Auth and creates
|
|
59
|
-
a new API key for CLI usage.
|
|
60
|
-
"""
|
|
61
|
-
from codeshift.api.database import get_supabase_anon_client
|
|
62
|
-
|
|
63
|
-
client = get_supabase_anon_client()
|
|
64
|
-
|
|
65
|
-
try:
|
|
66
|
-
# Authenticate with Supabase
|
|
67
|
-
auth_response = client.auth.sign_in_with_password(
|
|
68
|
-
{"email": request.email, "password": request.password}
|
|
69
|
-
)
|
|
70
|
-
except Exception as e:
|
|
71
|
-
raise HTTPException(
|
|
72
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
73
|
-
detail="Invalid email or password",
|
|
74
|
-
) from e
|
|
75
|
-
|
|
76
|
-
if not auth_response.user:
|
|
77
|
-
raise HTTPException(
|
|
78
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
79
|
-
detail="Invalid email or password",
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
user_id = auth_response.user.id
|
|
83
|
-
|
|
84
|
-
# Get or create profile
|
|
85
|
-
db = get_database()
|
|
86
|
-
profile = db.get_profile_by_id(user_id)
|
|
87
|
-
|
|
88
|
-
if not profile:
|
|
89
|
-
raise HTTPException(
|
|
90
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
91
|
-
detail="User profile not found",
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
# Generate a new API key
|
|
95
|
-
full_key, key_prefix, key_hash = generate_api_key()
|
|
96
|
-
|
|
97
|
-
# Store the API key
|
|
98
|
-
db.create_api_key(
|
|
99
|
-
user_id=user_id,
|
|
100
|
-
key_prefix=key_prefix,
|
|
101
|
-
key_hash=key_hash,
|
|
102
|
-
name="CLI Login Key",
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
return LoginResponse(
|
|
106
|
-
api_key=full_key,
|
|
107
|
-
user=UserInfo(
|
|
108
|
-
id=profile["id"],
|
|
109
|
-
email=profile["email"],
|
|
110
|
-
full_name=profile.get("full_name"),
|
|
111
|
-
tier=profile.get("tier", "free"),
|
|
112
|
-
stripe_customer_id=profile.get("stripe_customer_id"),
|
|
113
|
-
created_at=profile["created_at"],
|
|
114
|
-
),
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
@router.post("/register", response_model=LoginResponse)
|
|
119
|
-
async def register(request: RegisterRequest) -> LoginResponse:
|
|
120
|
-
"""Register a new user account.
|
|
121
|
-
|
|
122
|
-
Creates a new user in Supabase Auth and returns an API key for CLI usage.
|
|
123
|
-
The profile is automatically created by a database trigger.
|
|
124
|
-
"""
|
|
125
|
-
from codeshift.api.database import get_supabase_anon_client
|
|
126
|
-
|
|
127
|
-
client = get_supabase_anon_client()
|
|
128
|
-
|
|
129
|
-
try:
|
|
130
|
-
# Create user in Supabase Auth
|
|
131
|
-
auth_response = client.auth.sign_up(
|
|
132
|
-
{
|
|
133
|
-
"email": request.email,
|
|
134
|
-
"password": request.password,
|
|
135
|
-
"options": {"data": {"full_name": request.full_name} if request.full_name else {}},
|
|
136
|
-
}
|
|
137
|
-
)
|
|
138
|
-
except Exception as e:
|
|
139
|
-
error_msg = str(e).lower()
|
|
140
|
-
if "already registered" in error_msg or "already exists" in error_msg:
|
|
141
|
-
raise HTTPException(
|
|
142
|
-
status_code=status.HTTP_409_CONFLICT,
|
|
143
|
-
detail="An account with this email already exists",
|
|
144
|
-
) from e
|
|
145
|
-
raise HTTPException(
|
|
146
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
147
|
-
detail="Registration failed. Please try again.",
|
|
148
|
-
) from e
|
|
149
|
-
|
|
150
|
-
if not auth_response.user:
|
|
151
|
-
raise HTTPException(
|
|
152
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
153
|
-
detail="Registration failed. Please try again.",
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
user_id = auth_response.user.id
|
|
157
|
-
|
|
158
|
-
# Wait briefly for the trigger to create the profile
|
|
159
|
-
import asyncio
|
|
160
|
-
|
|
161
|
-
await asyncio.sleep(0.5)
|
|
162
|
-
|
|
163
|
-
# Get the profile (created by database trigger)
|
|
164
|
-
db = get_database()
|
|
165
|
-
profile = db.get_profile_by_id(user_id)
|
|
166
|
-
|
|
167
|
-
if not profile:
|
|
168
|
-
# Profile wasn't created by trigger - create it manually
|
|
169
|
-
try:
|
|
170
|
-
db.client.table("profiles").insert(
|
|
171
|
-
{
|
|
172
|
-
"id": user_id,
|
|
173
|
-
"email": request.email,
|
|
174
|
-
"full_name": request.full_name or "",
|
|
175
|
-
"tier": "free",
|
|
176
|
-
}
|
|
177
|
-
).execute()
|
|
178
|
-
profile = db.get_profile_by_id(user_id)
|
|
179
|
-
except Exception:
|
|
180
|
-
pass
|
|
181
|
-
|
|
182
|
-
if not profile:
|
|
183
|
-
raise HTTPException(
|
|
184
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
185
|
-
detail="Account created but profile setup failed. Please try logging in.",
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
# Generate a new API key
|
|
189
|
-
full_key, key_prefix, key_hash = generate_api_key()
|
|
190
|
-
|
|
191
|
-
# Store the API key
|
|
192
|
-
db.create_api_key(
|
|
193
|
-
user_id=user_id,
|
|
194
|
-
key_prefix=key_prefix,
|
|
195
|
-
key_hash=key_hash,
|
|
196
|
-
name="CLI Registration Key",
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
return LoginResponse(
|
|
200
|
-
api_key=full_key,
|
|
201
|
-
user=UserInfo(
|
|
202
|
-
id=profile["id"],
|
|
203
|
-
email=profile["email"],
|
|
204
|
-
full_name=profile.get("full_name"),
|
|
205
|
-
tier=profile.get("tier", "free"),
|
|
206
|
-
stripe_customer_id=profile.get("stripe_customer_id"),
|
|
207
|
-
created_at=profile["created_at"],
|
|
208
|
-
),
|
|
209
|
-
message="Registration successful",
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
@router.post("/device/code", response_model=DeviceCodeResponse)
|
|
214
|
-
async def request_device_code(request: DeviceCodeRequest) -> DeviceCodeResponse:
|
|
215
|
-
"""Request a device code for CLI authentication.
|
|
216
|
-
|
|
217
|
-
This initiates the device code flow for CLI authentication.
|
|
218
|
-
The user will receive a code to enter in their browser.
|
|
219
|
-
"""
|
|
220
|
-
# Generate codes
|
|
221
|
-
device_code = secrets.token_urlsafe(32)
|
|
222
|
-
user_code = "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(8))
|
|
223
|
-
user_code = f"{user_code[:4]}-{user_code[4:]}" # Format: XXXX-XXXX
|
|
224
|
-
|
|
225
|
-
# Store for later verification
|
|
226
|
-
_device_codes[device_code] = {
|
|
227
|
-
"user_code": user_code,
|
|
228
|
-
"client_id": request.client_id,
|
|
229
|
-
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=15),
|
|
230
|
-
"user_id": None, # Will be set when user authenticates
|
|
231
|
-
"status": "pending",
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return DeviceCodeResponse(
|
|
235
|
-
device_code=device_code,
|
|
236
|
-
user_code=user_code,
|
|
237
|
-
verification_uri="https://codeshift.dev/device",
|
|
238
|
-
expires_in=900,
|
|
239
|
-
interval=5,
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
@router.post("/device/token", response_model=LoginResponse)
|
|
244
|
-
async def exchange_device_code(request: DeviceTokenRequest) -> LoginResponse:
|
|
245
|
-
"""Exchange a device code for an API key.
|
|
246
|
-
|
|
247
|
-
The CLI polls this endpoint until the user completes authentication.
|
|
248
|
-
"""
|
|
249
|
-
device_data = _device_codes.get(request.device_code)
|
|
250
|
-
|
|
251
|
-
if not device_data:
|
|
252
|
-
raise HTTPException(
|
|
253
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
254
|
-
detail="Invalid device code",
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
# Check expiration
|
|
258
|
-
if datetime.now(timezone.utc) > device_data["expires_at"]:
|
|
259
|
-
del _device_codes[request.device_code]
|
|
260
|
-
raise HTTPException(
|
|
261
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
262
|
-
detail="Device code expired",
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
# Check status
|
|
266
|
-
if device_data["status"] == "pending":
|
|
267
|
-
raise HTTPException(
|
|
268
|
-
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
|
269
|
-
detail="authorization_pending",
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
if device_data["status"] == "denied":
|
|
273
|
-
del _device_codes[request.device_code]
|
|
274
|
-
raise HTTPException(
|
|
275
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
276
|
-
detail="User denied the request",
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
if device_data["status"] != "approved":
|
|
280
|
-
raise HTTPException(
|
|
281
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
282
|
-
detail="Invalid device code status",
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
# Get user info
|
|
286
|
-
user_id = device_data["user_id"]
|
|
287
|
-
db = get_database()
|
|
288
|
-
profile = db.get_profile_by_id(user_id)
|
|
289
|
-
|
|
290
|
-
if not profile:
|
|
291
|
-
raise HTTPException(
|
|
292
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
293
|
-
detail="User profile not found",
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
# Generate API key
|
|
297
|
-
full_key, key_prefix, key_hash = generate_api_key()
|
|
298
|
-
|
|
299
|
-
db.create_api_key(
|
|
300
|
-
user_id=user_id,
|
|
301
|
-
key_prefix=key_prefix,
|
|
302
|
-
key_hash=key_hash,
|
|
303
|
-
name="CLI Device Key",
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
# Clean up device code
|
|
307
|
-
del _device_codes[request.device_code]
|
|
308
|
-
|
|
309
|
-
return LoginResponse(
|
|
310
|
-
api_key=full_key,
|
|
311
|
-
user=UserInfo(
|
|
312
|
-
id=profile["id"],
|
|
313
|
-
email=profile["email"],
|
|
314
|
-
full_name=profile.get("full_name"),
|
|
315
|
-
tier=profile.get("tier", "free"),
|
|
316
|
-
stripe_customer_id=profile.get("stripe_customer_id"),
|
|
317
|
-
created_at=profile["created_at"],
|
|
318
|
-
),
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
@router.post("/device/approve")
|
|
323
|
-
async def approve_device_code(user_code: str, user: CurrentUser) -> dict:
|
|
324
|
-
"""Approve a device code (called from web UI after user logs in)."""
|
|
325
|
-
# Find the device code by user code
|
|
326
|
-
for device_code, data in _device_codes.items():
|
|
327
|
-
if data["user_code"] == user_code.upper().replace("-", "").strip():
|
|
328
|
-
if datetime.now(timezone.utc) > data["expires_at"]:
|
|
329
|
-
del _device_codes[device_code]
|
|
330
|
-
raise HTTPException(
|
|
331
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
332
|
-
detail="Device code expired",
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
data["user_id"] = user.user_id
|
|
336
|
-
data["status"] = "approved"
|
|
337
|
-
return {"message": "Device approved successfully"}
|
|
338
|
-
|
|
339
|
-
raise HTTPException(
|
|
340
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
341
|
-
detail="Invalid user code",
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
@router.get("/keys", response_model=list[APIKey])
|
|
346
|
-
async def list_api_keys(user: CurrentUser) -> list[APIKey]:
|
|
347
|
-
"""List all API keys for the current user."""
|
|
348
|
-
db = get_database()
|
|
349
|
-
|
|
350
|
-
result = (
|
|
351
|
-
db.client.table("api_keys")
|
|
352
|
-
.select("*")
|
|
353
|
-
.eq("user_id", user.user_id)
|
|
354
|
-
.eq("revoked", False)
|
|
355
|
-
.order("created_at", desc=True)
|
|
356
|
-
.execute()
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
return [
|
|
360
|
-
APIKey(
|
|
361
|
-
id=key["id"],
|
|
362
|
-
name=key["name"],
|
|
363
|
-
key_prefix=key["key_prefix"],
|
|
364
|
-
scopes=key.get("scopes", []),
|
|
365
|
-
revoked=key["revoked"],
|
|
366
|
-
last_used_at=key.get("last_used_at"),
|
|
367
|
-
expires_at=key.get("expires_at"),
|
|
368
|
-
created_at=key["created_at"],
|
|
369
|
-
)
|
|
370
|
-
for key in result.data
|
|
371
|
-
]
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
@router.post("/keys", response_model=APIKeyResponse)
|
|
375
|
-
async def create_api_key(request: APIKeyCreate, user: CurrentUser) -> APIKeyResponse:
|
|
376
|
-
"""Create a new API key."""
|
|
377
|
-
db = get_database()
|
|
378
|
-
|
|
379
|
-
# Generate the key
|
|
380
|
-
full_key, key_prefix, key_hash = generate_api_key()
|
|
381
|
-
|
|
382
|
-
# Store it
|
|
383
|
-
result = db.create_api_key(
|
|
384
|
-
user_id=user.user_id,
|
|
385
|
-
key_prefix=key_prefix,
|
|
386
|
-
key_hash=key_hash,
|
|
387
|
-
name=request.name,
|
|
388
|
-
scopes=request.scopes,
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
return APIKeyResponse(
|
|
392
|
-
id=result["id"],
|
|
393
|
-
name=request.name,
|
|
394
|
-
key=full_key, # Only time the full key is returned
|
|
395
|
-
key_prefix=key_prefix,
|
|
396
|
-
scopes=request.scopes,
|
|
397
|
-
created_at=result["created_at"],
|
|
398
|
-
)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
@router.delete("/keys/{key_id}")
|
|
402
|
-
async def revoke_api_key(key_id: str, user: CurrentUser) -> dict:
|
|
403
|
-
"""Revoke an API key."""
|
|
404
|
-
db = get_database()
|
|
405
|
-
|
|
406
|
-
# Verify ownership
|
|
407
|
-
result = db.client.table("api_keys").select("user_id").eq("id", key_id).execute()
|
|
408
|
-
|
|
409
|
-
if not result.data:
|
|
410
|
-
raise HTTPException(
|
|
411
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
412
|
-
detail="API key not found",
|
|
413
|
-
)
|
|
414
|
-
|
|
415
|
-
if result.data[0]["user_id"] != user.user_id:
|
|
416
|
-
raise HTTPException(
|
|
417
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
418
|
-
detail="Not authorized to revoke this key",
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
# Revoke
|
|
422
|
-
success = db.revoke_api_key(key_id)
|
|
423
|
-
|
|
424
|
-
if not success:
|
|
425
|
-
raise HTTPException(
|
|
426
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
427
|
-
detail="Failed to revoke key",
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
return {"message": "API key revoked successfully"}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
@router.post("/logout")
|
|
434
|
-
async def logout(user: CurrentUser) -> dict:
|
|
435
|
-
"""Logout by revoking the current API key."""
|
|
436
|
-
if user.api_key_id:
|
|
437
|
-
db = get_database()
|
|
438
|
-
db.revoke_api_key(user.api_key_id)
|
|
439
|
-
|
|
440
|
-
return {"message": "Logged out successfully"}
|