feat: multi-Oracle server support with runtime switching

Complete implementation of multi-server Oracle database support:

Backend:
- Multi-pool Oracle with lazy loading per server
- Email-to-server cache for automatic server discovery
- JWT tokens include server_id claim
- /auth/check-identity and /auth/check-email endpoints
- /auth/my-servers endpoint for listing user's accessible servers
- Server switch with password re-authentication

Frontend:
- New ServerSelector component for header dropdown
- Multi-step login flow (identity → server → password)
- Server switching from header with password modal
- Mobile drawer menu with server selection
- Dark mode support for all new components
- URL bookmark support with ?server= query param

Scripts:
- Unified start.sh replacing start-prod.sh/start-test.sh
- Unified ssh-tunnel.sh with multi-server support
- Updated status.sh for new architecture

Tests:
- E2E tests for multi-server and single-server login flows
- Backend unit tests for all new endpoints
- Oracle multi-pool integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

View File

@@ -17,6 +17,9 @@ logger = logging.getLogger(__name__)
DB_DIR = Path(__file__).parent.parent.parent / "data"
DB_PATH = DB_DIR / "telegram_bot.db"
# SQLite busy timeout in milliseconds (wait for locks instead of failing immediately)
SQLITE_BUSY_TIMEOUT_MS = 5000
async def get_db_connection() -> aiosqlite.Connection:
"""
@@ -41,6 +44,10 @@ async def init_database() -> None:
logger.info(f"Database directory: {DB_DIR}")
async with aiosqlite.connect(DB_PATH) as db:
# Enable WAL mode for better concurrent access
await db.execute("PRAGMA journal_mode=WAL")
# Set busy timeout to wait for locks instead of failing immediately
await db.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}")
# Enable foreign keys
await db.execute("PRAGMA foreign_keys = ON")

View File

@@ -43,6 +43,7 @@ async def create_or_update_user(
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
INSERT INTO telegram_users (
@@ -77,6 +78,7 @@ async def get_user(telegram_user_id: int) -> Optional[Dict[str, Any]]:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT * FROM telegram_users
@@ -115,6 +117,7 @@ async def link_user_to_oracle(
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE telegram_users
@@ -163,6 +166,7 @@ async def update_user_tokens(
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE telegram_users
@@ -193,6 +197,7 @@ async def update_user_last_active(telegram_user_id: int) -> bool:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE telegram_users
@@ -220,6 +225,7 @@ async def is_user_linked(telegram_user_id: int) -> bool:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT oracle_username FROM telegram_users
@@ -246,6 +252,7 @@ async def is_user_authenticated(telegram_user_id: int) -> bool:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT oracle_username, jwt_token, token_expires_at
@@ -299,6 +306,7 @@ async def create_auth_code(
expires_at = datetime.now() + timedelta(minutes=expires_in_minutes)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
INSERT INTO telegram_auth_codes (
@@ -328,6 +336,7 @@ async def get_auth_code(code: str) -> Optional[Dict[str, Any]]:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT * FROM telegram_auth_codes
@@ -356,6 +365,7 @@ async def verify_and_use_auth_code(code: str) -> Optional[Dict[str, Any]]:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
# Check if code exists, is not used, and not expired
cursor = await db.execute("""
@@ -399,6 +409,7 @@ async def get_pending_codes_for_user(telegram_user_id: int) -> List[Dict[str, An
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT * FROM telegram_auth_codes
@@ -431,6 +442,7 @@ async def get_pending_email_code(
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT code, email, oracle_username, expires_at, failed_attempts
@@ -476,6 +488,7 @@ async def create_email_auth_code(
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
INSERT INTO email_auth_codes
@@ -500,6 +513,7 @@ async def get_email_auth_code(code: str) -> Optional[Dict]:
"""Get email auth code details"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT code, email, oracle_username, telegram_user_id,
@@ -534,6 +548,7 @@ async def increment_failed_attempts(code: str) -> bool:
"""Increment failed validation attempts for code"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE email_auth_codes
@@ -553,6 +568,7 @@ async def mark_email_code_used(code: str) -> bool:
"""Mark email code as used"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE email_auth_codes
@@ -574,6 +590,7 @@ async def delete_user_email_codes(telegram_user_id: int) -> int:
"""Delete all email codes for user (cleanup)"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
DELETE FROM email_auth_codes
@@ -616,6 +633,7 @@ async def create_session(
expires_at = datetime.now() + timedelta(hours=expires_in_hours)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
INSERT INTO telegram_sessions (
@@ -645,6 +663,7 @@ async def get_session(session_id: str) -> Optional[Dict[str, Any]]:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT * FROM telegram_sessions
@@ -674,6 +693,7 @@ async def get_user_active_session(telegram_user_id: int) -> Optional[Dict[str, A
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT * FROM telegram_sessions
@@ -709,6 +729,7 @@ async def update_session_state(
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE telegram_sessions
@@ -738,6 +759,7 @@ async def delete_session(session_id: str) -> bool:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
await db.execute("""
DELETE FROM telegram_sessions
@@ -765,6 +787,7 @@ async def delete_user_sessions(telegram_user_id: int) -> bool:
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA busy_timeout=5000")
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
DELETE FROM telegram_sessions