remdb 0.3.114__py3-none-any.whl → 0.3.172__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (83) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/agents/sse_simulator.py +2 -0
  4. rem/agentic/context.py +103 -5
  5. rem/agentic/context_builder.py +36 -9
  6. rem/agentic/mcp/tool_wrapper.py +161 -18
  7. rem/agentic/otel/setup.py +1 -0
  8. rem/agentic/providers/phoenix.py +371 -108
  9. rem/agentic/providers/pydantic_ai.py +172 -30
  10. rem/agentic/schema.py +8 -4
  11. rem/api/deps.py +3 -5
  12. rem/api/main.py +26 -4
  13. rem/api/mcp_router/resources.py +15 -10
  14. rem/api/mcp_router/server.py +11 -3
  15. rem/api/mcp_router/tools.py +418 -4
  16. rem/api/middleware/tracking.py +5 -5
  17. rem/api/routers/admin.py +218 -1
  18. rem/api/routers/auth.py +349 -6
  19. rem/api/routers/chat/completions.py +255 -7
  20. rem/api/routers/chat/models.py +81 -7
  21. rem/api/routers/chat/otel_utils.py +33 -0
  22. rem/api/routers/chat/sse_events.py +17 -1
  23. rem/api/routers/chat/streaming.py +126 -19
  24. rem/api/routers/feedback.py +134 -14
  25. rem/api/routers/messages.py +24 -15
  26. rem/api/routers/query.py +6 -3
  27. rem/auth/__init__.py +13 -3
  28. rem/auth/jwt.py +352 -0
  29. rem/auth/middleware.py +115 -10
  30. rem/auth/providers/__init__.py +4 -1
  31. rem/auth/providers/email.py +215 -0
  32. rem/cli/commands/README.md +42 -0
  33. rem/cli/commands/cluster.py +617 -168
  34. rem/cli/commands/configure.py +4 -7
  35. rem/cli/commands/db.py +66 -22
  36. rem/cli/commands/experiments.py +468 -76
  37. rem/cli/commands/schema.py +6 -5
  38. rem/cli/commands/session.py +336 -0
  39. rem/cli/dreaming.py +2 -2
  40. rem/cli/main.py +2 -0
  41. rem/config.py +8 -1
  42. rem/models/core/experiment.py +58 -14
  43. rem/models/entities/__init__.py +4 -0
  44. rem/models/entities/ontology.py +1 -1
  45. rem/models/entities/ontology_config.py +1 -1
  46. rem/models/entities/subscriber.py +175 -0
  47. rem/models/entities/user.py +1 -0
  48. rem/schemas/agents/core/agent-builder.yaml +235 -0
  49. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  50. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  51. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  52. rem/services/__init__.py +3 -1
  53. rem/services/content/service.py +4 -3
  54. rem/services/email/__init__.py +10 -0
  55. rem/services/email/service.py +513 -0
  56. rem/services/email/templates.py +360 -0
  57. rem/services/phoenix/client.py +59 -18
  58. rem/services/postgres/README.md +38 -0
  59. rem/services/postgres/diff_service.py +127 -6
  60. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  61. rem/services/postgres/repository.py +5 -4
  62. rem/services/postgres/schema_generator.py +205 -4
  63. rem/services/session/compression.py +120 -50
  64. rem/services/session/reload.py +14 -7
  65. rem/services/user_service.py +41 -9
  66. rem/settings.py +442 -23
  67. rem/sql/migrations/001_install.sql +156 -0
  68. rem/sql/migrations/002_install_models.sql +1951 -88
  69. rem/sql/migrations/004_cache_system.sql +548 -0
  70. rem/sql/migrations/005_schema_update.sql +145 -0
  71. rem/utils/README.md +45 -0
  72. rem/utils/__init__.py +18 -0
  73. rem/utils/files.py +157 -1
  74. rem/utils/schema_loader.py +139 -10
  75. rem/utils/sql_paths.py +146 -0
  76. rem/utils/vision.py +1 -1
  77. rem/workers/__init__.py +3 -1
  78. rem/workers/db_listener.py +579 -0
  79. rem/workers/unlogged_maintainer.py +463 -0
  80. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
  81. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
  82. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  83. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
