ralphx 0.3.4__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.
Files changed (48) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +10 -2
  3. ralphx/adapters/claude_cli.py +222 -82
  4. ralphx/api/routes/auth.py +780 -98
  5. ralphx/api/routes/config.py +3 -56
  6. ralphx/api/routes/export_import.py +6 -9
  7. ralphx/api/routes/loops.py +4 -4
  8. ralphx/api/routes/planning.py +882 -19
  9. ralphx/api/routes/resources.py +528 -6
  10. ralphx/api/routes/stream.py +58 -56
  11. ralphx/api/routes/templates.py +2 -2
  12. ralphx/api/routes/workflows.py +258 -47
  13. ralphx/cli.py +4 -1
  14. ralphx/core/auth.py +372 -172
  15. ralphx/core/database.py +588 -164
  16. ralphx/core/executor.py +170 -19
  17. ralphx/core/loop.py +15 -2
  18. ralphx/core/loop_templates.py +29 -3
  19. ralphx/core/planning_iteration_executor.py +633 -0
  20. ralphx/core/planning_service.py +119 -24
  21. ralphx/core/preview.py +9 -25
  22. ralphx/core/project_db.py +864 -121
  23. ralphx/core/project_export.py +1 -5
  24. ralphx/core/project_import.py +14 -29
  25. ralphx/core/resources.py +28 -2
  26. ralphx/core/sample_project.py +1 -5
  27. ralphx/core/templates.py +9 -9
  28. ralphx/core/workflow_executor.py +32 -3
  29. ralphx/core/workflow_export.py +4 -7
  30. ralphx/core/workflow_import.py +3 -27
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/tools/diagnostics.py +1 -1
  34. ralphx/mcp/tools/monitoring.py +10 -16
  35. ralphx/mcp/tools/workflows.py +115 -33
  36. ralphx/mcp_server.py +6 -2
  37. ralphx/static/assets/index-BuLI7ffn.css +1 -0
  38. ralphx/static/assets/index-DWvlqOTb.js +264 -0
  39. ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
  40. ralphx/static/index.html +2 -2
  41. ralphx/templates/loop_templates/consumer.md +2 -2
  42. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
  43. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
  44. ralphx/static/assets/index-CcRDyY3b.css +0 -1
  45. ralphx/static/assets/index-CcxfTosc.js +0 -251
  46. ralphx/static/assets/index-CcxfTosc.js.map +0 -1
  47. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
  48. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/core/auth.py CHANGED
@@ -128,7 +128,7 @@ class AuthStatus(BaseModel):
128
128
  """Authentication status."""
129
129
 
130
130
  connected: bool
131
- scope: Optional[Literal["project", "global"]] = None
131
+ scope: Optional[Literal["project", "global", "account"]] = None
132
132
  email: Optional[str] = None # User's email address
133
133
  subscription_type: Optional[str] = None
134
134
  rate_limit_tier: Optional[str] = None
@@ -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,210 @@ 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
+
257
262
 
263
+ def get_effective_account_for_project(project_id: Optional[str] = None) -> Optional[dict]:
264
+ """Get the effective account for a project.
258
265
 
259
- async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bool:
260
- """Actually perform the token refresh via OAuth.
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
261
270
 
262
271
  Args:
