ralphx 0.3.4__py3-none-any.whl → 0.3.5__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 (38) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/api/routes/auth.py +703 -94
  3. ralphx/api/routes/config.py +3 -56
  4. ralphx/api/routes/export_import.py +6 -9
  5. ralphx/api/routes/loops.py +4 -4
  6. ralphx/api/routes/planning.py +19 -5
  7. ralphx/api/routes/templates.py +2 -2
  8. ralphx/api/routes/workflows.py +1 -22
  9. ralphx/cli.py +4 -1
  10. ralphx/core/auth.py +346 -171
  11. ralphx/core/database.py +588 -164
  12. ralphx/core/executor.py +0 -3
  13. ralphx/core/loop.py +15 -2
  14. ralphx/core/loop_templates.py +3 -3
  15. ralphx/core/planning_service.py +109 -21
  16. ralphx/core/preview.py +9 -25
  17. ralphx/core/project_db.py +124 -72
  18. ralphx/core/project_export.py +1 -5
  19. ralphx/core/project_import.py +14 -29
  20. ralphx/core/sample_project.py +1 -5
  21. ralphx/core/templates.py +9 -9
  22. ralphx/core/workflow_export.py +4 -7
  23. ralphx/core/workflow_import.py +3 -27
  24. ralphx/mcp/__init__.py +6 -2
  25. ralphx/mcp/registry.py +3 -3
  26. ralphx/mcp/tools/workflows.py +114 -32
  27. ralphx/mcp_server.py +6 -2
  28. ralphx/static/assets/index-0ovNnfOq.css +1 -0
  29. ralphx/static/assets/index-CY9s08ZB.js +251 -0
  30. ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
  31. ralphx/static/index.html +2 -2
  32. {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/METADATA +33 -12
  33. {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/RECORD +35 -35
  34. ralphx/static/assets/index-CcRDyY3b.css +0 -1
  35. ralphx/static/assets/index-CcxfTosc.js +0 -251
  36. ralphx/static/assets/index-CcxfTosc.js.map +0 -1
  37. {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/WHEEL +0 -0
  38. {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/entry_points.txt +0 -0
ralphx/core/auth.py CHANGED
@@ -140,50 +140,44 @@ class AuthStatus(BaseModel):
140
140
 
141
141
 
142
142
  def get_auth_status(project_id: Optional[str] = None) -> AuthStatus:
143
- """Get auth status from database (project-specific first, then global).
143
+ """Get auth status based on effective account for project.
144
144
 
145
- Returns detailed status including whether project is using global fallback.
145
+ Returns detailed status including whether project has a specific assignment.
146
146
  """
147
147
  db = Database()
148
148
 
149
- # Check for project-specific credentials (no fallback)
150
- project_creds = None
151
- if project_id:
152
- project_creds = db.get_credentials_by_scope("project", project_id)
149
+ # Get effective account (assigned to project, or default, or first active)
150
+ account = db.get_effective_account(project_id)
153
151
 
154
- # Check for global credentials
155
- global_creds = db.get_credentials_by_scope("global", None)
152
+ # Check if project has explicit assignment
153
+ has_project_assignment = False
154
+ if project_id:
155
+ assignment = db.get_project_account_assignment(project_id)
156
+ has_project_assignment = assignment is not None
156
157
 
157
- # Determine which credentials to use (project takes priority)
158
- creds = project_creds or global_creds
158
+ # Check if using default fallback
159
+ using_fallback = project_id is not None and not has_project_assignment and account is not None
159
160
 
160
- # Determine if we're using global as a fallback for a project
161
- using_fallback = (
162
- project_id is not None
163
- and project_creds is None
164
- and global_creds is not None
165
- )
166
-
167
- if not creds:
161
+ if not account:
168
162
  return AuthStatus(
169
163
  connected=False,
170
- has_project_credentials=project_creds is not None,
164
+ has_project_credentials=has_project_assignment,
171
165
  )
172
166
 
173
167
  # Check expiry
174
168
  now = int(time.time())
175
- expires_at = creds["expires_at"]
169
+ expires_at = account["expires_at"]
176
170
  is_expired = now >= expires_at
177
171
 
178
172
  return AuthStatus(
179
173
  connected=True,
180
- scope=creds["scope"],
181
- email=creds.get("email"),
174
+ scope="account", # New: accounts don't have scope like old credentials
175
+ email=account.get("email"),
182
176
  expires_at=datetime.fromtimestamp(expires_at),
183
177
  expires_in_seconds=max(0, expires_at - now),
184
178
  is_expired=is_expired,
185
179
  using_global_fallback=using_fallback,
186
- has_project_credentials=project_creds is not None,
180
+ has_project_credentials=has_project_assignment,
187
181
  )
188
182
 
189
183
 
@@ -215,17 +209,25 @@ async def _token_refresh_lock() -> AsyncGenerator[None, None]:
215
209
 
216
210
  def store_oauth_tokens(
217
211
  tokens: dict,
218
- scope: Literal["project", "global"],
219
212
  project_id: Optional[str] = None,
220
- ) -> bool:
221
- """Store OAuth tokens in database.
213
+ ) -> dict:
214
+ """Store OAuth tokens in database (accounts table).
222
215
 
223
216
  Args:
224
- tokens: Dict with access_token, refresh_token, expires_in, email (optional),
217
+ tokens: Dict with access_token, refresh_token, expires_in, email (required),
225
218
  scopes (optional), subscription_type (optional), rate_limit_tier (optional)
226
- scope: "project" or "global"
227
- project_id: Project ID for project-scoped credentials
219
+ project_id: Optional project ID to assign this account to
220
+
221
+ Returns:
222
+ Account dict with id, email, and other fields
223
+
224
+ Raises:
225
+ ValueError: If email not provided in tokens
228
226
  """
227
+ email = tokens.get("email")
228
+ if not email:
229
+ raise ValueError("Email is required to store OAuth tokens")
230
+
229
231
  db = Database()
230
232
  expires_at = int(time.time()) + tokens.get("expires_in", 28800)
231
233
 
@@ -234,43 +236,209 @@ def store_oauth_tokens(
234
236
  if tokens.get("scopes"):
235
237
  scopes_json = json.dumps(tokens["scopes"])
236
238
 
237
- db.store_credentials(
238
- scope=scope,
239
+ # Create or update account
240
+ account = db.create_account(
241
+ email=email,
239
242
  access_token=tokens["access_token"],
240
243
  refresh_token=tokens.get("refresh_token"),
241
244
  expires_at=expires_at,
242
- project_id=project_id if scope == "project" else None,
243
- email=tokens.get("email"),
244
245
  scopes=scopes_json,
245
246
  subscription_type=tokens.get("subscription_type"),
246
247
  rate_limit_tier=tokens.get("rate_limit_tier"),
247
248
  )
248
249
 
250
+ # If project_id provided, assign this account to the project
251
+ if project_id:
252
+ db.assign_account_to_project(project_id, account["id"])
253
+
249
254
  auth_log.info(
250
255
  "login",
251
- f"Logged in ({scope})",
252
- scope=scope,
253
- email=tokens.get("email"),
256
+ f"Logged in as {email}",
257
+ email=email,
254
258
  project_id=project_id,
255
259
  )
256
- return True
260
+ return account
261
+
262
+
263
+ def get_effective_account_for_project(project_id: Optional[str] = None) -> Optional[dict]:
264
+ """Get the effective account for a project.
265
+
266
+ Resolution order:
267
+ 1. If project has assignment -> use that account
268
+ 2. Else -> use default account
269
+ 3. If no default -> use first active account
270
+
271
+ Args:
272
+ project_id: Optional project ID
273
+
274
+ Returns:
275
+ Account dict or None
276
+ """
277
+ db = Database()
278
+ return db.get_effective_account(project_id)
279
+
280
+
281
+ def get_fallback_account_for_rate_limit(
282
+ current_account_id: int,
283
+ failed_account_ids: Optional[list[int]] = None,
284
+ ) -> Optional[dict]:
285
+ """Get a fallback account after hitting rate limit (429).
286
+
287
+ Args:
288
+ current_account_id: Account that hit the rate limit
289
+ failed_account_ids: List of account IDs that already failed
290
+
291
+ Returns:
292
+ Account dict for fallback, or None if no fallback available
293
+ """
294
+ db = Database()
295
+ exclude_ids = [current_account_id] + (failed_account_ids or [])
296
+ return db.get_fallback_account(exclude_ids=exclude_ids, prefer_lowest_usage=True)
297
+
298
+
299
+ @contextmanager
300
+ def swap_credentials_for_account(
301
+ account: dict,
302
+ ) -> Generator[bool, None, None]:
303
+ """Context manager: backup user creds, write account creds, restore after.
304
+
305
+ Similar to swap_credentials_for_loop but takes an account dict directly.
306
+ Used when we have a specific account to use (e.g., from fallback logic).
307
+
308
+ Args:
309
+ account: Account dict with access_token, refresh_token, etc.
310
+
311
+ Yields:
312
+ True if credentials were written, False if account invalid
313
+ """
314
+ global _credential_swap_active
315
+
316
+ db = Database()
317
+
318
+ # Acquire exclusive lock to prevent concurrent credential access
319
+ CREDENTIAL_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
320
+ lock_file = open(CREDENTIAL_LOCK_PATH, "w")
321
+
322
+ try:
323
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) # Exclusive lock
324
+
325
+ # Backup user's current credentials
326
+ had_backup = False
327
+ original_content = None
328
+ if CLAUDE_CREDENTIALS_PATH.exists():
329
+ original_content = CLAUDE_CREDENTIALS_PATH.read_text()
330
+ shutil.copy2(CLAUDE_CREDENTIALS_PATH, CLAUDE_CREDENTIALS_BACKUP)
331
+ had_backup = True
332
+
333
+ _credential_swap_active = True
334
+
335
+ # Write account credentials to Claude's location
336
+ has_creds = False
337
+ if account and account.get("access_token"):
338
+ default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
339
+ stored_scopes = account.get("scopes")
340
+ if stored_scopes:
341
+ try:
342
+ scopes = json.loads(stored_scopes)
343
+ except (json.JSONDecodeError, TypeError):
344
+ scopes = default_scopes
345
+ else:
346
+ scopes = default_scopes
347
+
348
+ creds_data = {
349
+ "claudeAiOauth": {
350
+ "accessToken": account["access_token"],
351
+ "refreshToken": account.get("refresh_token"),
352
+ "expiresAt": account.get("expires_at", 0) * 1000,
353
+ "scopes": scopes,
354
+ "subscriptionType": account.get("subscription_type") or "max",
355
+ "rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
356
+ }
357
+ }
358
+ CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
359
+ CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
360
+ has_creds = True
361
+
362
+ # Update last_used_at for the account
363
+ db.update_account(account["id"], last_used_at=datetime.utcnow().isoformat())
257
364
 
365
+ try:
366
+ yield has_creds
367
+ finally:
368
+ # Capture any token refresh that happened during execution
369
+ if has_creds and CLAUDE_CREDENTIALS_PATH.exists():
370
+ try:
371
+ current_creds = json.loads(CLAUDE_CREDENTIALS_PATH.read_text())
372
+ oauth = current_creds.get("claudeAiOauth", {})
373
+ new_refresh = oauth.get("refreshToken")
258
374
 
259
- async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bool:
260
- """Actually perform the token refresh via OAuth.
375
+ if new_refresh and new_refresh != account.get("refresh_token"):
376
+ db.update_account(
377
+ account["id"],
378
+ access_token=oauth.get("accessToken"),
379
+ refresh_token=new_refresh,
380
+ expires_at=int(oauth.get("expiresAt", 0) / 1000),
381
+ )
382
+ auth_log.info(
383
+ "account_token_captured",
384
+ f"Captured refreshed token for account {account.get('email')}",
385
+ account_id=account["id"],
386
+ )
387
+ except Exception as e:
388
+ auth_log.warning(
389
+ "account_token_capture_failed",
390
+ f"Failed to capture refreshed token for account: {e}",
391
+ account_id=account.get("id"),
392
+ )
393
+
394
+ # Restore user's original credentials
395
+ restoration_success = False
396
+ try:
397
+ if had_backup and CLAUDE_CREDENTIALS_BACKUP.exists():
398
+ shutil.copy2(CLAUDE_CREDENTIALS_BACKUP, CLAUDE_CREDENTIALS_PATH)
399
+ restoration_success = True
400
+ elif not had_backup and has_creds:
401
+ CLAUDE_CREDENTIALS_PATH.unlink(missing_ok=True)
402
+ restoration_success = True
403
+ else:
404
+ restoration_success = True
405
+ except Exception as e:
406
+ auth_log.error(
407
+ "account_restoration_failed",
408
+ f"Failed to restore credentials after account swap: {e}",
409
+ )
410
+
411
+ if restoration_success and CLAUDE_CREDENTIALS_BACKUP.exists():
412
+ try:
413
+ CLAUDE_CREDENTIALS_BACKUP.unlink()
414
+ except Exception:
415
+ pass
416
+
417
+ _credential_swap_active = False
418
+ finally:
419
+ _credential_swap_active = False
420
+ try:
421
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
422
+ lock_file.close()
423
+ except Exception:
424
+ pass
425
+
426
+
427
+ async def _do_token_refresh(account: dict, project_id: Optional[str] = None) -> bool:
428
+ """Actually perform the token refresh via OAuth for an account.
261
429
 
262
430
  Args:
263
- creds: Credentials dict from database
431
+ account: Account dict from database with id, refresh_token, email
264
432
  project_id: Optional project ID for logging
265
433
 
266
434
  Returns:
267
435
  True if refresh succeeded, False otherwise
268
436
  """
269
- if not creds.get("refresh_token"):
437
+ if not account.get("refresh_token"):
270
438
  auth_log.warning(
271
439
  "token_refresh_failed",
272
- f"No refresh token available ({creds.get('scope', 'unknown')})",
273
- scope=creds.get("scope"),
440
+ f"No refresh token available for account {account.get('email', 'unknown')}",
441
+ email=account.get("email"),
274
442
  project_id=project_id,
275
443
  )
276
444
  return False
@@ -282,7 +450,7 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
282
450
  TOKEN_URL,
283
451
  json={
284
452
  "grant_type": "refresh_token",
285
- "refresh_token": creds["refresh_token"],
453
+ "refresh_token": account["refresh_token"],
286
454
  "client_id": CLIENT_ID,
287
455
  },
288
456
  headers={
@@ -295,8 +463,9 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
295
463
  error_body = resp.text[:200] if resp.text else "no response body"
296
464
  auth_log.warning(
297
465
  "token_refresh_failed",
298
- f"Token refresh failed ({creds['scope']}): HTTP {resp.status_code} - {error_body}",
299
- scope=creds["scope"],
466
+ f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code} - {error_body}",
467
+ email=account.get("email"),
468
+ account_id=account.get("id"),
300
469
  project_id=project_id,
301
470
  status_code=resp.status_code,
302
471
  )
@@ -304,6 +473,8 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
304
473
  try:
305
474
  error_json = resp.json()
306
475
  if error_json.get("error") == "invalid_grant":
476
+ # Mark account as inactive since token is revoked
477
+ db.update_account(account["id"], is_active=False, last_error="invalid_grant")
307
478
  raise InvalidGrantError("Refresh token expired or revoked. Please re-login.")
308
479
  except (ValueError, KeyError):
309
480
  pass
@@ -312,33 +483,37 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
312
483
  tokens = resp.json()
313
484
  new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
314
485
 
315
- # Extract email from account field if present
316
- account = tokens.get("account", {})
317
- email = account.get("email_address")
486
+ # Build update dict - capture subscription/plan info if present
487
+ update_data = {
488
+ "access_token": tokens["access_token"],
489
+ "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
490
+ "expires_at": new_expires_at,
491
+ "consecutive_failures": 0, # Reset failure count on success
492
+ }
493
+ if tokens.get("subscription_type"):
494
+ update_data["subscription_type"] = tokens["subscription_type"]
495
+ if tokens.get("rate_limit_tier"):
496
+ update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
318
497
 
319
498
  # CRITICAL: If DB update fails after refresh, we've consumed the
320
499
  # refresh_token but not saved the new one. Log this clearly.
321
500
  try:
322
- db.update_credentials(
323
- creds["id"],
324
- access_token=tokens["access_token"],
325
- refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
326
- expires_at=new_expires_at,
327
- email=email if email else creds.get("email"),
328
- )
501
+ db.update_account(account["id"], **update_data)
329
502
  except Exception as db_error:
330
503
  auth_log.error(
331
504
  "token_db_update_failed",
332
- f"Failed to save refreshed token ({creds['scope']}): {db_error}. Token may be lost!",
333
- scope=creds["scope"],
505
+ f"Failed to save refreshed token for {account.get('email')}: {db_error}. Token may be lost!",
506
+ email=account.get("email"),
507
+ account_id=account.get("id"),
334
508
  project_id=project_id,
335
509
  )
336
510
  return False
337
511
 
338
512
  auth_log.info(
339
513
  "token_refresh",
340
- f"Token refreshed ({creds['scope']})",
341
- scope=creds["scope"],
514
+ f"Token refreshed for {account.get('email')}",
515
+ email=account.get("email"),
516
+ account_id=account.get("id"),
342
517
  project_id=project_id,
343
518
  expires_in=tokens.get("expires_in", 28800),
344
519
  )
@@ -346,53 +521,55 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
346
521
  except httpx.HTTPError as http_error:
347
522
  auth_log.warning(
348
523
  "token_refresh_failed",
349
- f"Token refresh HTTP error ({creds['scope']}): {http_error}",
350
- scope=creds["scope"],
524
+ f"Token refresh HTTP error for {account.get('email')}: {http_error}",
525
+ email=account.get("email"),
526
+ account_id=account.get("id"),
351
527
  project_id=project_id,
352
528
  )
353
529
  return False
354
530
  except Exception as e:
355
531
  auth_log.warning(
356
532
  "token_refresh_failed",
357
- f"Token refresh failed ({creds['scope']}): {e}",
358
- scope=creds["scope"],
533
+ f"Token refresh failed for {account.get('email')}: {e}",
534
+ email=account.get("email"),
535
+ account_id=account.get("id"),
359
536
  project_id=project_id,
360
537
  )
361
538
  return False
362
539
 
363
540
 
364
- async def validate_token_health(creds: dict) -> tuple[bool, str]:
365
- """Validate OAuth token by attempting a refresh.
541
+ async def validate_account_token(account: dict) -> tuple[bool, str]:
542
+ """Validate account's OAuth token by attempting a refresh.
366
543
 
367
544
  Since we're using OAuth tokens (not API keys), we validate by
368
545
  trying to refresh the token. If the refresh_token is still valid,
369
546
  we'll get a new access_token. If it's expired/revoked, we get an error.
370
547
 
371
548
  Args:
372
- creds: Credentials dict with refresh_token
549
+ account: Account dict with refresh_token
373
550
 
374
551
  Returns:
375
552
  Tuple of (is_valid, error_message)
376
553
  """
377
- if not creds:
378
- return False, "No credentials"
554
+ if not account:
555
+ return False, "No account"
379
556
 
380
- if not creds.get("refresh_token"):
557
+ if not account.get("refresh_token"):
381
558
  return False, "No refresh token available"
382
559
 
383
560
  try:
384
561
  # Use lock to prevent concurrent refresh operations.
385
- # Re-read creds inside lock in case another process already refreshed.
562
+ # Re-read account inside lock in case another process already refreshed.
386
563
  async with _token_refresh_lock():
387
- # Re-fetch credentials to get any updates from concurrent refresh
564
+ # Re-fetch account to get any updates from concurrent refresh
388
565
  db = Database()
389
- fresh_creds = db.get_credentials_by_id(creds["id"])
390
- if fresh_creds and fresh_creds.get("refresh_token") != creds.get("refresh_token"):
566
+ fresh_account = db.get_account(account["id"])
567
+ if fresh_account and fresh_account.get("refresh_token") != account.get("refresh_token"):
391
568
  # Another process already refreshed - we have fresh tokens
392
569
  auth_log.info(
393
570
  "token_already_refreshed",
394
571
  "Token was refreshed by another process",
395
- scope=creds.get("scope"),
572
+ email=account.get("email"),
396
573
  )
397
574
  return True, ""
398
575
 
@@ -402,7 +579,7 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
402
579
  TOKEN_URL,
403
580
  json={
404
581
  "grant_type": "refresh_token",
405
- "refresh_token": creds["refresh_token"],
582
+ "refresh_token": account["refresh_token"],
406
583
  "client_id": CLIENT_ID,
407
584
  },
408
585
  headers={
@@ -415,32 +592,35 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
415
592
  # Token is valid and we got a new one - update it
416
593
  tokens = resp.json()
417
594
  new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
418
- account = tokens.get("account", {})
419
- email = account.get("email_address")
420
595
 
421
- # db already created above in lock, but re-use is fine
596
+ # Build update dict - capture subscription/plan info if present
597
+ update_data = {
598
+ "access_token": tokens["access_token"],
599
+ "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
600
+ "expires_at": new_expires_at,
601
+ "consecutive_failures": 0,
602
+ }
603
+ if tokens.get("subscription_type"):
604
+ update_data["subscription_type"] = tokens["subscription_type"]
605
+ if tokens.get("rate_limit_tier"):
606
+ update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
607
+
422
608
  # CRITICAL: If DB update fails, we've consumed the refresh_token
423
609
  # but not saved the new one. Handle this carefully.
424
610
  try:
425
- db.update_credentials(
426
- creds["id"],
427
- access_token=tokens["access_token"],
428
- refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
429
- expires_at=new_expires_at,
430
- email=email if email else creds.get("email"),
431
- )
611
+ db.update_account(account["id"], **update_data)
432
612
  except Exception as db_error:
433
613
  auth_log.error(
434
614
  "token_db_update_failed",
435
615
  f"Failed to save refreshed token: {db_error}. Token may be lost!",
436
- scope=creds.get("scope"),
616
+ email=account.get("email"),
437
617
  )
438
618
  return False, f"Token refresh succeeded but failed to save: {db_error}"
439
619
 
440
620
  auth_log.info(
441
621
  "token_validated",
442
- f"Token validated and refreshed ({creds.get('scope', 'unknown')})",
443
- scope=creds.get("scope"),
622
+ f"Token validated and refreshed for {account.get('email')}",
623
+ email=account.get("email"),
444
624
  )
445
625
  return True, ""
446
626
 
@@ -450,6 +630,8 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
450
630
  error_type = error_json.get("error", "unknown")
451
631
  error_desc = error_json.get("error_description", "")
452
632
  if error_type == "invalid_grant":
633
+ # Mark account as needing re-auth
634
+ db.update_account(account["id"], is_active=False, last_error="invalid_grant")
453
635
  return False, f"Refresh token expired or revoked: {error_desc}. Please re-login."
454
636
  return False, f"OAuth error: {error_type} - {error_desc}"
455
637
  except Exception:
@@ -493,65 +675,65 @@ async def refresh_token_if_needed(
493
675
  """Auto-refresh token if expired. Returns True if valid token available.
494
676
 
495
677
  Args:
496
- project_id: Optional project ID to check for project-scoped creds
678
+ project_id: Optional project ID to get effective account for
497
679
  validate: If True, actually validate the token by calling API
498
680
 
499
681
  Returns:
500
- True if valid credentials exist (either not expired or successfully refreshed)
501
- False if no credentials or refresh failed
682
+ True if valid account exists (either not expired or successfully refreshed)
683
+ False if no account or refresh failed
502
684
  """
503
685
  db = Database()
504
- creds = db.get_credentials(project_id)
686
+ account = db.get_effective_account(project_id)
505
687
 
506
- if not creds:
688
+ if not account:
507
689
  return False
508
690
 
509
691
  now = int(time.time())
510
692
 
511
693
  # If validation requested, actually test the token
512
- # Note: validate_token_health() attempts a refresh as part of validation.
694
+ # Note: validate_account_token() attempts a refresh as part of validation.
513
695
  # If it fails, the refresh already failed - no point retrying immediately.
514
696
  if validate:
515
- is_valid, error = await validate_token_health(creds)
697
+ is_valid, error = await validate_account_token(account)
516
698
  if not is_valid:
517
699
  auth_log.warning(
518
700
  "token_invalid",
519
701
  f"Token validation failed: {error}",
520
- scope=creds.get("scope"),
702
+ email=account.get("email"),
521
703
  project_id=project_id,
522
704
  )
523
- # Don't retry - validate_token_health already tried to refresh.
705
+ # Don't retry - validate_account_token already tried to refresh.
524
706
  # The error message explains what went wrong.
525
707
  return False
526
708
  return True
527
709
 
528
710
  # Otherwise just check expiry timestamp (5 minute buffer)
529
- if now < creds["expires_at"] - 300:
711
+ if now < account["expires_at"] - 300:
530
712
  return True # Token assumed valid based on expiry
531
713
 
532
- return await _do_token_refresh(creds, project_id)
714
+ return await _do_token_refresh(account, project_id)
533
715
 
534
716
 
535
717
  async def force_refresh_token(project_id: Optional[str] = None) -> dict:
536
718
  """Force refresh a token regardless of expiry time.
537
719
 
538
720
  Args:
539
- project_id: Optional project ID to check for project-scoped creds
721
+ project_id: Optional project ID to get effective account for
540
722
 
541
723
  Returns:
542
724
  dict with success status and message
543
725
  """
544
726
  db = Database()
545
- creds = db.get_credentials(project_id)
727
+ account = db.get_effective_account(project_id)
546
728
 
547
- if not creds:
548
- return {"success": False, "error": "No credentials found"}
729
+ if not account:
730
+ return {"success": False, "error": "No account found"}
549
731
 
550
- if not creds.get("refresh_token"):
732
+ if not account.get("refresh_token"):
551
733
  return {"success": False, "error": "No refresh token available"}
552
734
 
553
735
  try:
554
- success = await _do_token_refresh(creds, project_id)
736
+ success = await _do_token_refresh(account, project_id)
555
737
  if success:
556
738
  return {"success": True, "message": "Token refreshed successfully"}
557
739
  else:
@@ -561,7 +743,7 @@ async def force_refresh_token(project_id: Optional[str] = None) -> dict:
561
743
 
562
744
 
563
745
  async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
564
- """Refresh all tokens expiring within buffer_seconds.
746
+ """Refresh all account tokens expiring within buffer_seconds.
565
747
 
566
748
  Called by background task to proactively keep tokens fresh.
567
749
 
@@ -575,26 +757,26 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
575
757
  now = int(time.time())
576
758
  result = {"checked": 0, "refreshed": 0, "failed": 0}
577
759
 
578
- # Get all credentials (global + all projects)
579
- all_creds = db.get_all_credentials()
760
+ # Get all active accounts
761
+ all_accounts = db.list_accounts(include_inactive=False, include_deleted=False)
580
762
 
581
- for creds in all_creds:
763
+ for account in all_accounts:
582
764
  result["checked"] += 1
583
765
 
584
766
  # Skip if not expiring soon
585
- if now < creds["expires_at"] - buffer_seconds:
767
+ if now < account["expires_at"] - buffer_seconds:
586
768
  continue
587
769
 
588
770
  # Skip if no refresh token
589
- if not creds.get("refresh_token"):
771
+ if not account.get("refresh_token"):
590
772
  continue
591
773
 
592
774
  # Attempt refresh with lock to prevent race conditions
593
775
  try:
594
776
  async with _token_refresh_lock():
595
- # Re-fetch credentials to check if another process already refreshed
596
- fresh_creds = db.get_credentials_by_id(creds["id"])
597
- if fresh_creds and now < fresh_creds["expires_at"] - buffer_seconds:
777
+ # Re-fetch account to check if another process already refreshed
778
+ fresh_account = db.get_account(account["id"])
779
+ if fresh_account and now < fresh_account["expires_at"] - buffer_seconds:
598
780
  # Token was refreshed by another process, skip
599
781
  continue
600
782
 
@@ -603,7 +785,7 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
603
785
  TOKEN_URL,
604
786
  json={
605
787
  "grant_type": "refresh_token",
606
- "refresh_token": creds["refresh_token"],
788
+ "refresh_token": account["refresh_token"],
607
789
  "client_id": CLIENT_ID,
608
790
  },
609
791
  headers={
@@ -615,72 +797,63 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
615
797
  if resp.status_code == 200:
616
798
  tokens = resp.json()
617
799
  new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
618
- account = tokens.get("account", {})
619
- email = account.get("email_address")
800
+
801
+ # Build update dict - capture subscription/plan info if present
802
+ update_data = {
803
+ "access_token": tokens["access_token"],
804
+ "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
805
+ "expires_at": new_expires_at,
806
+ "consecutive_failures": 0,
807
+ }
808
+ if tokens.get("subscription_type"):
809
+ update_data["subscription_type"] = tokens["subscription_type"]
810
+ if tokens.get("rate_limit_tier"):
811
+ update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
620
812
 
621
813
  try:
622
- db.update_credentials(
623
- creds["id"],
624
- access_token=tokens["access_token"],
625
- refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
626
- expires_at=new_expires_at,
627
- email=email if email else creds.get("email"),
628
- )
814
+ db.update_account(account["id"], **update_data)
629
815
  result["refreshed"] += 1
630
816
  auth_log.info(
631
817
  "token_refresh",
632
- f"Token refreshed ({creds['scope']})",
633
- scope=creds["scope"],
634
- project_id=creds.get("project_id"),
818
+ f"Token refreshed for {account.get('email')}",
819
+ email=account.get("email"),
820
+ account_id=account.get("id"),
635
821
  expires_in=tokens.get("expires_in", 28800),
636
822
  )
637
823
  except Exception as db_error:
638
824
  result["failed"] += 1
639
825
  auth_log.error(
640
826
  "token_db_update_failed",
641
- f"Failed to save refreshed token ({creds['scope']}): {db_error}",
642
- scope=creds["scope"],
643
- project_id=creds.get("project_id"),
827
+ f"Failed to save refreshed token for {account.get('email')}: {db_error}",
828
+ email=account.get("email"),
829
+ account_id=account.get("id"),
644
830
  )
645
831
  else:
646
832
  result["failed"] += 1
833
+ # Track consecutive failures
834
+ db.update_account(
835
+ account["id"],
836
+ consecutive_failures=account.get("consecutive_failures", 0) + 1,
837
+ )
647
838
  auth_log.warning(
648
839
  "token_refresh_failed",
649
- f"Token refresh failed ({creds['scope']}): HTTP {resp.status_code}",
650
- scope=creds["scope"],
651
- project_id=creds.get("project_id"),
840
+ f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code}",
841
+ email=account.get("email"),
842
+ account_id=account.get("id"),
652
843
  )
653
844
  except Exception as e:
654
845
  result["failed"] += 1
655
846
  auth_log.error(
656
847
  "token_refresh_error",
657
- f"Token refresh error ({creds['scope']}): {e}",
658
- scope=creds["scope"],
659
- project_id=creds.get("project_id"),
848
+ f"Token refresh error for {account.get('email')}: {e}",
849
+ email=account.get("email"),
850
+ account_id=account.get("id"),
660
851
  error=str(e),
661
852
  )
662
853
 
663
854
  return result
664
855
 
665
856
 
666
- def clear_credentials(
667
- scope: Literal["project", "global"],
668
- project_id: Optional[str] = None,
669
- ) -> bool:
670
- """Remove credentials for the specified scope from database."""
671
- db = Database()
672
- result = db.delete_credentials(scope, project_id if scope == "project" else None)
673
-
674
- if result:
675
- auth_log.info(
676
- "logout",
677
- f"Logged out ({scope})",
678
- scope=scope,
679
- project_id=project_id,
680
- )
681
- return result
682
-
683
-
684
857
  @contextmanager
685
858
  def swap_credentials_for_loop(
686
859
  project_id: Optional[str] = None,
@@ -711,7 +884,8 @@ def swap_credentials_for_loop(
711
884
  global _credential_swap_active
712
885
 
713
886
  db = Database()
714
- creds = db.get_credentials(project_id)
887
+ # Get the effective account for this project (assigned or default)
888
+ account = db.get_effective_account(project_id)
715
889
 
716
890
  # Acquire exclusive lock to prevent concurrent credential access
717
891
  CREDENTIAL_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -732,13 +906,13 @@ def swap_credentials_for_loop(
732
906
  # Mark swap as active AFTER backup is complete
733
907
  _credential_swap_active = True
734
908
 
735
- # Write credentials from DB to Claude's location
909
+ # Write account credentials to Claude's location
736
910
  has_creds = False
737
- if creds:
738
- # Default scopes if not stored (for backwards compatibility)
911
+ if account:
912
+ # Default scopes if not stored
739
913
  # These are the minimum scopes Claude Code CLI needs for execution
740
914
  default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
741
- stored_scopes = creds.get("scopes")
915
+ stored_scopes = account.get("scopes")
742
916
  if stored_scopes:
743
917
  try:
744
918
  scopes = json.loads(stored_scopes)
@@ -749,16 +923,16 @@ def swap_credentials_for_loop(
749
923
 
750
924
  creds_data = {
751
925
  "claudeAiOauth": {
752
- "accessToken": creds["access_token"],
753
- "refreshToken": creds["refresh_token"],
754
- "expiresAt": creds["expires_at"] * 1000, # Convert to milliseconds
926
+ "accessToken": account["access_token"],
927
+ "refreshToken": account["refresh_token"],
928
+ "expiresAt": account["expires_at"] * 1000, # Convert to milliseconds
755
929
  "scopes": scopes,
756
930
  # subscriptionType: Claude subscription tier. Values include "free", "pro", "max".
757
931
  # Default to "max" as RalphX is designed for Max subscription users.
758
- "subscriptionType": creds.get("subscription_type") or "max",
932
+ "subscriptionType": account.get("subscription_type") or "max",
759
933
  # rateLimitTier: API rate limit tier. "default_claude_max_20x" indicates
760
934
  # the 20x rate limit multiplier for Max subscribers.
761
- "rateLimitTier": creds.get("rate_limit_tier") or "default_claude_max_20x",
935
+ "rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
762
936
  }
763
937
  }
764
938
  CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -778,17 +952,18 @@ def swap_credentials_for_loop(
778
952
  new_refresh = oauth.get("refreshToken")
779
953
 
780
954
  # If refresh token changed, Claude CLI refreshed during execution
781
- if new_refresh and new_refresh != creds["refresh_token"]:
782
- db.update_credentials(
783
- creds["id"],
955
+ if new_refresh and new_refresh != account["refresh_token"]:
956
+ db.update_account(
957
+ account["id"],
784
958
  access_token=oauth.get("accessToken"),
785
959
  refresh_token=new_refresh,
786
960
  expires_at=int(oauth.get("expiresAt", 0) / 1000),
787
961
  )
788
962
  auth_log.info(
789
963
  "token_captured",
790
- f"Captured refreshed token from Claude CLI ({creds['scope']})",
791
- scope=creds["scope"],
964
+ f"Captured refreshed token from Claude CLI ({account['email']})",
965
+ email=account["email"],
966
+ account_id=account["id"],
792
967
  project_id=project_id,
793
968
  )
794
969
  except Exception as e:
@@ -796,7 +971,7 @@ def swap_credentials_for_loop(
796
971
  auth_log.warning(
797
972
  "token_capture_failed",
798
973
  f"Failed to capture refreshed token: {e}",
799
- scope=creds.get("scope") if creds else None,
974
+ email=account.get("email") if account else None,
800
975
  project_id=project_id,
801
976
  )
802
977