rem/api/routers/admin.py CHANGED
@@ -9,6 +9,9 @@ Endpoints:
9
9
  GET /api/admin/messages - List all messages across users (admin only)
10
10
  GET /api/admin/stats - System statistics (admin only)
11
11
 
12
+ Internal Endpoints (hidden from Swagger, secret-protected):
13
+ POST /api/admin/internal/rebuild-kv - Trigger kv_store rebuild (called by pg_net)
14
+
12
15
  All endpoints require:
13
16
  1. Authentication (valid session)
14
17
  2. Admin role in user's roles list
@@ -17,11 +20,14 @@ Design Pattern:
17
20
  - Uses require_admin dependency for role enforcement
18
21
  - Cross-tenant queries (no user_id filtering)
19
22
  - Audit logging for admin actions
23
+ - Internal endpoints use X-Internal-Secret header for authentication
20
24
  """
21
25
 
26
+ import asyncio
27
+ import threading
22
28
  from typing import Literal
23
29
 
24
- from fastapi import APIRouter, Depends, HTTPException, Query
30
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, BackgroundTasks
25
31
  from loguru import logger
26
32
  from pydantic import BaseModel
27
33
 
@@ -32,6 +38,12 @@ from ...settings import settings
32
38
 
33
39
  router = APIRouter(prefix="/api/admin", tags=["admin"])
34
40
 
41
+ # =============================================================================
42
+ # Internal Router (hidden from Swagger)
43
+ # =============================================================================
44
+
45
+ internal_router = APIRouter(prefix="/internal", include_in_schema=False)
46
+
35
47
 
36
48
  # =============================================================================
37
49
  # Response Models
@@ -275,3 +287,208 @@ async def get_system_stats(
275
287
  active_sessions_24h=0, # TODO: implement
276
288
  messages_24h=0, # TODO: implement
277
289
  )
290
+
291
+
292
+ # =============================================================================
293
+ # Internal Endpoints (hidden from Swagger, secret-protected)
294
+ # =============================================================================
295
+
296
+
297
+ class RebuildKVRequest(BaseModel):
298
+ """Request body for kv_store rebuild trigger."""
299
+
300
+ user_id: str | None = None
301
+ triggered_by: str = "api"
302
+ timestamp: str | None = None
303
+
304
+
305
+ class RebuildKVResponse(BaseModel):
306
+ """Response from kv_store rebuild trigger."""
307
+
308
+ status: Literal["submitted", "started", "skipped"]
309
+ message: str
310
+ job_method: str | None = None # "sqs" or "thread"
311
+
312
+
313
+ async def _get_internal_secret() -> str | None:
314
+ """
315
+ Get the internal API secret from cache_system_state table.
316
+
317
+ Returns None if the table doesn't exist or secret not found.
318
+ """
319
+ from ...services.postgres import get_postgres_service
320
+
321
+ db = get_postgres_service()
322
+ if not db:
323
+ return None
324
+
325
+ try:
326
+ await db.connect()
327
+ secret = await db.fetchval("SELECT rem_get_cache_api_secret()")
328
+ return secret
329
+ except Exception as e:
330
+ logger.warning(f"Could not get internal API secret: {e}")
331
+ return None
332
+ finally:
333
+ await db.disconnect()
334
+
335
+
336
+ async def _validate_internal_secret(x_internal_secret: str | None = Header(None)):
337
+ """
338
+ Dependency to validate the X-Internal-Secret header.
339
+
340
+ Raises 401 if secret is missing or invalid.
341
+ """
342
+ if not x_internal_secret:
343
+ logger.warning("Internal endpoint called without X-Internal-Secret header")
344
+ raise HTTPException(status_code=401, detail="Missing X-Internal-Secret header")
345
+
346
+ expected_secret = await _get_internal_secret()
347
+ if not expected_secret:
348
+ logger.error("Could not retrieve internal secret from database")
349
+ raise HTTPException(status_code=503, detail="Internal secret not configured")
350
+
351
+ if x_internal_secret != expected_secret:
352
+ logger.warning("Internal endpoint called with invalid secret")
353
+ raise HTTPException(status_code=401, detail="Invalid X-Internal-Secret")
354
+
355
+ return True
356
+
357
+
358
+ def _run_rebuild_in_thread():
359
+ """
360
+ Run the kv_store rebuild in a background thread.
361
+
362
+ This is the fallback when SQS is not available.
363
+ """
364
+
365
+ def rebuild_task():
366
+ """Thread target function."""
367
+ import asyncio
368
+ from ...workers.unlogged_maintainer import UnloggedMaintainer
369
+
370
+ async def _run():
371
+ maintainer = UnloggedMaintainer()
372
+ if not maintainer.db:
373
+ logger.error("Database not configured, cannot rebuild")
374
+ return
375
+ try:
376
+ await maintainer.db.connect()
377
+ await maintainer.rebuild_with_lock()
378
+ except Exception as e:
379
+ logger.error(f"Background rebuild failed: {e}")
380
+ finally:
381
+ await maintainer.db.disconnect()
382
+
383
+ # Create new event loop for this thread
384
+ loop = asyncio.new_event_loop()
385
+ asyncio.set_event_loop(loop)
386
+ try:
387
+ loop.run_until_complete(_run())
388
+ finally:
389
+ loop.close()
390
+
391
+ thread = threading.Thread(target=rebuild_task, name="kv-rebuild-worker")
392
+ thread.daemon = True
393
+ thread.start()
394
+ logger.info(f"Started background rebuild thread: {thread.name}")
395
+
396
+
397
+ def _submit_sqs_rebuild_job_sync(request: RebuildKVRequest) -> bool:
398
+ """
399
+ Submit rebuild job to SQS queue (synchronous).
400
+
401
+ Returns True if job was submitted, False if SQS unavailable.
402
+ """
403
+ import json
404
+
405
+ import boto3
406
+ from botocore.exceptions import ClientError
407
+
408
+ if not settings.sqs.queue_url:
409
+ logger.debug("SQS queue URL not configured, cannot submit SQS job")
410
+ return False
411
+
412
+ try:
413
+ sqs = boto3.client("sqs", region_name=settings.sqs.region)
414
+
415
+ message_body = {
416
+ "action": "rebuild_kv_store",
417
+ "user_id": request.user_id,
418
+ "triggered_by": request.triggered_by,
419
+ "timestamp": request.timestamp,
420
+ }
421
+
422
+ response = sqs.send_message(
423
+ QueueUrl=settings.sqs.queue_url,
424
+ MessageBody=json.dumps(message_body),
425
+ MessageAttributes={
426
+ "action": {"DataType": "String", "StringValue": "rebuild_kv_store"},
427
+ },
428
+ )
429
+
430
+ message_id = response.get("MessageId")
431
+ logger.info(f"Submitted rebuild job to SQS: {message_id}")
432
+ return True
433
+
434
+ except ClientError as e:
435
+ logger.warning(f"Failed to submit SQS job: {e}")
436
+ return False
437
+ except Exception as e:
438
+ logger.warning(f"SQS submission error: {e}")
439
+ return False
440
+
441
+
442
+ async def _submit_sqs_rebuild_job(request: RebuildKVRequest) -> bool:
443
+ """
444
+ Submit rebuild job to SQS queue (async wrapper).
445
+
446
+ Runs boto3 call in thread pool to avoid blocking event loop.
447
+ """
448
+ import asyncio
449
+
450
+ return await asyncio.to_thread(_submit_sqs_rebuild_job_sync, request)
451
+
452
+
453
+ @internal_router.post("/rebuild-kv", response_model=RebuildKVResponse)
454
+ async def trigger_kv_rebuild(
455
+ request: RebuildKVRequest,
456
+ _: bool = Depends(_validate_internal_secret),
457
+ ) -> RebuildKVResponse:
458
+ """
459
+ Trigger kv_store rebuild (internal endpoint, not shown in Swagger).
460
+
461
+ Called by pg_net from PostgreSQL when self-healing detects empty cache.
462
+ Authentication: X-Internal-Secret header must match secret in cache_system_state.
463
+
464
+ Priority:
465
+ 1. Submit job to SQS (if configured) - scales with KEDA
466
+ 2. Fallback to background thread - runs in same process
467
+
468
+ Note: This endpoint returns immediately. Rebuild happens asynchronously.
469
+ """
470
+ logger.info(
471
+ f"Rebuild kv_store requested by {request.triggered_by} "
472
+ f"(user_id={request.user_id})"
473
+ )
474
+
475
+ # Try SQS first
476
+ if await _submit_sqs_rebuild_job(request):
477
+ return RebuildKVResponse(
478
+ status="submitted",
479
+ message="Rebuild job submitted to SQS queue",
480
+ job_method="sqs",
481
+ )
482
+
483
+ # Fallback to background thread
484
+ _run_rebuild_in_thread()
485
+
486
+ return RebuildKVResponse(
487
+ status="started",
488
+ message="Rebuild started in background thread (SQS unavailable)",
489
+ job_method="thread",
490
+ )
491
+
492
+
493
+ # Include internal router in main router
494
+ router.include_router(internal_router)
rem/api/routers/auth.py CHANGED
@@ -1,20 +1,68 @@
1
1
  """