263
- creds: Credentials dict from database
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
+ os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
361
+ has_creds = True
362
+
363
+ # Update last_used_at for the account
364
+ db.update_account(account["id"], last_used_at=datetime.utcnow().isoformat())
365
+
366
+ try:
367
+ yield has_creds
368
+ finally:
369
+ # Capture any token refresh that happened during execution
370
+ if has_creds and CLAUDE_CREDENTIALS_PATH.exists():
371
+ try:
372
+ current_creds = json.loads(CLAUDE_CREDENTIALS_PATH.read_text())
373
+ oauth = current_creds.get("claudeAiOauth", {})
374
+ new_refresh = oauth.get("refreshToken")
375
+
376
+ if new_refresh and new_refresh != account.get("refresh_token"):
377
+ db.update_account(
378
+ account["id"],
379
+ access_token=oauth.get("accessToken"),
380
+ refresh_token=new_refresh,
381
+ expires_at=int(oauth.get("expiresAt", 0) / 1000),
382
+ )
383
+ auth_log.info(
384
+ "account_token_captured",
385
+ f"Captured refreshed token for account {account.get('email')}",
386
+ account_id=account["id"],
387
+ )
388
+ except Exception as e:
389
+ auth_log.warning(
390
+ "account_token_capture_failed",
391
+ f"Failed to capture refreshed token for account: {e}",
392
+ account_id=account.get("id"),
393
+ )
394
+
395
+ # Restore user's original credentials
396
+ restoration_success = False
397
+ try:
398
+ if had_backup and CLAUDE_CREDENTIALS_BACKUP.exists():
399
+ shutil.copy2(CLAUDE_CREDENTIALS_BACKUP, CLAUDE_CREDENTIALS_PATH)
400
+ restoration_success = True
401
+ elif not had_backup and has_creds:
402
+ CLAUDE_CREDENTIALS_PATH.unlink(missing_ok=True)
403
+ restoration_success = True
404
+ else:
405
+ restoration_success = True
406
+ except Exception as e:
407
+ auth_log.error(
408
+ "account_restoration_failed",
409
+ f"Failed to restore credentials after account swap: {e}",
410
+ )
411
+
412
+ if restoration_success and CLAUDE_CREDENTIALS_BACKUP.exists():
413
+ try:
414
+ CLAUDE_CREDENTIALS_BACKUP.unlink()
415
+ except Exception:
416
+ pass
417
+
418
+ _credential_swap_active = False
419
+ finally:
420
+ _credential_swap_active = False
421
+ try:
422
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
423
+ lock_file.close()
424
+ except Exception:
425
+ pass
426
+
427
+
428
+ async def _do_token_refresh(account: dict, project_id: Optional[str] = None) -> bool:
429
+ """Actually perform the token refresh via OAuth for an account.
430
+
431
+ Args:
432
+ account: Account dict from database with id, refresh_token, email
264
433
  project_id: Optional project ID for logging
265
434
 
266
435
  Returns:
267
436
  True if refresh succeeded, False otherwise
268
437
  """
269
- if not creds.get("refresh_token"):
438
+ if not account.get("refresh_token"):
270
439
  auth_log.warning(
271
440
  "token_refresh_failed",
272
- f"No refresh token available ({creds.get('scope', 'unknown')})",
273
- scope=creds.get("scope"),
441
+ f"No refresh token available for account {account.get('email', 'unknown')}",
442
+ email=account.get("email"),
274
443
  project_id=project_id,
275
444
  )
276
445
  return False
@@ -282,7 +451,7 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
282
451
  TOKEN_URL,
283
452
  json={
284
453
  "grant_type": "refresh_token",
285
- "refresh_token": creds["refresh_token"],
454
+ "refresh_token": account["refresh_token"],
286
455
  "client_id": CLIENT_ID,
287
456
  },
288
457
  headers={
@@ -295,8 +464,9 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
295
464
  error_body = resp.text[:200] if resp.text else "no response body"
296
465
  auth_log.warning(
297
466
  "token_refresh_failed",
298
- f"Token refresh failed ({creds['scope']}): HTTP {resp.status_code} - {error_body}",
299
- scope=creds["scope"],
467
+ f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code} - {error_body}",
468
+ email=account.get("email"),
469
+ account_id=account.get("id"),
300
470
  project_id=project_id,
301
471
  status_code=resp.status_code,
302
472
  )
@@ -304,6 +474,8 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
304
474
  try:
305
475
  error_json = resp.json()
306
476
  if error_json.get("error") == "invalid_grant":
477
+ # Mark account as inactive since token is revoked
478
+ db.update_account(account["id"], is_active=False, last_error="invalid_grant")
307
479
  raise InvalidGrantError("Refresh token expired or revoked. Please re-login.")
308
480
  except (ValueError, KeyError):
309
481
  pass
@@ -312,33 +484,37 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
312
484
  tokens = resp.json()
