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.

Files changed (57) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +36 -9
  5. rem/agentic/mcp/tool_wrapper.py +43 -14
  6. rem/agentic/providers/pydantic_ai.py +76 -34
  7. rem/agentic/schema.py +4 -3
  8. rem/agentic/tools/rem_tools.py +11 -0
  9. rem/api/deps.py +3 -5
  10. rem/api/main.py +22 -3
  11. rem/api/mcp_router/resources.py +75 -14
  12. rem/api/mcp_router/server.py +28 -23
  13. rem/api/mcp_router/tools.py +177 -2
  14. rem/api/middleware/tracking.py +5 -5
  15. rem/api/routers/auth.py +352 -6
  16. rem/api/routers/chat/completions.py +5 -3
  17. rem/api/routers/chat/streaming.py +95 -22
  18. rem/api/routers/messages.py +24 -15
  19. rem/auth/__init__.py +13 -3
  20. rem/auth/jwt.py +352 -0
  21. rem/auth/middleware.py +70 -30
  22. rem/auth/providers/__init__.py +4 -1
  23. rem/auth/providers/email.py +215 -0
  24. rem/cli/commands/ask.py +1 -1
  25. rem/cli/commands/db.py +118 -54
  26. rem/models/entities/__init__.py +4 -0
  27. rem/models/entities/ontology.py +93 -101
  28. rem/models/entities/subscriber.py +175 -0
  29. rem/models/entities/user.py +1 -0
  30. rem/schemas/agents/core/agent-builder.yaml +235 -0
  31. rem/services/__init__.py +3 -1
  32. rem/services/content/service.py +4 -3
  33. rem/services/email/__init__.py +10 -0
  34. rem/services/email/service.py +522 -0
  35. rem/services/email/templates.py +360 -0
  36. rem/services/embeddings/worker.py +26 -12
  37. rem/services/postgres/README.md +38 -0
  38. rem/services/postgres/diff_service.py +19 -3
  39. rem/services/postgres/pydantic_to_sqlalchemy.py +37 -2
  40. rem/services/postgres/register_type.py +1 -1
  41. rem/services/postgres/repository.py +37 -25
  42. rem/services/postgres/schema_generator.py +5 -5
  43. rem/services/postgres/sql_builder.py +6 -5
  44. rem/services/session/compression.py +113 -50
  45. rem/services/session/reload.py +14 -7
  46. rem/services/user_service.py +41 -9
  47. rem/settings.py +182 -1
  48. rem/sql/background_indexes.sql +5 -0
  49. rem/sql/migrations/001_install.sql +33 -4
  50. rem/sql/migrations/002_install_models.sql +204 -186
  51. rem/sql/migrations/005_schema_update.sql +145 -0
  52. rem/utils/model_helpers.py +101 -0
  53. rem/utils/schema_loader.py +45 -7
  54. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/METADATA +1 -1
  55. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/RECORD +57 -48
  56. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/WHEEL +0 -0
  57. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/entry_points.txt +0 -0
@@ -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
- logger.error(f"{func.__name__} failed: {e}", exc_info=True)
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, # Set tenant_id to user_id for backward compat
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
+ }
@@ -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
- 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 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": str(user_entity.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
+ }