2
- OAuth 2.1 Authentication Router.
2
+ Authentication Router.
3
3
 
4
- Leverages Authlib for standards-compliant OAuth/OIDC implementation.
5
- Minimal custom code - Authlib handles PKCE, token validation, JWKS.
4
+ Supports multiple authentication methods:
5
+ 1. Email (passwordless): POST /api/auth/email/send-code, POST /api/auth/email/verify
6
+ 2. OAuth (Google, Microsoft): GET /api/auth/{provider}/login, GET /api/auth/{provider}/callback
6
7
 
7
8
  Endpoints:
9
+ - POST /api/auth/email/send-code - Send login code to email
10
+ - POST /api/auth/email/verify - Verify code and create session
8
11
  - GET /api/auth/{provider}/login - Initiate OAuth flow
9
12
  - GET /api/auth/{provider}/callback - OAuth callback
10
13
  - POST /api/auth/logout - Clear session
11
14
  - GET /api/auth/me - Current user info
12
15
 
13
16
  Supported providers:
17
+ - email: Passwordless email login
14
18
  - google: Google OAuth 2.0 / OIDC
15
19
  - microsoft: Microsoft Entra ID OIDC
16
20
 
17
- Design Pattern (OAuth 2.1 + PKCE):
21
+ =============================================================================
22
+ Email Authentication Access Control
23
+ =============================================================================
24
+
25
+ The email auth provider implements a tiered access control system:
26
+
27
+ Access Control Flow (send-code):
28
+ User requests login code
29
+ ├── User exists in database?
30
+ │ ├── Yes → Check user.tier
31
+ │ │ ├── tier == BLOCKED → Reject "Account is blocked"
32
+ │ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
33
+ │ └── No (new user) → Check EMAIL__TRUSTED_EMAIL_DOMAINS
34
+ │ ├── Setting configured → domain in trusted list?
35
+ │ │ ├── Yes → Create user & send code
36
+ │ │ └── No → Reject "Email domain not allowed for signup"
37
+ │ └── Not configured (empty) → Create user & send code (no restrictions)
38
+
39
+ Key Behaviors:
40
+ - Existing users: Always allowed to login (unless tier=BLOCKED)
41
+ - New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
42
+ - No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
43
+
44
+ User Tiers (models.entities.UserTier):
45
+ - BLOCKED: Cannot login (rejected at send-code)
46
+ - ANONYMOUS: Rate-limited anonymous access
47
+ - FREE: Standard free tier
48
+ - BASIC/PRO: Paid tiers with additional features
49
+
50
+ Configuration:
51
+ # Allow only specific domains for new signups
52
+ EMAIL__TRUSTED_EMAIL_DOMAINS=siggymd.ai,example.com
53
+
54
+ # Allow all domains (no restrictions)
55
+ EMAIL__TRUSTED_EMAIL_DOMAINS=
56
+
57
+ Example blocking a user:
58
+ user = await user_repo.get_by_id(user_id, tenant_id="default")
59
+ user.tier = UserTier.BLOCKED
60
+ await user_repo.upsert(user)
61
+
62
+ =============================================================================
63
+ OAuth Design Pattern (OAuth 2.1 + PKCE)
64
+ =============================================================================
65
+
18
66
  1. User clicks "Login with Google"