313
485
  new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
314
486
 
315
- # Extract email from account field if present
316
- account = tokens.get("account", {})
317
- email = account.get("email_address")
487
+ # Build update dict - capture subscription/plan info if present
488
+ update_data = {
489
+ "access_token": tokens["access_token"],
490
+ "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
491
+ "expires_at": new_expires_at,
492
+ "consecutive_failures": 0, # Reset failure count on success
493
+ }
494
+ if tokens.get("subscription_type"):
495
+ update_data["subscription_type"] = tokens["subscription_type"]
496
+ if tokens.get("rate_limit_tier"):
497
+ update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
318
498
 
319
499
  # CRITICAL: If DB update fails after refresh, we've consumed the
320
500
  # refresh_token but not saved the new one. Log this clearly.
321
501
  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
- )
502
+ db.update_account(account["id"], **update_data)
329
503
  except Exception as db_error:
330
504
  auth_log.error(
331
505
  "token_db_update_failed",
332
- f"Failed to save refreshed token ({creds['scope']}): {db_error}. Token may be lost!",
333
- scope=creds["scope"],
506
+ f"Failed to save refreshed token for {account.get('email')}: {db_error}. Token may be lost!",
507
+ email=account.get("email"),
508
+ account_id=account.get("id"),
334
509
  project_id=project_id,
335
510
  )
336
511
  return False
337
512
 
338
513
  auth_log.info(
339
514
  "token_refresh",
340
- f"Token refreshed ({creds['scope']})",
341
- scope=creds["scope"],
515
+ f"Token refreshed for {account.get('email')}",
516
+ email=account.get("email"),
517
+ account_id=account.get("id"),
342
518
  project_id=project_id,
343
519
  expires_in=tokens.get("expires_in", 28800),
344
520
  )
@@ -346,53 +522,55 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
346
522
  except httpx.HTTPError as http_error:
347
523
  auth_log.warning(
348
524
  "token_refresh_failed",
349
- f"Token refresh HTTP error ({creds['scope']}): {http_error}",
350
- scope=creds["scope"],
525
+ f"Token refresh HTTP error for {account.get('email')}: {http_error}",
526
+ email=account.get("email"),
527
+ account_id=account.get("id"),
351
528
  project_id=project_id,
352
529
  )
353
530
  return False
354
531
  except Exception as e:
355
532
  auth_log.warning(
356
533
  "token_refresh_failed",
357
- f"Token refresh failed ({creds['scope']}): {e}",
358
- scope=creds["scope"],
534
+ f"Token refresh failed for {account.get('email')}: {e}",
535
+ email=account.get("email"),
536
+ account_id=account.get("id"),
359
537
  project_id=project_id,
360
538
  )
361
539
  return False
362
540
 
363
541
 
364
- async def validate_token_health(creds: dict) -> tuple[bool, str]:
365
- """Validate OAuth token by attempting a refresh.
542
+ async def validate_account_token(account: dict) -> tuple[bool, str]:
543
+ """Validate account's OAuth token by attempting a refresh.
366
544
 
367
545
  Since we're using OAuth tokens (not API keys), we validate by
368
546
  trying to refresh the token. If the refresh_token is still valid,
369
547
  we'll get a new access_token. If it's expired/revoked, we get an error.
370
548
 
371
549
  Args:
372
- creds: Credentials dict with refresh_token
550
+ account: Account dict with refresh_token
373
551
 
374
552
  Returns:
375
553
  Tuple of (is_valid, error_message)
