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.
- rem/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +2 -0
- rem/agentic/context.py +103 -5
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +161 -18
- rem/agentic/otel/setup.py +1 -0
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +172 -30
- rem/agentic/schema.py +8 -4
- rem/api/deps.py +3 -5
- rem/api/main.py +26 -4
- rem/api/mcp_router/resources.py +15 -10
- rem/api/mcp_router/server.py +11 -3
- rem/api/mcp_router/tools.py +418 -4
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/admin.py +218 -1
- rem/api/routers/auth.py +349 -6
- rem/api/routers/chat/completions.py +255 -7
- rem/api/routers/chat/models.py +81 -7
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +17 -1
- rem/api/routers/chat/streaming.py +126 -19
- rem/api/routers/feedback.py +134 -14
- rem/api/routers/messages.py +24 -15
- rem/api/routers/query.py +6 -3
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +115 -10
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +42 -0
- rem/cli/commands/cluster.py +617 -168
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +66 -22
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/schema.py +6 -5
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/config.py +8 -1
- rem/models/core/experiment.py +58 -14
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +4 -3
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +513 -0
- rem/services/email/templates.py +360 -0
- rem/services/phoenix/client.py +59 -18
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +127 -6
- rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
- rem/services/postgres/repository.py +5 -4
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/session/compression.py +120 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +442 -23
- rem/sql/migrations/001_install.sql +156 -0
- rem/sql/migrations/002_install_models.sql +1951 -88
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/files.py +157 -1
- rem/utils/schema_loader.py +139 -10
- rem/utils/sql_paths.py +146 -0
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
2
|
+
Authentication Router.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
+
}
|