19
67
  2. /login generates state + PKCE code_verifier
20
68
  3. Store code_verifier in session
@@ -37,6 +85,7 @@ Environment variables:
37
85
  AUTH__MICROSOFT__CLIENT_ID=<microsoft-client-id>
38
86
  AUTH__MICROSOFT__CLIENT_SECRET=<microsoft-client-secret>
39
87
  AUTH__MICROSOFT__TENANT=common
88
+ EMAIL__TRUSTED_EMAIL_DOMAINS=example.com # Optional: restrict new signups
40
89
 
41
90
  References:
42
91
  - Authlib: https://docs.authlib.org/en/latest/
@@ -46,11 +95,15 @@ References:
46
95
  from fastapi import APIRouter, HTTPException, Request
47
96
  from fastapi.responses import RedirectResponse
48
97
  from authlib.integrations.starlette_client import OAuth
98
+ from pydantic import BaseModel, EmailStr
49
99
  from loguru import logger
50
100
 
51
101
  from ...settings import settings
52
102
  from ...services.postgres.service import PostgresService
53
103
  from ...services.user_service import UserService
104
+ from ...auth.providers.email import EmailAuthProvider
105
+ from ...auth.jwt import JWTService, get_jwt_service
106
+ from ...utils.user_id import email_to_user_id
54
107
 