376
554
  """
377
- if not creds:
378
- return False, "No credentials"
555
+ if not account:
556
+ return False, "No account"
379
557
 
380
- if not creds.get("refresh_token"):
558
+ if not account.get("refresh_token"):
381
559
  return False, "No refresh token available"
382
560
 
383
561
  try:
384
562
  # Use lock to prevent concurrent refresh operations.
385
- # Re-read creds inside lock in case another process already refreshed.
563
+ # Re-read account inside lock in case another process already refreshed.
386
564
  async with _token_refresh_lock():
387
- # Re-fetch credentials to get any updates from concurrent refresh
565
+ # Re-fetch account to get any updates from concurrent refresh
388
566
  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"):
567
+ fresh_account = db.get_account(account["id"])
568
+ if fresh_account and fresh_account.get("refresh_token") != account.get("refresh_token"):
391
569
  # Another process already refreshed - we have fresh tokens
392
570
  auth_log.info(
393
571
  "token_already_refreshed",
394
572
  "Token was refreshed by another process",
395
- scope=creds.get("scope"),
573
+ email=account.get("email"),
396
574
  )
397
575
  return True, ""
398
576
 
@@ -402,7 +580,7 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
402
580
  TOKEN_URL,
403
581
  json={
404
582
  "grant_type": "refresh_token",
405
- "refresh_token": creds["refresh_token"],
583
+ "refresh_token": account["refresh_token"],
406
584
  "client_id": CLIENT_ID,
407
585
  },
408
586
  headers={
@@ -415,32 +593,35 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
415
593
  # Token is valid and we got a new one - update it
416
594
  tokens = resp.json()
417
595
  new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
418
- account = tokens.get("account", {})
419
- email = account.get("email_address")
420
596
 
421
- # db already created above in lock, but re-use is fine
597
+ # Build update dict - capture subscription/plan info if present
598
+ update_data = {
599
+ "access_token": tokens["access_token"],
600
+ "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
601
+ "expires_at": new_expires_at,
602
+ "consecutive_failures": 0,
603
+ }
604
+ if tokens.get("subscription_type"):
605
+ update_data["subscription_type"] = tokens["subscription_type"]
606
+ if tokens.get("rate_limit_tier"):
607
+ update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
608
+
422
609
  # CRITICAL: If DB update fails, we've consumed the refresh_token
423
610
  # but not saved the new one. Handle this carefully.
424
611
  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
- )
612
+ db.update_account(account["id"], **update_data)
432
613
  except Exception as db_error:
433
614
  auth_log.error(
434
615
  "token_db_update_failed",
435
616
  f"Failed to save refreshed token: {db_error}. Token may be lost!",
436
- scope=creds.get("scope"),
617
+ email=account.get("email"),
437
618
  )
438
619
  return False, f"Token refresh succeeded but failed to save: {db_error}"
439
620
 
440
621
  auth_log.info(
441
622
  "token_validated",
442
- f"Token validated and refreshed ({creds.get('scope', 'unknown')})",
443
- scope=creds.get("scope"),
623
+ f"Token validated and refreshed for {account.get('email')}",
624
+ email=account.get("email"),
444
625
  )
445
626
  return True, ""
446
627
 
@@ -450,6 +631,8 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
450
631
  error_type = error_json.get("error", "unknown")
451
632
  error_desc = error_json.get("error_description", "")
452
633
  if error_type == "invalid_grant":
634
+ # Mark account as needing re-auth
635
+ db.update_account(account["id"], is_active=False, last_error="invalid_grant")
453
636
  return False, f"Refresh token expired or revoked: {error_desc}. Please re-login."
454
637
  return False, f"OAuth error: {error_type} - {error_desc}"
455
638
  except Exception:
@@ -493,65 +676,81 @@ async def refresh_token_if_needed(
493
676
  """Auto-refresh token if expired. Returns True if valid token available.
494
677
 
495
678
  Args:
496
- project_id: Optional project ID to check for project-scoped creds
679
+ project_id: Optional project ID to get effective account for
497
680
  validate: If True, actually validate the token by calling API
498
681
 
499
682
  Returns:
