remdb 0.3.146__py3-none-any.whl → 0.3.181__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/context.py +81 -3
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +43 -14
- rem/agentic/providers/pydantic_ai.py +76 -34
- rem/agentic/schema.py +4 -3
- rem/agentic/tools/rem_tools.py +11 -0
- rem/api/deps.py +3 -5
- rem/api/main.py +22 -3
- rem/api/mcp_router/resources.py +75 -14
- rem/api/mcp_router/server.py +28 -23
- rem/api/mcp_router/tools.py +177 -2
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/auth.py +352 -6
- rem/api/routers/chat/completions.py +5 -3
- rem/api/routers/chat/streaming.py +95 -22
- rem/api/routers/messages.py +24 -15
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +70 -30
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/ask.py +1 -1
- rem/cli/commands/db.py +118 -54
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +93 -101
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- 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 +522 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/worker.py +26 -12
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +19 -3
- rem/services/postgres/pydantic_to_sqlalchemy.py +37 -2
- rem/services/postgres/register_type.py +1 -1
- rem/services/postgres/repository.py +37 -25
- rem/services/postgres/schema_generator.py +5 -5
- rem/services/postgres/sql_builder.py +6 -5
- rem/services/session/compression.py +113 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +182 -1
- rem/sql/background_indexes.sql +5 -0
- rem/sql/migrations/001_install.sql +33 -4
- rem/sql/migrations/002_install_models.sql +204 -186
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/model_helpers.py +101 -0
- rem/utils/schema_loader.py +45 -7
- {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/METADATA +1 -1
- {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/RECORD +57 -48
- {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/WHEEL +0 -0
- {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/entry_points.txt +0 -0
rem/api/mcp_router/tools.py
CHANGED
|
@@ -116,7 +116,8 @@ def mcp_tool_error_handler(func: Callable) -> Callable:
|
|
|
116
116
|
# Otherwise wrap in success response
|
|
117
117
|
return {"status": "success", **result}
|
|
118
118
|
except Exception as e:
|
|
119
|
-
|
|
119
|
+
# Use %s format to avoid issues with curly braces in error messages
|
|
120
|
+
logger.opt(exception=True).error("{} failed: {}", func.__name__, str(e))
|
|
120
121
|
return {
|
|
121
122
|
"status": "error",
|
|
122
123
|
"error": str(e),
|
|
@@ -154,6 +155,10 @@ async def search_rem(
|
|
|
154
155
|
- Fast exact match across all tables
|
|
155
156
|
- Uses indexed label_vector for instant retrieval
|
|
156
157
|
- Example: LOOKUP "Sarah Chen" returns all entities named "Sarah Chen"
|
|
158
|
+
- **Ontology Note**: Ontology content may contain markdown links like
|
|
159
|
+
`[sertraline](../../drugs/antidepressants/sertraline.md)`. The link name
|
|
160
|
+
(e.g., "sertraline") can be used as a LOOKUP subject, while the relative
|
|
161
|
+
path provides semantic context (e.g., it's a drug, specifically an antidepressant).
|
|
157
162
|
|
|
158
163
|
**FUZZY** - Fuzzy text matching with similarity threshold:
|
|
159
164
|
- Finds partial matches and typos
|
|
@@ -380,9 +385,10 @@ async def ask_rem_agent(
|
|
|
380
385
|
from ...utils.schema_loader import load_agent_schema
|
|
381
386
|
|
|
382
387
|
# Create agent context
|
|
388
|
+
# Note: tenant_id defaults to "default" if user_id is None
|
|
383
389
|
context = AgentContext(
|
|
384
390
|
user_id=user_id,
|
|
385
|
-
tenant_id=user_id, #
|
|
391
|
+
tenant_id=user_id or "default", # Use default tenant for anonymous users
|
|
386
392
|
default_model=settings.llm.default_model,
|
|
387
393
|
)
|
|
388
394
|
|
|
@@ -1040,3 +1046,172 @@ async def get_schema(
|
|
|
1040
1046
|
logger.info(f"Retrieved schema for table '{table_name}' with {len(column_defs)} columns")
|
|
1041
1047
|
|
|
1042
1048
|
return result
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
@mcp_tool_error_handler
|
|
1052
|
+
async def save_agent(
|
|
1053
|
+
name: str,
|
|
1054
|
+
description: str,
|
|
1055
|
+
properties: dict[str, Any] | None = None,
|
|
1056
|
+
required: list[str] | None = None,
|
|
1057
|
+
tools: list[str] | None = None,
|
|
1058
|
+
tags: list[str] | None = None,
|
|
1059
|
+
version: str = "1.0.0",
|
|
1060
|
+
user_id: str | None = None,
|
|
1061
|
+
) -> dict[str, Any]:
|
|
1062
|
+
"""
|
|
1063
|
+
Save an agent schema to REM, making it available for use.
|
|
1064
|
+
|
|
1065
|
+
This tool creates or updates an agent definition in the user's schema space.
|
|
1066
|
+
The agent becomes immediately available for conversations.
|
|
1067
|
+
|
|
1068
|
+
**Default Tools**: All agents automatically get `search_rem` and `register_metadata`
|
|
1069
|
+
tools unless explicitly overridden.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
name: Agent name in kebab-case (e.g., "code-reviewer", "sales-assistant").
|
|
1073
|
+
Must be unique within the user's schema space.
|
|
1074
|
+
description: The agent's system prompt. This is the full instruction set
|
|
1075
|
+
that defines the agent's behavior, personality, and capabilities.
|
|
1076
|
+
Use markdown formatting for structure.
|
|
1077
|
+
properties: Output schema properties as a dict. Each property should have:
|
|
1078
|
+
- type: "string", "number", "boolean", "array", "object"
|
|
1079
|
+
- description: What this field captures
|
|
1080
|
+
Example: {"answer": {"type": "string", "description": "Response to user"}}
|
|
1081
|
+
If not provided, defaults to a simple {"answer": {"type": "string"}} schema.
|
|
1082
|
+
required: List of required property names. Defaults to ["answer"] if not provided.
|
|
1083
|
+
tools: List of tool names the agent can use. Defaults to ["search_rem", "register_metadata"].
|
|
1084
|
+
tags: Optional tags for categorizing the agent.
|
|
1085
|
+
version: Semantic version string (default: "1.0.0").
|
|
1086
|
+
user_id: User identifier for scoping. Uses authenticated user if not provided.
|
|
1087
|
+
|
|
1088
|
+
Returns:
|
|
1089
|
+
Dict with:
|
|
1090
|
+
- status: "success" or "error"
|
|
1091
|
+
- agent_name: Name of the saved agent
|
|
1092
|
+
- version: Version saved
|
|
1093
|
+
- message: Human-readable status
|
|
1094
|
+
|
|
1095
|
+
Examples:
|
|
1096
|
+
# Create a simple agent
|
|
1097
|
+
save_agent(
|
|
1098
|
+
name="greeting-bot",
|
|
1099
|
+
description="You are a friendly greeter. Say hello warmly.",
|
|
1100
|
+
properties={"answer": {"type": "string", "description": "Greeting message"}},
|
|
1101
|
+
required=["answer"]
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
# Create agent with structured output
|
|
1105
|
+
save_agent(
|
|
1106
|
+
name="sentiment-analyzer",
|
|
1107
|
+
description="Analyze sentiment of text provided by the user.",
|
|
1108
|
+
properties={
|
|
1109
|
+
"answer": {"type": "string", "description": "Analysis explanation"},
|
|
1110
|
+
"sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
|
|
1111
|
+
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
|
1112
|
+
},
|
|
1113
|
+
required=["answer", "sentiment"],
|
|
1114
|
+
tags=["analysis", "nlp"]
|
|
1115
|
+
)
|
|
1116
|
+
"""
|
|
1117
|
+
from ...agentic.agents.agent_manager import save_agent as _save_agent
|
|
1118
|
+
|
|
1119
|
+
# Get user_id from context if not provided
|
|
1120
|
+
user_id = AgentContext.get_user_id_or_default(user_id, source="save_agent")
|
|
1121
|
+
|
|
1122
|
+
# Delegate to agent_manager
|
|
1123
|
+
result = await _save_agent(
|
|
1124
|
+
name=name,
|
|
1125
|
+
description=description,
|
|
1126
|
+
user_id=user_id,
|
|
1127
|
+
properties=properties,
|
|
1128
|
+
required=required,
|
|
1129
|
+
tools=tools,
|
|
1130
|
+
tags=tags,
|
|
1131
|
+
version=version,
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
# Add helpful message for Slack users
|
|
1135
|
+
if result.get("status") == "success":
|
|
1136
|
+
result["message"] = f"Agent '{name}' saved. Use `/custom-agent {name}` to chat with it."
|
|
1137
|
+
|
|
1138
|
+
return result
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# =============================================================================
|
|
1142
|
+
# Test/Debug Tools (for development only)
|
|
1143
|
+
# =============================================================================
|
|
1144
|
+
|
|
1145
|
+
@mcp_tool_error_handler
|
|
1146
|
+
async def test_error_handling(
|
|
1147
|
+
error_type: Literal["exception", "error_response", "timeout", "success"] = "success",
|
|
1148
|
+
delay_seconds: float = 0,
|
|
1149
|
+
error_message: str = "Test error occurred",
|
|
1150
|
+
) -> dict[str, Any]:
|
|
1151
|
+
"""
|
|
1152
|
+
Test tool for simulating different error scenarios.
|
|
1153
|
+
|
|
1154
|
+
**FOR DEVELOPMENT/TESTING ONLY** - This tool helps verify that error
|
|
1155
|
+
handling works correctly through the streaming layer.
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
error_type: Type of error to simulate:
|
|
1159
|
+
- "success": Returns successful response (default)
|
|
1160
|
+
- "exception": Raises an exception (tests @mcp_tool_error_handler)
|
|
1161
|
+
- "error_response": Returns {"status": "error", ...} dict
|
|
1162
|
+
- "timeout": Delays for 60 seconds (simulates timeout)
|
|
1163
|
+
delay_seconds: Optional delay before responding (0-10 seconds)
|
|
1164
|
+
error_message: Custom error message for error scenarios
|
|
1165
|
+
|
|
1166
|
+
Returns:
|
|
1167
|
+
Dict with test results or error information
|
|
1168
|
+
|
|
1169
|
+
Examples:
|
|
1170
|
+
# Test successful response
|
|
1171
|
+
test_error_handling(error_type="success")
|
|
1172
|
+
|
|
1173
|
+
# Test exception handling
|
|
1174
|
+
test_error_handling(error_type="exception", error_message="Database connection failed")
|
|
1175
|
+
|
|
1176
|
+
# Test error response format
|
|
1177
|
+
test_error_handling(error_type="error_response", error_message="Resource not found")
|
|
1178
|
+
|
|
1179
|
+
# Test with delay
|
|
1180
|
+
test_error_handling(error_type="success", delay_seconds=2)
|
|
1181
|
+
"""
|
|
1182
|
+
import asyncio
|
|
1183
|
+
|
|
1184
|
+
logger.info(f"test_error_handling called: type={error_type}, delay={delay_seconds}")
|
|
1185
|
+
|
|
1186
|
+
# Apply delay (capped at 10 seconds for safety)
|
|
1187
|
+
if delay_seconds > 0:
|
|
1188
|
+
await asyncio.sleep(min(delay_seconds, 10))
|
|
1189
|
+
|
|
1190
|
+
if error_type == "exception":
|
|
1191
|
+
# This tests the @mcp_tool_error_handler decorator
|
|
1192
|
+
raise RuntimeError(f"TEST EXCEPTION: {error_message}")
|
|
1193
|
+
|
|
1194
|
+
elif error_type == "error_response":
|
|
1195
|
+
# This tests how the streaming layer handles error status responses
|
|
1196
|
+
return {
|
|
1197
|
+
"status": "error",
|
|
1198
|
+
"error": error_message,
|
|
1199
|
+
"error_code": "TEST_ERROR",
|
|
1200
|
+
"recoverable": True,
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
elif error_type == "timeout":
|
|
1204
|
+
# Simulate a very long operation (for testing client-side timeouts)
|
|
1205
|
+
await asyncio.sleep(60)
|
|
1206
|
+
return {"status": "success", "message": "Timeout test completed (should not reach here)"}
|
|
1207
|
+
|
|
1208
|
+
else: # success
|
|
1209
|
+
return {
|
|
1210
|
+
"status": "success",
|
|
1211
|
+
"message": "Test completed successfully",
|
|
1212
|
+
"test_data": {
|
|
1213
|
+
"error_type": error_type,
|
|
1214
|
+
"delay_applied": delay_seconds,
|
|
1215
|
+
"timestamp": str(asyncio.get_event_loop().time()),
|
|
1216
|
+
},
|
|
1217
|
+
}
|
rem/api/middleware/tracking.py
CHANGED
|
@@ -102,14 +102,14 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
|
|
|
102
102
|
# Tenant ID from header or default
|
|
103
103
|
tenant_id = request.headers.get("X-Tenant-Id", "default")
|
|
104
104
|
|
|
105
|
-
# 4. Rate Limiting
|
|
106
|
-
if settings.postgres.enabled:
|
|
105
|
+
# 4. Rate Limiting (skip if disabled via settings)
|
|
106
|
+
if settings.postgres.enabled and settings.api.rate_limit_enabled:
|
|
107
107
|
is_allowed, current, limit = await self.rate_limiter.check_rate_limit(
|
|
108
108
|
tenant_id=tenant_id,
|
|
109
109
|
identifier=identifier,
|
|
110
110
|
tier=tier
|
|
111
111
|
)
|
|
112
|
-
|
|
112
|
+
|
|
113
113
|
if not is_allowed:
|
|
114
114
|
return JSONResponse(
|
|
115
115
|
status_code=429,
|
|
@@ -141,8 +141,8 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
|
|
|
141
141
|
secure=settings.environment == "production"
|
|
142
142
|
)
|
|
143
143
|
|
|
144
|
-
# Add Rate Limit headers
|
|
145
|
-
if settings.postgres.enabled and 'limit' in locals():
|
|
144
|
+
# Add Rate Limit headers (only if rate limiting is enabled)
|
|
145
|
+
if settings.postgres.enabled and settings.api.rate_limit_enabled and 'limit' in locals():
|
|
146
146
|
response.headers["X-RateLimit-Limit"] = str(limit)
|
|
147
147
|
response.headers["X-RateLimit-Remaining"] = str(max(0, limit - current))
|
|
148
148
|
|
rem/api/routers/auth.py
CHANGED
|
@@ -1,20 +1,71 @@
|
|
|
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 subscriber list first
|
|
34
|
+
│ ├── Email in subscribers table? → Allow (create user & send code)
|
|
35
|
+
│ └── Not a subscriber → Check EMAIL__TRUSTED_EMAIL_DOMAINS
|
|
36
|
+
│ ├── Setting configured → domain in trusted list?
|
|
37
|
+
│ │ ├── Yes → Create user & send code
|
|
38
|
+
│ │ └── No → Reject "Email domain not allowed for signup"
|
|
39
|
+
│ └── Not configured (empty) → Create user & send code (no restrictions)
|
|
40
|
+
|
|
41
|
+
Key Behaviors:
|
|
42
|
+
- Existing users: Always allowed to login (unless tier=BLOCKED)
|
|
43
|
+
- Subscribers: Always allowed to login (regardless of email domain)
|
|
44
|
+
- New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
|
|
45
|
+
- No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
|
|
46
|
+
|
|
47
|
+
User Tiers (models.entities.UserTier):
|
|
48
|
+
- BLOCKED: Cannot login (rejected at send-code)
|
|
49
|
+
- ANONYMOUS: Rate-limited anonymous access
|
|
50
|
+
- FREE: Standard free tier
|
|
51
|
+
- BASIC/PRO: Paid tiers with additional features
|
|
52
|
+
|
|
53
|
+
Configuration:
|
|
54
|
+
# Allow only specific domains for new signups
|
|
55
|
+
EMAIL__TRUSTED_EMAIL_DOMAINS=siggymd.ai,example.com
|
|
56
|
+
|
|
57
|
+
# Allow all domains (no restrictions)
|
|
58
|
+
EMAIL__TRUSTED_EMAIL_DOMAINS=
|
|
59
|
+
|
|
60
|
+
Example blocking a user:
|
|
61
|
+
user = await user_repo.get_by_id(user_id, tenant_id="default")
|
|
62
|
+
user.tier = UserTier.BLOCKED
|
|
63
|
+
await user_repo.upsert(user)
|
|
64
|
+
|
|
65
|
+
=============================================================================
|
|
66
|
+
OAuth Design Pattern (OAuth 2.1 + PKCE)
|
|
67
|
+
=============================================================================
|
|
68
|
+
|
|
18
69
|
1. User clicks "Login with Google"
|
|
19
70
|
2. /login generates state + PKCE code_verifier
|
|
20
71
|
3. Store code_verifier in session
|
|
@@ -37,6 +88,7 @@ Environment variables:
|
|
|
37
88
|
AUTH__MICROSOFT__CLIENT_ID=<microsoft-client-id>
|
|
38
89
|
AUTH__MICROSOFT__CLIENT_SECRET=<microsoft-client-secret>
|
|
39
90
|
AUTH__MICROSOFT__TENANT=common
|
|
91
|
+
EMAIL__TRUSTED_EMAIL_DOMAINS=example.com # Optional: restrict new signups
|
|
40
92
|
|
|
41
93
|
References:
|
|
42
94
|
- Authlib: https://docs.authlib.org/en/latest/
|
|
@@ -46,11 +98,15 @@ References:
|
|
|
46
98
|
from fastapi import APIRouter, HTTPException, Request
|
|
47
99
|
from fastapi.responses import RedirectResponse
|
|
48
100
|
from authlib.integrations.starlette_client import OAuth
|
|
101
|
+
from pydantic import BaseModel, EmailStr
|
|
49
102
|
from loguru import logger
|
|
50
103
|
|
|
51
104
|
from ...settings import settings
|
|
52
105
|
from ...services.postgres.service import PostgresService
|
|
53
106
|
from ...services.user_service import UserService
|
|
107
|
+
from ...auth.providers.email import EmailAuthProvider
|
|
108
|
+
from ...auth.jwt import JWTService, get_jwt_service
|
|
109
|
+
from ...utils.user_id import email_to_user_id
|
|
54
110
|
|
|
55
111
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
56
112
|
|
|
@@ -87,6 +143,182 @@ if settings.auth.microsoft.client_id:
|
|
|
87
143
|
logger.info(f"Microsoft OAuth provider registered (tenant: {tenant})")
|
|
88
144
|
|
|
89
145
|
|
|
146
|
+
# =============================================================================
|
|
147
|
+
# Email Authentication Endpoints
|
|
148
|
+
# =============================================================================
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class EmailSendCodeRequest(BaseModel):
|
|
152
|
+
"""Request to send login code."""
|
|
153
|
+
email: EmailStr
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class EmailVerifyRequest(BaseModel):
|
|
157
|
+
"""Request to verify login code."""
|
|
158
|
+
email: EmailStr
|
|
159
|
+
code: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("/email/send-code")
|
|
163
|
+
async def send_email_code(request: Request, body: EmailSendCodeRequest):
|
|
164
|
+
"""
|
|
165
|
+
Send a login code to an email address.
|
|
166
|
+
|
|
167
|
+
Creates user if not exists (using deterministic UUID from email).
|
|
168
|
+
Stores code in user metadata with expiry.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
request: FastAPI request
|
|
172
|
+
body: EmailSendCodeRequest with email
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Success status and message
|
|
176
|
+
"""
|
|
177
|
+
if not settings.email.is_configured:
|
|
178
|
+
raise HTTPException(
|
|
179
|
+
status_code=501,
|
|
180
|
+
detail="Email authentication is not configured"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Get database connection
|
|
184
|
+
if not settings.postgres.enabled:
|
|
185
|
+
raise HTTPException(
|
|
186
|
+
status_code=501,
|
|
187
|
+
detail="Database is required for email authentication"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
db = PostgresService()
|
|
191
|
+
try:
|
|
192
|
+
await db.connect()
|
|
193
|
+
|
|
194
|
+
# Initialize email auth provider
|
|
195
|
+
email_auth = EmailAuthProvider()
|
|
196
|
+
|
|
197
|
+
# Send code
|
|
198
|
+
result = await email_auth.send_code(
|
|
199
|
+
email=body.email,
|
|
200
|
+
db=db,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if result.success:
|
|
204
|
+
return {
|
|
205
|
+
"success": True,
|
|
206
|
+
"message": result.message,
|
|
207
|
+
"email": result.email,
|
|
208
|
+
}
|
|
209
|
+
else:
|
|
210
|
+
raise HTTPException(
|
|
211
|
+
status_code=400,
|
|
212
|
+
detail=result.message or result.error
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
except HTTPException:
|
|
216
|
+
raise
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.error(f"Error sending login code: {e}")
|
|
219
|
+
raise HTTPException(status_code=500, detail="Failed to send login code")
|
|
220
|
+
finally:
|
|
221
|
+
await db.disconnect()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@router.post("/email/verify")
|
|
225
|
+
async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
226
|
+
"""
|
|
227
|
+
Verify login code and create session with JWT tokens.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
request: FastAPI request
|
|
231
|
+
body: EmailVerifyRequest with email and code
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Success status with user info and JWT tokens
|
|
235
|
+
"""
|
|
236
|
+
if not settings.email.is_configured:
|
|
237
|
+
raise HTTPException(
|
|
238
|
+
status_code=501,
|
|
239
|
+
detail="Email authentication is not configured"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if not settings.postgres.enabled:
|
|
243
|
+
raise HTTPException(
|
|
244
|
+
status_code=501,
|
|
245
|
+
detail="Database is required for email authentication"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
db = PostgresService()
|
|
249
|
+
try:
|
|
250
|
+
await db.connect()
|
|
251
|
+
|
|
252
|
+
# Initialize email auth provider
|
|
253
|
+
email_auth = EmailAuthProvider()
|
|
254
|
+
|
|
255
|
+
# Verify code
|
|
256
|
+
result = await email_auth.verify_code(
|
|
257
|
+
email=body.email,
|
|
258
|
+
code=body.code,
|
|
259
|
+
db=db,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if not result.success:
|
|
263
|
+
raise HTTPException(
|
|
264
|
+
status_code=400,
|
|
265
|
+
detail=result.message or result.error
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Create session - compatible with OAuth session format
|
|
269
|
+
user_dict = email_auth.get_user_dict(
|
|
270
|
+
email=result.email,
|
|
271
|
+
user_id=result.user_id,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Fetch actual user data from database to get role/tier
|
|
275
|
+
user_service = UserService(db)
|
|
276
|
+
try:
|
|
277
|
+
user_entity = await user_service.get_user_by_id(result.user_id)
|
|
278
|
+
if user_entity:
|
|
279
|
+
# Override defaults with actual database values
|
|
280
|
+
user_dict["role"] = user_entity.role or "user"
|
|
281
|
+
user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
|
|
282
|
+
user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
|
|
283
|
+
user_dict["name"] = user_entity.name or user_dict["name"]
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.warning(f"Could not fetch user details: {e}")
|
|
286
|
+
# Continue with defaults from get_user_dict
|
|
287
|
+
|
|
288
|
+
# Generate JWT tokens
|
|
289
|
+
jwt_service = get_jwt_service()
|
|
290
|
+
tokens = jwt_service.create_tokens(user_dict)
|
|
291
|
+
|
|
292
|
+
# Store user in session (for backward compatibility)
|
|
293
|
+
request.session["user"] = user_dict
|
|
294
|
+
|
|
295
|
+
logger.info(f"User authenticated via email: {result.email}")
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
"success": True,
|
|
299
|
+
"message": result.message,
|
|
300
|
+
"user": user_dict,
|
|
301
|
+
# JWT tokens for stateless auth
|
|
302
|
+
"access_token": tokens["access_token"],
|
|
303
|
+
"refresh_token": tokens["refresh_token"],
|
|
304
|
+
"token_type": tokens["token_type"],
|
|
305
|
+
"expires_in": tokens["expires_in"],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
except HTTPException:
|
|
309
|
+
raise
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.error(f"Error verifying login code: {e}")
|
|
312
|
+
raise HTTPException(status_code=500, detail="Failed to verify login code")
|
|
313
|
+
finally:
|
|
314
|
+
await db.disconnect()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# =============================================================================
|
|
318
|
+
# OAuth Authentication Endpoints
|
|
319
|
+
# =============================================================================
|
|
320
|
+
|
|
321
|
+
|
|
90
322
|
@router.get("/{provider}/login")
|
|
91
323
|
async def login(provider: str, request: Request):
|
|
92
324
|
"""
|
|
@@ -201,8 +433,9 @@ async def callback(provider: str, request: Request):
|
|
|
201
433
|
await user_service.link_anonymous_session(user_entity, anon_id)
|
|
202
434
|
|
|
203
435
|
# Enrich session user with DB info
|
|
436
|
+
# user_id = UUID5 hash of email (deterministic, bijection)
|
|
204
437
|
db_info = {
|
|
205
|
-
"id":
|
|
438
|
+
"id": email_to_user_id(user_info.get("email")),
|
|
206
439
|
"tenant_id": user_entity.tenant_id,
|
|
207
440
|
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
208
441
|
"roles": [user_entity.role] if user_entity.role else [],
|
|
@@ -268,7 +501,7 @@ async def logout(request: Request):
|
|
|
268
501
|
@router.get("/me")
|
|
269
502
|
async def me(request: Request):
|
|
270
503
|
"""
|
|
271
|
-
Get current user information from session.
|
|
504
|
+
Get current user information from session or JWT.
|
|
272
505
|
|
|
273
506
|
Args:
|
|
274
507
|
request: FastAPI request
|
|
@@ -276,6 +509,16 @@ async def me(request: Request):
|
|
|
276
509
|
Returns:
|
|
277
510
|
User information or 401 if not authenticated
|
|
278
511
|
"""
|
|
512
|
+
# First check for JWT in Authorization header
|
|
513
|
+
auth_header = request.headers.get("Authorization")
|
|
514
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
515
|
+
token = auth_header[7:]
|
|
516
|
+
jwt_service = get_jwt_service()
|
|
517
|
+
user = jwt_service.verify_token(token)
|
|
518
|
+
if user:
|
|
519
|
+
return user
|
|
520
|
+
|
|
521
|
+
# Fall back to session
|
|
279
522
|
user = request.session.get("user")
|
|
280
523
|
if not user:
|
|
281
524
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
@@ -283,6 +526,69 @@ async def me(request: Request):
|
|
|
283
526
|
return user
|
|
284
527
|
|
|
285
528
|
|
|
529
|
+
# =============================================================================
|
|
530
|
+
# JWT Token Endpoints
|
|
531
|
+
# =============================================================================
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class TokenRefreshRequest(BaseModel):
|
|
535
|
+
"""Request to refresh access token."""
|
|
536
|
+
refresh_token: str
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@router.post("/token/refresh")
|
|
540
|
+
async def refresh_token(body: TokenRefreshRequest):
|
|
541
|
+
"""
|
|
542
|
+
Refresh access token using refresh token.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
body: TokenRefreshRequest with refresh_token
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
New access token or 401 if refresh token is invalid
|
|
549
|
+
"""
|
|
550
|
+
jwt_service = get_jwt_service()
|
|
551
|
+
result = jwt_service.refresh_access_token(body.refresh_token)
|
|
552
|
+
|
|
553
|
+
if not result:
|
|
554
|
+
raise HTTPException(
|
|
555
|
+
status_code=401,
|
|
556
|
+
detail="Invalid or expired refresh token"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@router.post("/token/verify")
|
|
563
|
+
async def verify_token(request: Request):
|
|
564
|
+
"""
|
|
565
|
+
Verify an access token is valid.
|
|
566
|
+
|
|
567
|
+
Pass the token in the Authorization header: Bearer <token>
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
User info if valid, 401 if invalid
|
|
571
|
+
"""
|
|
572
|
+
auth_header = request.headers.get("Authorization")
|
|
573
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
574
|
+
raise HTTPException(
|
|
575
|
+
status_code=401,
|
|
576
|
+
detail="Missing Authorization header"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
token = auth_header[7:]
|
|
580
|
+
jwt_service = get_jwt_service()
|
|
581
|
+
user = jwt_service.verify_token(token)
|
|
582
|
+
|
|
583
|
+
if not user:
|
|
584
|
+
raise HTTPException(
|
|
585
|
+
status_code=401,
|
|
586
|
+
detail="Invalid or expired token"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return {"valid": True, "user": user}
|
|
590
|
+
|
|
591
|
+
|
|
286
592
|
# =============================================================================
|
|
287
593
|
# Development Token Endpoints (non-production only)
|
|
288
594
|
# =============================================================================
|
|
@@ -351,3 +657,43 @@ async def get_dev_token(request: Request):
|
|
|
351
657
|
"usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
|
|
352
658
|
"warning": "This token is for development/testing only and will not work in production.",
|
|
353
659
|
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@router.get("/dev/mock-code/{email}")
|
|
663
|
+
async def get_mock_code(email: str, request: Request):
|
|
664
|
+
"""
|
|
665
|
+
Get the mock login code for testing (non-production only).
|
|
666
|
+
|
|
667
|
+
This endpoint retrieves the code that was "sent" via email in mock mode.
|
|
668
|
+
Use this for automated testing without real email delivery.
|
|
669
|
+
|
|
670
|
+
Usage:
|
|
671
|
+
1. POST /api/auth/email/send-code with email
|
|
672
|
+
2. GET /api/auth/dev/mock-code/{email} to retrieve the code
|
|
673
|
+
3. POST /api/auth/email/verify with email and code
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
401 if in production environment
|
|
677
|
+
404 if no code found for the email
|
|
678
|
+
The code and email otherwise
|
|
679
|
+
"""
|
|
680
|
+
if settings.environment == "production":
|
|
681
|
+
raise HTTPException(
|
|
682
|
+
status_code=401,
|
|
683
|
+
detail="Mock codes are not available in production"
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
from ...services.email import EmailService
|
|
687
|
+
|
|
688
|
+
code = EmailService.get_mock_code(email)
|
|
689
|
+
if not code:
|
|
690
|
+
raise HTTPException(
|
|
691
|
+
status_code=404,
|
|
692
|
+
detail=f"No mock code found for {email}. Send a code first."
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
"email": email,
|
|
697
|
+
"code": code,
|
|
698
|
+
"warning": "This endpoint is for testing only and will not work in production.",
|
|
699
|
+
}
|