55
108
  router = APIRouter(prefix="/api/auth", tags=["auth"])
56
109
 
@@ -87,6 +140,182 @@ if settings.auth.microsoft.client_id:
87
140
  logger.info(f"Microsoft OAuth provider registered (tenant: {tenant})")
88
141
 
89
142
 
143
+ # =============================================================================
144
+ # Email Authentication Endpoints
145
+ # =============================================================================
146
+
147
+
148
+ class EmailSendCodeRequest(BaseModel):
149
+ """Request to send login code."""
150
+ email: EmailStr
151
+
152
+
153
+ class EmailVerifyRequest(BaseModel):
154
+ """Request to verify login code."""
155
+ email: EmailStr
156
+ code: str
157
+
158
+
159
+ @router.post("/email/send-code")
160
+ async def send_email_code(request: Request, body: EmailSendCodeRequest):
161
+ """
162
+ Send a login code to an email address.
163
+
164
+ Creates user if not exists (using deterministic UUID from email).
165
+ Stores code in user metadata with expiry.
166
+
167
+ Args:
168
+ request: FastAPI request
169
+ body: EmailSendCodeRequest with email
170
+
171
+ Returns:
172
+ Success status and message
173
+ """
174
+ if not settings.email.is_configured:
175
+ raise HTTPException(
176
+ status_code=501,
177
+ detail="Email authentication is not configured"
178
+ )
179
+
180
+ # Get database connection
181
+ if not settings.postgres.enabled:
182
+ raise HTTPException(
183
+ status_code=501,
184
+ detail="Database is required for email authentication"
185
+ )
186
+
187
+ db = PostgresService()
188
+ try:
189
+ await db.connect()
190
+
191
+ # Initialize email auth provider
192
+ email_auth = EmailAuthProvider()
193
+
194
+ # Send code
195
+ result = await email_auth.send_code(
196
+ email=body.email,
197
+ db=db,
198
+ )
199
+
200
+ if result.success:
201
+ return {
202
+ "success": True,
203
+ "message": result.message,
204
+ "email": result.email,
205
+ }
206
+ else:
207
+ raise HTTPException(
208
+ status_code=400,
209
+ detail=result.message or result.error
210
+ )
211
+
212
+ except HTTPException:
213
+ raise
214
+ except Exception as e:
215
+ logger.error(f"Error sending login code: {e}")
216
+ raise HTTPException(status_code=500, detail="Failed to send login code")
217
+ finally:
218
+ await db.disconnect()
219
+
220
+
221
+ @router.post("/email/verify")
222
+ async def verify_email_code(request: Request, body: EmailVerifyRequest):
223
+ """
224
+ Verify login code and create session with JWT tokens.
225
+
226
+ Args:
227
+ request: FastAPI request
228
+ body: EmailVerifyRequest with email and code
229
+
230
+ Returns:
231
+ Success status with user info and JWT tokens
232
+ """
233
+ if not settings.email.is_configured:
234
+ raise HTTPException(
235
+ status_code=501,
236
+ detail="Email authentication is not configured"
237
+ )
238
+
239
+ if not settings.postgres.enabled:
240
+ raise HTTPException(
241
+ status_code=501,
242
+ detail="Database is required for email authentication"
243
+ )
244
+
245
+ db = PostgresService()
246
+ try:
247
+ await db.connect()
248
+
249
+ # Initialize email auth provider
250
+ email_auth = EmailAuthProvider()
251
+
252
+ # Verify code
253
+ result = await email_auth.verify_code(
254
+ email=body.email,
255
+ code=body.code,
256
+ db=db,
257
+ )
258
+
259
+ if not result.success:
260
+ raise HTTPException(
261
+ status_code=400,
262
+ detail=result.message or result.error
263
+ )
264
+
265
+ # Create session - compatible with OAuth session format
266
+ user_dict = email_auth.get_user_dict(
267
+ email=result.email,
268
+ user_id=result.user_id,
269
+ )
270
+
271
+ # Fetch actual user data from database to get role/tier
272
+ user_service = UserService(db)
273
+ try:
274
+ user_entity = await user_service.get_user_by_id(result.user_id)
275
+ if user_entity:
276
+ # Override defaults with actual database values
277
+ user_dict["role"] = user_entity.role or "user"
278
+ user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
279
+ user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
280
+ user_dict["name"] = user_entity.name or user_dict["name"]
281
+ except Exception as e:
282
+ logger.warning(f"Could not fetch user details: {e}")
283
+ # Continue with defaults from get_user_dict
284
+
285
+ # Generate JWT tokens
286
+ jwt_service = get_jwt_service()
287
+ tokens = jwt_service.create_tokens(user_dict)
288
+
289
+ # Store user in session (for backward compatibility)
290
+ request.session["user"] = user_dict
291
+
292
+ logger.info(f"User authenticated via email: {result.email}")
293
+
294
+ return {
295
+ "success": True,
296
+ "message": result.message,
297
+ "user": user_dict,
298
+ # JWT tokens for stateless auth
299
+ "access_token": tokens["access_token"],
300
+ "refresh_token": tokens["refresh_token"],
301
+ "token_type": tokens["token_type"],
302
+ "expires_in": tokens["expires_in"],
303
+ }
304
+
305
+ except HTTPException:
306
+ raise
307
+ except Exception as e:
308
+ logger.error(f"Error verifying login code: {e}")
309
+ raise HTTPException(status_code=500, detail="Failed to verify login code")
310
+ finally:
311
+ await db.disconnect()
312
+
313
+
314
+ # =============================================================================
315
+ # OAuth Authentication Endpoints
316
+ # =============================================================================
317
+
318
+
90
319
  @router.get("/{provider}/login")