500
- True if valid credentials exist (either not expired or successfully refreshed)
501
- False if no credentials or refresh failed
683
+ True if valid account exists (either not expired or successfully refreshed)
684
+ False if no account or refresh failed
502
685
  """
503
686
  db = Database()
504
- creds = db.get_credentials(project_id)
687
+ account = db.get_effective_account(project_id)
505
688
 
506
- if not creds:
689
+ if not account:
507
690
  return False
508
691
 
509
692
  now = int(time.time())
510
693
 
511
694
  # If validation requested, actually test the token
512
- # Note: validate_token_health() attempts a refresh as part of validation.
695
+ # Note: validate_account_token() attempts a refresh as part of validation.
513
696
  # If it fails, the refresh already failed - no point retrying immediately.
514
697
  if validate:
515
- is_valid, error = await validate_token_health(creds)
698
+ is_valid, error = await validate_account_token(account)
516
699
  if not is_valid:
517
700
  auth_log.warning(
518
701
  "token_invalid",
519
702
  f"Token validation failed: {error}",
520
- scope=creds.get("scope"),
703
+ email=account.get("email"),
521
704
  project_id=project_id,
522
705
  )
523
- # Don't retry - validate_token_health already tried to refresh.
706
+ # Don't retry - validate_account_token already tried to refresh.
524
707
  # The error message explains what went wrong.
525
708
  return False
526
709
  return True
527
710
 
528
711
  # Otherwise just check expiry timestamp (5 minute buffer)
529
- if now < creds["expires_at"] - 300:
712
+ if now < account["expires_at"] - 300:
530
713
  return True # Token assumed valid based on expiry
531
714
 
532
- return await _do_token_refresh(creds, project_id)
715
+ # Use lock to prevent concurrent refresh operations.
716
+ # Re-read account inside lock in case another process already refreshed.
717
+ async with _token_refresh_lock():
718
+ # Re-fetch account to get latest token state
719
+ fresh_account = db.get_effective_account(project_id)
720
+ if not fresh_account:
721
+ return False
722
+ # Check if another process already refreshed while we waited for lock
723
+ if int(time.time()) < fresh_account["expires_at"] - 300:
724
+ return True
725
+ return await _do_token_refresh(fresh_account, project_id)
533
726
 
534
727
 
535
728
  async def force_refresh_token(project_id: Optional[str] = None) -> dict:
536
729
  """Force refresh a token regardless of expiry time.
537
730
 
538
731
  Args:
539
- project_id: Optional project ID to check for project-scoped creds
732
+ project_id: Optional project ID to get effective account for
540
733
 
541
734
  Returns:
542
735
  dict with success status and message