91
320
  async def login(provider: str, request: Request):
92
321
  """
@@ -201,8 +430,9 @@ async def callback(provider: str, request: Request):
201
430
  await user_service.link_anonymous_session(user_entity, anon_id)
202
431
 
203
432
  # Enrich session user with DB info
433
+ # user_id = UUID5 hash of email (deterministic, bijection)
204
434
  db_info = {
205
- "id": str(user_entity.id),
435
+ "id": email_to_user_id(user_info.get("email")),
206
436
  "tenant_id": user_entity.tenant_id,
207
437
  "tier": user_entity.tier.value if user_entity.tier else "free",
208
438
  "roles": [user_entity.role] if user_entity.role else [],
@@ -268,7 +498,7 @@ async def logout(request: Request):
268
498
  @router.get("/me")
269
499
  async def me(request: Request):
270
500
  """
271
- Get current user information from session.
501
+ Get current user information from session or JWT.
272
502
 
273
503
  Args:
274
504
  request: FastAPI request
@@ -276,6 +506,16 @@ async def me(request: Request):
276
506
  Returns:
277
507
  User information or 401 if not authenticated
278
508
  """
509
+ # First check for JWT in Authorization header
510
+ auth_header = request.headers.get("Authorization")
511
+ if auth_header and auth_header.startswith("Bearer "):
512
+ token = auth_header[7:]
513
+ jwt_service = get_jwt_service()
514
+ user = jwt_service.verify_token(token)
515
+ if user:
516
+ return user
517
+
518
+ # Fall back to session
279
519
  user = request.session.get("user")
280
520
  if not user:
281
521
  raise HTTPException(status_code=401, detail="Not authenticated")
@@ -283,6 +523,69 @@ async def me(request: Request):
283
523
  return user
284
524
 
285
525
 
526
+ # =============================================================================
527
+ # JWT Token Endpoints
528
+ # =============================================================================
529
+
530
+
531
+ class TokenRefreshRequest(BaseModel):
532
+ """Request to refresh access token."""
533
+ refresh_token: str
534
+
535
+
536
+ @router.post("/token/refresh")
537
+ async def refresh_token(body: TokenRefreshRequest):
538
+ """
539
+ Refresh access token using refresh token.
540
+
541
+ Args:
542
+ body: TokenRefreshRequest with refresh_token
543
+
544
+ Returns:
545
+ New access token or 401 if refresh token is invalid
546
+ """
547
+ jwt_service = get_jwt_service()
548
+ result = jwt_service.refresh_access_token(body.refresh_token)
549
+
550
+ if not result:
551
+ raise HTTPException(
552
+ status_code=401,
553
+ detail="Invalid or expired refresh token"
554
+ )
555
+
556
+ return result
557
+
558
+
559
+ @router.post("/token/verify")
560
+ async def verify_token(request: Request):
561
+ """
562
+ Verify an access token is valid.
563
+
564
+ Pass the token in the Authorization header: Bearer <token>
565
+
566
+ Returns:
567
+ User info if valid, 401 if invalid
568
+ """
569
+ auth_header = request.headers.get("Authorization")
570
+ if not auth_header or not auth_header.startswith("Bearer "):
571
+ raise HTTPException(
572
+ status_code=401,
573
+ detail="Missing Authorization header"
574
+ )
575
+
576
+ token = auth_header[7:]
577
+ jwt_service = get_jwt_service()
578
+ user = jwt_service.verify_token(token)
579
+
580
+ if not user:
581
+ raise HTTPException(
582
+ status_code=401,
583
+ detail="Invalid or expired token"
584
+ )
585
+
586
+ return {"valid": True, "user": user}
587
+
588
+
286
589
  # =============================================================================
287
590
  # Development Token Endpoints (non-production only)
288
591
  # =============================================================================
@@ -351,3 +654,43 @@ async def get_dev_token(request: Request):
351
654
  "usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
352
655
  "warning": "This token is for development/testing only and will not work in production.",
353
656
  }
657
+
658
+
659
+ @router.get("/dev/mock-code/{email}")
660
+ async def get_mock_code(email: str, request: Request):
661
+ """
662
+ Get the mock login code for testing (non-production only).
663
+
664
+ This endpoint retrieves the code that was "sent" via email in mock mode.
665
+ Use this for automated testing without real email delivery.
666
+
667
+ Usage:
668
+ 1. POST /api/auth/email/send-code with email
669
+ 2. GET /api/auth/dev/mock-code/{email} to retrieve the code
670
+ 3. POST /api/auth/email/verify with email and code
671
+
672
+ Returns:
673
+ 401 if in production environment
674
+ 404 if no code found for the email
675
+ The code and email otherwise
676
+ """
677
+ if settings.environment == "production":
678
+ raise HTTPException(
679
+ status_code=401,
680
+ detail="Mock codes are not available in production"
681
+ )
682
+
683
+ from ...services.email import EmailService
684
+
685
+ code = EmailService.get_mock_code(email)
686
+ if not code:
687
+ raise HTTPException(
688
+ status_code=404,
689
+ detail=f"No mock code found for {email}. Send a code first."
690
+ )
691
+
692
+ return {
693
+ "email": email,
694
+ "code": code,
695
+ "warning": "This endpoint is for testing only and will not work in production.",
696
+ }