543
736
  """
544
737
  db = Database()
545
- creds = db.get_credentials(project_id)
738
+ account = db.get_effective_account(project_id)
546
739
 
547
- if not creds:
548
- return {"success": False, "error": "No credentials found"}
740
+ if not account:
741
+ return {"success": False, "error": "No account found"}
549
742
 
550
- if not creds.get("refresh_token"):
743
+ if not account.get("refresh_token"):
551
744
  return {"success": False, "error": "No refresh token available"}
552
745
 
553
746
  try:
554
- success = await _do_token_refresh(creds, project_id)
747
+ # Use lock to prevent concurrent refresh operations.
748
+ # Re-read account inside lock in case another process already refreshed.
749
+ async with _token_refresh_lock():
750
+ fresh_account = db.get_effective_account(project_id)
751
+ if not fresh_account or not fresh_account.get("refresh_token"):
752
+ return {"success": False, "error": "No account or refresh token available"}
753
+ success = await _do_token_refresh(fresh_account, project_id)
555
754
  if success:
556
755
  return {"success": True, "message": "Token refreshed successfully"}
557
756
  else:
@@ -561,7 +760,7 @@ async def force_refresh_token(project_id: Optional[str] = None) -> dict:
561
760
 
562
761
 
563
762
  async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
564
- """Refresh all tokens expiring within buffer_seconds.
763
+ """Refresh all account tokens expiring within buffer_seconds.
565
764
 
566
765
  Called by background task to proactively keep tokens fresh.
567
766
 
@@ -575,35 +774,39 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
575
774
  now = int(time.time())
576
775
  result = {"checked": 0, "refreshed": 0, "failed": 0}
577
776
 
578
- # Get all credentials (global + all projects)
579
- all_creds = db.get_all_credentials()
777
+ # Get all active accounts
778
+ all_accounts = db.list_accounts(include_inactive=False, include_deleted=False)
580
779
 
581
- for creds in all_creds:
780
+ for account in all_accounts:
582
781
  result["checked"] += 1
583
782
 
584
783
  # Skip if not expiring soon
585
- if now < creds["expires_at"] - buffer_seconds:
784
+ if now < account["expires_at"] - buffer_seconds:
586
785
  continue
587
786
 
588
787
  # Skip if no refresh token
589
- if not creds.get("refresh_token"):
788
+ if not account.get("refresh_token"):
590
789
  continue
591
790
 
592
791
  # Attempt refresh with lock to prevent race conditions
593
792
  try:
594
793
  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:
794
+ # Re-fetch account to check if another process already refreshed
795
+ fresh_account = db.get_account(account["id"])
796
+ if fresh_account and now < fresh_account["expires_at"] - buffer_seconds:
598
797
  # Token was refreshed by another process, skip
599
798
  continue
600
799
 
800
+ # Use fresh_account's refresh_token (not the pre-lock stale one)
801
+ # Another process may have refreshed and changed the token while we waited
802
+ refresh_token = fresh_account["refresh_token"] if fresh_account else account["refresh_token"]
803
+
601
804
  async with httpx.AsyncClient() as client:
602
805
  resp = await client.post(
603
806
  TOKEN_URL,
604
807
  json={
605
808
  "grant_type": "refresh_token",
606
- "refresh_token": creds["refresh_token"],
809
+ "refresh_token": refresh_token,
607
810
  "client_id": CLIENT_ID,
608
811
  },
609
812
  headers={
@@ -615,72 +818,66 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
615
818
  if resp.status_code == 200:
616
819
  tokens = resp.json()
617
820
  new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
618
- account = tokens.get("account", {})
619
- email = account.get("email_address")
821
+
822
+ # Build update dict - capture subscription/plan info if present
823
+ # Use refresh_token (the fresh value used for this request) as fallback,
824
+ # not account["refresh_token"] which is the pre-lock stale copy
825
+ update_data = {
826
+ "access_token": tokens["access_token"],
827
+ "refresh_token": tokens.get("refresh_token", refresh_token),
828
+ "expires_at": new_expires_at,
829
+ "consecutive_failures": 0,
830
+ }
831
+ if tokens.get("subscription_type"):
832
+ update_data["subscription_type"] = tokens["subscription_type"]
833
+ if tokens.get("rate_limit_tier"):
834
+ update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
620
835
 
621
836
  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
- )
837
+ db.update_account(account["id"], **update_data)
629
838
  result["refreshed"] += 1
630
839
  auth_log.info(
631
840
  "token_refresh",
632
- f"Token refreshed ({creds['scope']})",
633
- scope=creds["scope"],
634
- project_id=creds.get("project_id"),
841
+ f"Token refreshed for {account.get('email')}",
842
+ email=account.get("email"),
843
+ account_id=account.get("id"),
635
844
  expires_in=tokens.get("expires_in", 28800),
636
845
  )
637
846
  except Exception as db_error:
638
847
  result["failed"] += 1
639
848
  auth_log.error(
640
849
  "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"),
850
+ f"Failed to save refreshed token for {account.get('email')}: {db_error}",
851
+ email=account.get("email"),
852
+ account_id=account.get("id"),
644
853
  )
645
854
  else:
646
855
  result["failed"] += 1
856
+ # Track consecutive failures (use fresh_account for accurate count)
857
+ failure_count = (fresh_account or account).get("consecutive_failures", 0)
858
+ db.update_account(
859
+ account["id"],
860
+ consecutive_failures=failure_count + 1,
861
+ )
647
862
  auth_log.warning(
648
863
  "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"),
864
+ f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code}",
865
+ email=account.get("email"),
866
+ account_id=account.get("id"),
652
867
  )
653
868
  except Exception as e:
654
869
  result["failed"] += 1
655
870
  auth_log.error(
656
871
  "token_refresh_error",
657
- f"Token refresh error ({creds['scope']}): {e}",
658
- scope=creds["scope"],
659
- project_id=creds.get("project_id"),
872
+ f"Token refresh error for {account.get('email')}: {e}",
873
+ email=account.get("email"),
874
+ account_id=account.get("id"),
660
875
  error=str(e),
661
876
  )
662
877
 
663
878
  return result
664
879
 
665
880
 
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
881
  @contextmanager
685
882
  def swap_credentials_for_loop(
686
883
  project_id: Optional[str] = None,
@@ -711,7 +908,8 @@ def swap_credentials_for_loop(
711
908
  global _credential_swap_active
712
909
 
713
910
  db = Database()
714
- creds = db.get_credentials(project_id)
911
+ # Get the effective account for this project (assigned or default)
912
+ account = db.get_effective_account(project_id)
715
913
 
716
914
  # Acquire exclusive lock to prevent concurrent credential access
717
915
  CREDENTIAL_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -732,13 +930,13 @@ def swap_credentials_for_loop(
732
930
  # Mark swap as active AFTER backup is complete
733
931
  _credential_swap_active = True
734
932
 
735
- # Write credentials from DB to Claude's location
933
+ # Write account credentials to Claude's location
736
934
  has_creds = False
737
- if creds:
738
- # Default scopes if not stored (for backwards compatibility)
935
+ if account:
936
+ # Default scopes if not stored
739
937
  # These are the minimum scopes Claude Code CLI needs for execution
740
938
  default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
741
- stored_scopes = creds.get("scopes")
939
+ stored_scopes = account.get("scopes")
742
940
  if stored_scopes:
743
941
  try:
744
942
  scopes = json.loads(stored_scopes)
@@ -749,20 +947,21 @@ def swap_credentials_for_loop(
749
947
 
750
948
  creds_data = {
751
949
  "claudeAiOauth": {
752
- "accessToken": creds["access_token"],
753
- "refreshToken": creds["refresh_token"],
754
- "expiresAt": creds["expires_at"] * 1000, # Convert to milliseconds
950
+ "accessToken": account["access_token"],
951
+ "refreshToken": account["refresh_token"],
952
+ "expiresAt": account["expires_at"] * 1000, # Convert to milliseconds
755
953
  "scopes": scopes,
756
954
  # subscriptionType: Claude subscription tier. Values include "free", "pro", "max".
757
955
  # Default to "max" as RalphX is designed for Max subscription users.
758
- "subscriptionType": creds.get("subscription_type") or "max",
956
+ "subscriptionType": account.get("subscription_type") or "max",
759
957
  # rateLimitTier: API rate limit tier. "default_claude_max_20x" indicates
760
958
  # the 20x rate limit multiplier for Max subscribers.
761
- "rateLimitTier": creds.get("rate_limit_tier") or "default_claude_max_20x",
959
+ "rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
762
960
  }
763
961
  }
764
962
  CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
765
963
  CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
964
+ os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
766
965
  has_creds = True
767
966
 
768
967
  try:
@@ -778,17 +977,18 @@ def swap_credentials_for_loop(
778
977
  new_refresh = oauth.get("refreshToken")
779
978
 
780
979
  # 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"],
980
+ if new_refresh and new_refresh != account["refresh_token"]:
981
+ db.update_account(
982
+ account["id"],
784
983
  access_token=oauth.get("accessToken"),
785
984
  refresh_token=new_refresh,
786
985
  expires_at=int(oauth.get("expiresAt", 0) / 1000),
787
986
  )
788
987
  auth_log.info(
789
988
  "token_captured",
790
- f"Captured refreshed token from Claude CLI ({creds['scope']})",
791
- scope=creds["scope"],
989
+ f"Captured refreshed token from Claude CLI ({account['email']})",
990
+ email=account["email"],
991
+ account_id=account["id"],
792
992
  project_id=project_id,
793
993
  )
794
994
  except Exception as e:
@@ -796,7 +996,7 @@ def swap_credentials_for_loop(
796
996
  auth_log.warning(
797
997
  "token_capture_failed",
798
998
  f"Failed to capture refreshed token: {e}",
799
- scope=creds.get("scope") if creds else None,
999
+ email=account.get("email") if account else None,
800
1000
  project_id=project_id,
801
1001
  )
802
1002