remdb 0.3.242__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 (235) hide show
  1. rem/__init__.py +129 -0
  2. rem/agentic/README.md +760 -0
  3. rem/agentic/__init__.py +54 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +38 -0
  6. rem/agentic/agents/agent_manager.py +311 -0
  7. rem/agentic/agents/sse_simulator.py +502 -0
  8. rem/agentic/context.py +425 -0
  9. rem/agentic/context_builder.py +360 -0
  10. rem/agentic/llm_provider_models.py +301 -0
  11. rem/agentic/mcp/__init__.py +0 -0
  12. rem/agentic/mcp/tool_wrapper.py +273 -0
  13. rem/agentic/otel/__init__.py +5 -0
  14. rem/agentic/otel/setup.py +240 -0
  15. rem/agentic/providers/phoenix.py +926 -0
  16. rem/agentic/providers/pydantic_ai.py +854 -0
  17. rem/agentic/query.py +117 -0
  18. rem/agentic/query_helper.py +89 -0
  19. rem/agentic/schema.py +737 -0
  20. rem/agentic/serialization.py +245 -0
  21. rem/agentic/tools/__init__.py +5 -0
  22. rem/agentic/tools/rem_tools.py +242 -0
  23. rem/api/README.md +657 -0
  24. rem/api/deps.py +253 -0
  25. rem/api/main.py +460 -0
  26. rem/api/mcp_router/prompts.py +182 -0
  27. rem/api/mcp_router/resources.py +820 -0
  28. rem/api/mcp_router/server.py +243 -0
  29. rem/api/mcp_router/tools.py +1605 -0
  30. rem/api/middleware/tracking.py +172 -0
  31. rem/api/routers/admin.py +520 -0
  32. rem/api/routers/auth.py +898 -0
  33. rem/api/routers/chat/__init__.py +5 -0
  34. rem/api/routers/chat/child_streaming.py +394 -0
  35. rem/api/routers/chat/completions.py +702 -0
  36. rem/api/routers/chat/json_utils.py +76 -0
  37. rem/api/routers/chat/models.py +202 -0
  38. rem/api/routers/chat/otel_utils.py +33 -0
  39. rem/api/routers/chat/sse_events.py +546 -0
  40. rem/api/routers/chat/streaming.py +950 -0
  41. rem/api/routers/chat/streaming_utils.py +327 -0
  42. rem/api/routers/common.py +18 -0
  43. rem/api/routers/dev.py +87 -0
  44. rem/api/routers/feedback.py +276 -0
  45. rem/api/routers/messages.py +620 -0
  46. rem/api/routers/models.py +86 -0
  47. rem/api/routers/query.py +362 -0
  48. rem/api/routers/shared_sessions.py +422 -0
  49. rem/auth/README.md +258 -0
  50. rem/auth/__init__.py +36 -0
  51. rem/auth/jwt.py +367 -0
  52. rem/auth/middleware.py +318 -0
  53. rem/auth/providers/__init__.py +16 -0
  54. rem/auth/providers/base.py +376 -0
  55. rem/auth/providers/email.py +215 -0
  56. rem/auth/providers/google.py +163 -0
  57. rem/auth/providers/microsoft.py +237 -0
  58. rem/cli/README.md +517 -0
  59. rem/cli/__init__.py +8 -0
  60. rem/cli/commands/README.md +299 -0
  61. rem/cli/commands/__init__.py +3 -0
  62. rem/cli/commands/ask.py +549 -0
  63. rem/cli/commands/cluster.py +1808 -0
  64. rem/cli/commands/configure.py +495 -0
  65. rem/cli/commands/db.py +828 -0
  66. rem/cli/commands/dreaming.py +324 -0
  67. rem/cli/commands/experiments.py +1698 -0
  68. rem/cli/commands/mcp.py +66 -0
  69. rem/cli/commands/process.py +388 -0
  70. rem/cli/commands/query.py +109 -0
  71. rem/cli/commands/scaffold.py +47 -0
  72. rem/cli/commands/schema.py +230 -0
  73. rem/cli/commands/serve.py +106 -0
  74. rem/cli/commands/session.py +453 -0
  75. rem/cli/dreaming.py +363 -0
  76. rem/cli/main.py +123 -0
  77. rem/config.py +244 -0
  78. rem/mcp_server.py +41 -0
  79. rem/models/core/__init__.py +49 -0
  80. rem/models/core/core_model.py +70 -0
  81. rem/models/core/engram.py +333 -0
  82. rem/models/core/experiment.py +672 -0
  83. rem/models/core/inline_edge.py +132 -0
  84. rem/models/core/rem_query.py +246 -0
  85. rem/models/entities/__init__.py +68 -0
  86. rem/models/entities/domain_resource.py +38 -0
  87. rem/models/entities/feedback.py +123 -0
  88. rem/models/entities/file.py +57 -0
  89. rem/models/entities/image_resource.py +88 -0
  90. rem/models/entities/message.py +64 -0
  91. rem/models/entities/moment.py +123 -0
  92. rem/models/entities/ontology.py +181 -0
  93. rem/models/entities/ontology_config.py +131 -0
  94. rem/models/entities/resource.py +95 -0
  95. rem/models/entities/schema.py +87 -0
  96. rem/models/entities/session.py +84 -0
  97. rem/models/entities/shared_session.py +180 -0
  98. rem/models/entities/subscriber.py +175 -0
  99. rem/models/entities/user.py +93 -0
  100. rem/py.typed +0 -0
  101. rem/registry.py +373 -0
  102. rem/schemas/README.md +507 -0
  103. rem/schemas/__init__.py +6 -0
  104. rem/schemas/agents/README.md +92 -0
  105. rem/schemas/agents/core/agent-builder.yaml +235 -0
  106. rem/schemas/agents/core/moment-builder.yaml +178 -0
  107. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  108. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  109. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  110. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  111. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  112. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  113. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  114. rem/schemas/agents/examples/hello-world.yaml +37 -0
  115. rem/schemas/agents/examples/query.yaml +54 -0
  116. rem/schemas/agents/examples/simple.yaml +21 -0
  117. rem/schemas/agents/examples/test.yaml +29 -0
  118. rem/schemas/agents/rem.yaml +132 -0
  119. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  120. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  121. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  122. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  123. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  124. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  125. rem/services/__init__.py +18 -0
  126. rem/services/audio/INTEGRATION.md +308 -0
  127. rem/services/audio/README.md +376 -0
  128. rem/services/audio/__init__.py +15 -0
  129. rem/services/audio/chunker.py +354 -0
  130. rem/services/audio/transcriber.py +259 -0
  131. rem/services/content/README.md +1269 -0
  132. rem/services/content/__init__.py +5 -0
  133. rem/services/content/providers.py +760 -0
  134. rem/services/content/service.py +762 -0
  135. rem/services/dreaming/README.md +230 -0
  136. rem/services/dreaming/__init__.py +53 -0
  137. rem/services/dreaming/affinity_service.py +322 -0
  138. rem/services/dreaming/moment_service.py +251 -0
  139. rem/services/dreaming/ontology_service.py +54 -0
  140. rem/services/dreaming/user_model_service.py +297 -0
  141. rem/services/dreaming/utils.py +39 -0
  142. rem/services/email/__init__.py +10 -0
  143. rem/services/email/service.py +522 -0
  144. rem/services/email/templates.py +360 -0
  145. rem/services/embeddings/__init__.py +11 -0
  146. rem/services/embeddings/api.py +127 -0
  147. rem/services/embeddings/worker.py +435 -0
  148. rem/services/fs/README.md +662 -0
  149. rem/services/fs/__init__.py +62 -0
  150. rem/services/fs/examples.py +206 -0
  151. rem/services/fs/examples_paths.py +204 -0
  152. rem/services/fs/git_provider.py +935 -0
  153. rem/services/fs/local_provider.py +760 -0
  154. rem/services/fs/parsing-hooks-examples.md +172 -0
  155. rem/services/fs/paths.py +276 -0
  156. rem/services/fs/provider.py +460 -0
  157. rem/services/fs/s3_provider.py +1042 -0
  158. rem/services/fs/service.py +186 -0
  159. rem/services/git/README.md +1075 -0
  160. rem/services/git/__init__.py +17 -0
  161. rem/services/git/service.py +469 -0
  162. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  163. rem/services/phoenix/README.md +453 -0
  164. rem/services/phoenix/__init__.py +46 -0
  165. rem/services/phoenix/client.py +960 -0
  166. rem/services/phoenix/config.py +88 -0
  167. rem/services/phoenix/prompt_labels.py +477 -0
  168. rem/services/postgres/README.md +757 -0
  169. rem/services/postgres/__init__.py +49 -0
  170. rem/services/postgres/diff_service.py +599 -0
  171. rem/services/postgres/migration_service.py +427 -0
  172. rem/services/postgres/programmable_diff_service.py +635 -0
  173. rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
  174. rem/services/postgres/register_type.py +353 -0
  175. rem/services/postgres/repository.py +481 -0
  176. rem/services/postgres/schema_generator.py +661 -0
  177. rem/services/postgres/service.py +802 -0
  178. rem/services/postgres/sql_builder.py +355 -0
  179. rem/services/rate_limit.py +113 -0
  180. rem/services/rem/README.md +318 -0
  181. rem/services/rem/__init__.py +23 -0
  182. rem/services/rem/exceptions.py +71 -0
  183. rem/services/rem/executor.py +293 -0
  184. rem/services/rem/parser.py +180 -0
  185. rem/services/rem/queries.py +196 -0
  186. rem/services/rem/query.py +371 -0
  187. rem/services/rem/service.py +608 -0
  188. rem/services/session/README.md +374 -0
  189. rem/services/session/__init__.py +13 -0
  190. rem/services/session/compression.py +488 -0
  191. rem/services/session/pydantic_messages.py +310 -0
  192. rem/services/session/reload.py +85 -0
  193. rem/services/user_service.py +130 -0
  194. rem/settings.py +1877 -0
  195. rem/sql/background_indexes.sql +52 -0
  196. rem/sql/migrations/001_install.sql +983 -0
  197. rem/sql/migrations/002_install_models.sql +3157 -0
  198. rem/sql/migrations/003_optional_extensions.sql +326 -0
  199. rem/sql/migrations/004_cache_system.sql +282 -0
  200. rem/sql/migrations/005_schema_update.sql +145 -0
  201. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  202. rem/utils/AGENTIC_CHUNKING.md +597 -0
  203. rem/utils/README.md +628 -0
  204. rem/utils/__init__.py +61 -0
  205. rem/utils/agentic_chunking.py +622 -0
  206. rem/utils/batch_ops.py +343 -0
  207. rem/utils/chunking.py +108 -0
  208. rem/utils/clip_embeddings.py +276 -0
  209. rem/utils/constants.py +97 -0
  210. rem/utils/date_utils.py +228 -0
  211. rem/utils/dict_utils.py +98 -0
  212. rem/utils/embeddings.py +436 -0
  213. rem/utils/examples/embeddings_example.py +305 -0
  214. rem/utils/examples/sql_types_example.py +202 -0
  215. rem/utils/files.py +323 -0
  216. rem/utils/markdown.py +16 -0
  217. rem/utils/mime_types.py +158 -0
  218. rem/utils/model_helpers.py +492 -0
  219. rem/utils/schema_loader.py +649 -0
  220. rem/utils/sql_paths.py +146 -0
  221. rem/utils/sql_types.py +350 -0
  222. rem/utils/user_id.py +81 -0
  223. rem/utils/vision.py +325 -0
  224. rem/workers/README.md +506 -0
  225. rem/workers/__init__.py +7 -0
  226. rem/workers/db_listener.py +579 -0
  227. rem/workers/db_maintainer.py +74 -0
  228. rem/workers/dreaming.py +502 -0
  229. rem/workers/engram_processor.py +312 -0
  230. rem/workers/sqs_file_processor.py +193 -0
  231. rem/workers/unlogged_maintainer.py +463 -0
  232. remdb-0.3.242.dist-info/METADATA +1632 -0
  233. remdb-0.3.242.dist-info/RECORD +235 -0
  234. remdb-0.3.242.dist-info/WHEEL +4 -0
  235. remdb-0.3.242.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,898 @@
1
+ """
2
+ Authentication Router.
3
+
4
+ Supports multiple authentication methods:
5
+ 1. Email (passwordless): POST /api/auth/email/send-code, POST /api/auth/email/verify
6
+ 2. Pre-approved codes: POST /api/auth/email/verify (with pre-approved code, no send-code needed)
7
+ 3. OAuth (Google, Microsoft): GET /api/auth/{provider}/login, GET /api/auth/{provider}/callback
8
+
9
+ Endpoints:
10
+ - POST /api/auth/email/send-code - Send login code to email
11
+ - POST /api/auth/email/verify - Verify code and create session (supports pre-approved codes)
12
+ - GET /api/auth/{provider}/login - Initiate OAuth flow
13
+ - GET /api/auth/{provider}/callback - OAuth callback
14
+ - POST /api/auth/logout - Clear session
15
+ - GET /api/auth/me - Current user info
16
+
17
+ Supported providers:
18
+ - email: Passwordless email login
19
+ - preapproved: Pre-approved codes (bypass email, set via AUTH__PREAPPROVED_CODES)
20
+ - google: Google OAuth 2.0 / OIDC
21
+ - microsoft: Microsoft Entra ID OIDC
22
+
23
+ =============================================================================
24
+ Pre-Approved Code Authentication
25
+ =============================================================================
26
+
27
+ Pre-approved codes allow login without email verification. Useful for:
28
+ - Demo accounts
29
+ - Testing
30
+ - Beta access codes
31
+ - Admin provisioning
32
+
33
+ Configuration:
34
+ AUTH__PREAPPROVED_CODES=A12345,A67890,B11111,B22222
35
+
36
+ Code prefixes:
37
+ A = Admin role (e.g., A12345, AADMIN1)
38
+ B = Normal user role (e.g., B11111, BUSER1)
39
+
40
+ Flow:
41
+ 1. User enters email + pre-approved code (no send-code step needed)
42
+ 2. POST /api/auth/email/verify with email and code
43
+ 3. System validates code against AUTH__PREAPPROVED_CODES
44
+ 4. Creates user if not exists, sets role based on prefix
45
+ 5. Returns JWT tokens (same as email auth)
46
+
47
+ Example:
48
+ curl -X POST http://localhost:8000/api/auth/email/verify \
49
+ -H "Content-Type: application/json" \
50
+ -d '{"email": "admin@example.com", "code": "A12345"}'
51
+
52
+ =============================================================================
53
+ Email Authentication Access Control
54
+ =============================================================================
55
+
56
+ The email auth provider implements a tiered access control system:
57
+
58
+ Access Control Flow (send-code):
59
+ User requests login code
60
+ ├── User exists in database?
61
+ │ ├── Yes → Check user.tier
62
+ │ │ ├── tier == BLOCKED → Reject "Account is blocked"
63
+ │ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
64
+ │ └── No (new user) → Check subscriber list first
65
+ │ ├── Email in subscribers table? → Allow (create user & send code)
66
+ │ └── Not a subscriber → Check EMAIL__TRUSTED_EMAIL_DOMAINS
67
+ │ ├── Setting configured → domain in trusted list?
68
+ │ │ ├── Yes → Create user & send code
69
+ │ │ └── No → Reject "Email domain not allowed for signup"
70
+ │ └── Not configured (empty) → Create user & send code (no restrictions)
71
+
72
+ Key Behaviors:
73
+ - Existing users: Always allowed to login (unless tier=BLOCKED)
74
+ - Subscribers: Always allowed to login (regardless of email domain)
75
+ - New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
76
+ - No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
77
+
78
+ User Tiers (models.entities.UserTier):
79
+ - BLOCKED: Cannot login (rejected at send-code)
80
+ - ANONYMOUS: Rate-limited anonymous access
81
+ - FREE: Standard free tier
82
+ - BASIC/PRO: Paid tiers with additional features
83
+
84
+ Configuration:
85
+ # Allow only specific domains for new signups
86
+ EMAIL__TRUSTED_EMAIL_DOMAINS=siggymd.ai,example.com
87
+
88
+ # Allow all domains (no restrictions)
89
+ EMAIL__TRUSTED_EMAIL_DOMAINS=
90
+
91
+ Example blocking a user:
92
+ user = await user_repo.get_by_id(user_id, tenant_id="default")
93
+ user.tier = UserTier.BLOCKED
94
+ await user_repo.upsert(user)
95
+
96
+ =============================================================================
97
+ OAuth Design Pattern (OAuth 2.1 + PKCE)
98
+ =============================================================================
99
+
100
+ 1. User clicks "Login with Google"
101
+ 2. /login generates state + PKCE code_verifier
102
+ 3. Store code_verifier in session
103
+ 4. Redirect to provider with code_challenge
104
+ 5. User authenticates and grants consent
105
+ 6. Provider redirects to /callback with code
106
+ 7. Exchange code + code_verifier for tokens
107
+ 8. Validate ID token signature with JWKS
108
+ 9. Store user info in session
109
+ 10. Redirect to application
110
+
111
+ Dependencies:
112
+ pip install authlib httpx
113
+
114
+ Environment variables:
115
+ AUTH__ENABLED=true
116
+ AUTH__SESSION_SECRET=<random-secret>
117
+ AUTH__GOOGLE__CLIENT_ID=<google-client-id>
118
+ AUTH__GOOGLE__CLIENT_SECRET=<google-client-secret>
119
+ AUTH__MICROSOFT__CLIENT_ID=<microsoft-client-id>
120
+ AUTH__MICROSOFT__CLIENT_SECRET=<microsoft-client-secret>
121
+ AUTH__MICROSOFT__TENANT=common
122
+ EMAIL__TRUSTED_EMAIL_DOMAINS=example.com # Optional: restrict new signups
123
+
124
+ References:
125
+ - Authlib: https://docs.authlib.org/en/latest/
126
+ - OAuth 2.1: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11
127
+ """
128
+
129
+ from fastapi import APIRouter, HTTPException, Request
130
+ from fastapi.responses import RedirectResponse
131
+ from authlib.integrations.starlette_client import OAuth
132
+ from pydantic import BaseModel, EmailStr
133
+ from loguru import logger
134
+
135
+ from .common import ErrorResponse
136
+
137
+ from ...settings import settings
138
+ from ...services.postgres.service import PostgresService
139
+ from ...services.user_service import UserService
140
+ from ...auth.providers.email import EmailAuthProvider
141
+ from ...auth.jwt import JWTService, get_jwt_service
142
+ from ...utils.user_id import email_to_user_id
143
+
144
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
145
+
146
+ # Initialize Authlib OAuth client
147
+ # Authlib handles PKCE, state, nonce, token validation automatically
148
+ oauth = OAuth()
149
+
150
+ # Register Google provider
151
+ if settings.auth.google.client_id:
152
+ oauth.register(
153
+ name="google",
154
+ client_id=settings.auth.google.client_id,
155
+ client_secret=settings.auth.google.client_secret,
156
+ server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
157
+ client_kwargs={
158
+ "scope": "openid email profile",
159
+ # Authlib automatically adds PKCE to authorization request
160
+ },
161
+ )
162
+ logger.info("Google OAuth provider registered")
163
+
164
+ # Register Microsoft provider
165
+ if settings.auth.microsoft.client_id:
166
+ tenant = settings.auth.microsoft.tenant
167
+ oauth.register(
168
+ name="microsoft",
169
+ client_id=settings.auth.microsoft.client_id,
170
+ client_secret=settings.auth.microsoft.client_secret,
171
+ server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
172
+ client_kwargs={
173
+ "scope": "openid email profile User.Read",
174
+ },
175
+ )
176
+ logger.info(f"Microsoft OAuth provider registered (tenant: {tenant})")
177
+
178
+
179
+ # =============================================================================
180
+ # Email Authentication Endpoints
181
+ # =============================================================================
182
+
183
+
184
+ class EmailSendCodeRequest(BaseModel):
185
+ """Request to send login code."""
186
+ email: EmailStr
187
+
188
+
189
+ class EmailVerifyRequest(BaseModel):
190
+ """Request to verify login code."""
191
+ email: EmailStr
192
+ code: str
193
+
194
+
195
+ @router.post(
196
+ "/email/send-code",
197
+ responses={
198
+ 400: {"model": ErrorResponse, "description": "Invalid request or email rejected"},
199
+ 500: {"model": ErrorResponse, "description": "Failed to send login code"},
200
+ 501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
201
+ },
202
+ )
203
+ async def send_email_code(request: Request, body: EmailSendCodeRequest):
204
+ """
205
+ Send a login code to an email address.
206
+
207
+ Creates user if not exists (using deterministic UUID from email).
208
+ Stores code in user metadata with expiry.
209
+
210
+ Args:
211
+ request: FastAPI request
212
+ body: EmailSendCodeRequest with email
213
+
214
+ Returns:
215
+ Success status and message
216
+ """
217
+ if not settings.email.is_configured:
218
+ raise HTTPException(
219
+ status_code=501,
220
+ detail="Email authentication is not configured"
221
+ )
222
+
223
+ # Get database connection
224
+ if not settings.postgres.enabled:
225
+ raise HTTPException(
226
+ status_code=501,
227
+ detail="Database is required for email authentication"
228
+ )
229
+
230
+ db = PostgresService()
231
+ try:
232
+ await db.connect()
233
+
234
+ # Initialize email auth provider
235
+ email_auth = EmailAuthProvider()
236
+
237
+ # Send code
238
+ result = await email_auth.send_code(
239
+ email=body.email,
240
+ db=db,
241
+ )
242
+
243
+ if result.success:
244
+ return {
245
+ "success": True,
246
+ "message": result.message,
247
+ "email": result.email,
248
+ }
249
+ else:
250
+ raise HTTPException(
251
+ status_code=400,
252
+ detail=result.message or result.error
253
+ )
254
+
255
+ except HTTPException:
256
+ raise
257
+ except Exception as e:
258
+ logger.error(f"Error sending login code: {e}")
259
+ raise HTTPException(status_code=500, detail="Failed to send login code")
260
+ finally:
261
+ await db.disconnect()
262
+
263
+
264
+ @router.post(
265
+ "/email/verify",
266
+ responses={
267
+ 400: {"model": ErrorResponse, "description": "Invalid or expired code"},
268
+ 500: {"model": ErrorResponse, "description": "Failed to verify login code"},
269
+ 501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
270
+ },
271
+ )
272
+ async def verify_email_code(request: Request, body: EmailVerifyRequest):
273
+ """
274
+ Verify login code and create session with JWT tokens.
275
+
276
+ Supports two authentication methods:
277
+ 1. Pre-approved codes: Codes from AUTH__PREAPPROVED_CODES bypass email verification.
278
+ - A prefix = admin role, B prefix = normal user role
279
+ - Creates user if not exists, logs in directly
280
+ 2. Email verification: Standard 6-digit code sent via email
281
+
282
+ Args:
283
+ request: FastAPI request
284
+ body: EmailVerifyRequest with email and code
285
+
286
+ Returns:
287
+ Success status with user info and JWT tokens
288
+ """
289
+ if not settings.postgres.enabled:
290
+ raise HTTPException(
291
+ status_code=501,
292
+ detail="Database is required for email authentication"
293
+ )
294
+
295
+ db = PostgresService()
296
+ try:
297
+ await db.connect()
298
+ user_service = UserService(db)
299
+
300
+ # Check for pre-approved code first
301
+ preapproved = settings.auth.check_preapproved_code(body.code)
302
+ if preapproved:
303
+ logger.info(f"Pre-approved code login attempt for {body.email} (role: {preapproved['role']})")
304
+
305
+ # Get or create user with pre-approved role
306
+ user_id = email_to_user_id(body.email)
307
+ user_entity = await user_service.get_user_by_id(user_id)
308
+
309
+ if not user_entity:
310
+ # Create new user with role from pre-approved code
311
+ user_entity = await user_service.get_or_create_user(
312
+ email=body.email,
313
+ name=body.email.split("@")[0],
314
+ tenant_id="default",
315
+ )
316
+ # Update role based on pre-approved code prefix
317
+ user_entity.role = preapproved["role"]
318
+ from ...services.postgres.repository import Repository
319
+ from ...models.entities.user import User
320
+ user_repo = Repository(User, "users", db=db)
321
+ await user_repo.upsert(user_entity)
322
+ logger.info(f"Created user {body.email} with role={preapproved['role']} via pre-approved code")
323
+ else:
324
+ # Update existing user's role if admin code used
325
+ if preapproved["role"] == "admin" and user_entity.role != "admin":
326
+ user_entity.role = "admin"
327
+ from ...services.postgres.repository import Repository
328
+ from ...models.entities.user import User
329
+ user_repo = Repository(User, "users", db=db)
330
+ await user_repo.upsert(user_entity)
331
+ logger.info(f"Upgraded user {body.email} to admin via pre-approved code")
332
+
333
+ # Build user dict for session/JWT
334
+ user_dict = {
335
+ "id": str(user_entity.id),
336
+ "email": body.email,
337
+ "email_verified": True,
338
+ "name": user_entity.name or body.email.split("@")[0],
339
+ "provider": "preapproved",
340
+ "tenant_id": user_entity.tenant_id or "default",
341
+ "tier": user_entity.tier.value if user_entity.tier else "free",
342
+ "role": user_entity.role or preapproved["role"],
343
+ "roles": [user_entity.role or preapproved["role"]],
344
+ }
345
+
346
+ # Generate JWT tokens
347
+ jwt_service = get_jwt_service()
348
+ tokens = jwt_service.create_tokens(user_dict)
349
+
350
+ # Store user in session
351
+ request.session["user"] = user_dict
352
+
353
+ logger.info(f"User authenticated via pre-approved code: {body.email} (role: {user_dict['role']})")
354
+
355
+ return {
356
+ "success": True,
357
+ "message": "Successfully authenticated with pre-approved code!",
358
+ "user": user_dict,
359
+ "access_token": tokens["access_token"],
360
+ "refresh_token": tokens["refresh_token"],
361
+ "token_type": tokens["token_type"],
362
+ "expires_in": tokens["expires_in"],
363
+ }
364
+
365
+ # Standard email verification flow
366
+ if not settings.email.is_configured:
367
+ raise HTTPException(
368
+ status_code=501,
369
+ detail="Email authentication is not configured"
370
+ )
371
+
372
+ # Initialize email auth provider
373
+ email_auth = EmailAuthProvider()
374
+
375
+ # Verify code
376
+ result = await email_auth.verify_code(
377
+ email=body.email,
378
+ code=body.code,
379
+ db=db,
380
+ )
381
+
382
+ if not result.success:
383
+ raise HTTPException(
384
+ status_code=400,
385
+ detail=result.message or result.error
386
+ )
387
+
388
+ # Create session - compatible with OAuth session format
389
+ user_dict = email_auth.get_user_dict(
390
+ email=result.email,
391
+ user_id=result.user_id,
392
+ )
393
+
394
+ # Fetch actual user data from database to get role/tier
395
+ try:
396
+ user_entity = await user_service.get_user_by_id(result.user_id)
397
+ if user_entity:
398
+ # Override defaults with actual database values
399
+ user_dict["role"] = user_entity.role or "user"
400
+ user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
401
+ user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
402
+ user_dict["name"] = user_entity.name or user_dict["name"]
403
+ except Exception as e:
404
+ logger.warning(f"Could not fetch user details: {e}")
405
+ # Continue with defaults from get_user_dict
406
+
407
+ # Generate JWT tokens
408
+ jwt_service = get_jwt_service()
409
+ tokens = jwt_service.create_tokens(user_dict)
410
+
411
+ # Store user in session (for backward compatibility)
412
+ request.session["user"] = user_dict
413
+
414
+ logger.info(f"User authenticated via email: {result.email}")
415
+
416
+ return {
417
+ "success": True,
418
+ "message": result.message,
419
+ "user": user_dict,
420
+ # JWT tokens for stateless auth
421
+ "access_token": tokens["access_token"],
422
+ "refresh_token": tokens["refresh_token"],
423
+ "token_type": tokens["token_type"],
424
+ "expires_in": tokens["expires_in"],
425
+ }
426
+
427
+ except HTTPException:
428
+ raise
429
+ except Exception as e:
430
+ logger.error(f"Error verifying login code: {e}")
431
+ raise HTTPException(status_code=500, detail="Failed to verify login code")
432
+ finally:
433
+ await db.disconnect()
434
+
435
+
436
+ # =============================================================================
437
+ # OAuth Authentication Endpoints
438
+ # =============================================================================
439
+
440
+
441
+ @router.get(
442
+ "/{provider}/login",
443
+ responses={
444
+ 400: {"model": ErrorResponse, "description": "Unknown OAuth provider"},
445
+ 501: {"model": ErrorResponse, "description": "Authentication is disabled"},
446
+ },
447
+ )
448
+ async def login(provider: str, request: Request):
449
+ """
450
+ Initiate OAuth flow with provider.
451
+
452
+ Authlib automatically:
453
+ - Generates state for CSRF protection
454
+ - Generates PKCE code_verifier and code_challenge
455
+ - Stores state and code_verifier in session
456
+ - Redirects to provider's authorization endpoint
457
+
458
+ Args:
459
+ provider: OAuth provider (google, microsoft)
460
+ request: FastAPI request (for session access)
461
+
462
+ Returns:
463
+ Redirect to provider's authorization page
464
+ """
465
+ if not settings.auth.enabled:
466
+ raise HTTPException(status_code=501, detail="Authentication is disabled")
467
+
468
+ # Get OAuth client for provider
469
+ client = oauth.create_client(provider)
470
+ if not client:
471
+ raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}")
472
+
473
+ # Get redirect URI from settings
474
+ if provider == "google":
475
+ redirect_uri = settings.auth.google.redirect_uri
476
+ elif provider == "microsoft":
477
+ redirect_uri = settings.auth.microsoft.redirect_uri
478
+ else:
479
+ raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}")
480
+
481
+ # Authlib authorize_redirect() automatically:
482
+ # - Generates state parameter
483
+ # - Generates PKCE code_verifier and code_challenge
484
+ # - Stores state and code_verifier in session
485
+ # - Builds authorization URL with all required parameters
486
+ return await client.authorize_redirect(request, redirect_uri)
487
+
488
+
489
+ @router.get(
490
+ "/{provider}/callback",
491
+ responses={
492
+ 400: {"model": ErrorResponse, "description": "Authentication failed or unknown provider"},
493
+ 501: {"model": ErrorResponse, "description": "Authentication is disabled"},
494
+ },
495
+ )
496
+ async def callback(provider: str, request: Request):
497
+ """
498
+ OAuth callback endpoint.
499
+
500
+ Authlib automatically:
501
+ - Validates state parameter (CSRF protection)
502
+ - Exchanges code for tokens with PKCE code_verifier
503
+ - Validates ID token signature with JWKS
504
+ - Verifies ID token claims (iss, aud, exp, nonce)
505
+
506
+ Args:
507
+ provider: OAuth provider (google, microsoft)
508
+ request: FastAPI request (for session and query params)
509
+
510
+ Returns:
511
+ Redirect to application home page
512
+ """
513
+ if not settings.auth.enabled:
514
+ raise HTTPException(status_code=501, detail="Authentication is disabled")
515
+
516
+ # Get OAuth client for provider
517
+ client = oauth.create_client(provider)
518
+ if not client:
519
+ raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}")
520
+
521
+ try:
522
+ # Authlib authorize_access_token() automatically:
523
+ # - Validates state from session (CSRF)
524
+ # - Retrieves code_verifier from session
525
+ # - Exchanges authorization code for tokens
526
+ # - Validates ID token signature with JWKS
527
+ # - Verifies ID token claims
528
+ token = await client.authorize_access_token(request)
529
+
530
+ # Parse user info from ID token or call userinfo endpoint
531
+ # Authlib parses ID token claims automatically
532
+ user_info = token.get("userinfo")
533
+ if not user_info:
534
+ # Fetch from userinfo endpoint if not in ID token
535
+ user_info = await client.userinfo(token=token)
536
+
537
+ # --- REM Integration Start ---
538
+ if settings.postgres.enabled:
539
+ # Connect to DB
540
+ db = PostgresService()
541
+ try:
542
+ await db.connect()
543
+ user_service = UserService(db)
544
+
545
+ # Get/Create User
546
+ user_entity = await user_service.get_or_create_user(
547
+ email=user_info.get("email"),
548
+ name=user_info.get("name", "New User"),
549
+ avatar_url=user_info.get("picture"),
550
+ tenant_id="default", # Single tenant for now
551
+ )
552
+
553
+ # Link Anonymous Session
554
+ # TrackingMiddleware sets request.state.anon_id
555
+ anon_id = getattr(request.state, "anon_id", None)
556
+ # Fallback to cookie if middleware didn't run or state missing
557
+ if not anon_id:
558
+ # Attempt to parse cookie manually if needed, but middleware
559
+ # usually handles the signature logic.
560
+ # Just check raw cookie for simple case (not recommended if signed)
561
+ pass
562
+
563
+ if anon_id:
564
+ await user_service.link_anonymous_session(user_entity, anon_id)
565
+
566
+ # Enrich session user with DB info
567
+ # user_id = UUID5 hash of email (deterministic, bijection)
568
+ db_info = {
569
+ "id": email_to_user_id(user_info.get("email")),
570
+ "tenant_id": user_entity.tenant_id,
571
+ "tier": user_entity.tier.value if user_entity.tier else "free",
572
+ "roles": [user_entity.role] if user_entity.role else [],
573
+ }
574
+
575
+ except Exception as db_e:
576
+ logger.error(f"Database error during auth callback: {db_e}")
577
+ # Continue login even if DB fails, but warn
578
+ db_info = {"id": "db_error", "tier": "free"}
579
+ finally:
580
+ await db.disconnect()
581
+ else:
582
+ db_info = {"id": "no_db", "tier": "free"}
583
+ # --- REM Integration End ---
584
+
585
+ # Store user info in session
586
+ request.session["user"] = {
587
+ "provider": provider,
588
+ "sub": user_info.get("sub"),
589
+ "email": user_info.get("email"),
590
+ "name": user_info.get("name"),
591
+ "picture": user_info.get("picture"),
592
+ # Add DB info
593
+ "id": db_info.get("id"),
594
+ "tenant_id": db_info.get("tenant_id", "default"),
595
+ "tier": db_info.get("tier"),
596
+ "roles": db_info.get("roles", []),
597
+ }
598
+
599
+ # Store tokens in session for API access
600
+ request.session["tokens"] = {
601
+ "access_token": token.get("access_token"),
602
+ "refresh_token": token.get("refresh_token"),
603
+ "expires_at": token.get("expires_at"),
604
+ }
605
+
606
+ logger.info(f"User authenticated: {user_info.get('email')} via {provider}")
607
+
608
+ # Redirect to application
609
+ # TODO: Support custom redirect URL from state parameter
610
+ return RedirectResponse(url="/")
611
+
612
+ except Exception as e:
613
+ logger.error(f"OAuth callback error: {e}")
614
+ raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
615
+
616
+
617
+ @router.post("/logout")
618
+ async def logout(request: Request):
619
+ """
620
+ Clear user session.
621
+
622
+ Args:
623
+ request: FastAPI request
624
+
625
+ Returns:
626
+ Success message
627
+ """
628
+ request.session.clear()
629
+ return {"message": "Logged out successfully"}
630
+
631
+
632
+ @router.get(
633
+ "/me",
634
+ responses={
635
+ 401: {"model": ErrorResponse, "description": "Not authenticated"},
636
+ },
637
+ )
638
+ async def me(request: Request):
639
+ """
640
+ Get current user information from session or JWT.
641
+
642
+ Args:
643
+ request: FastAPI request
644
+
645
+ Returns:
646
+ User information or 401 if not authenticated
647
+ """
648
+ # First check for JWT in Authorization header
649
+ auth_header = request.headers.get("Authorization")
650
+ if auth_header and auth_header.startswith("Bearer "):
651
+ token = auth_header[7:]
652
+ jwt_service = get_jwt_service()
653
+ user = jwt_service.verify_token(token)
654
+ if user:
655
+ return user
656
+
657
+ # Fall back to session
658
+ user = request.session.get("user")
659
+ if not user:
660
+ raise HTTPException(status_code=401, detail="Not authenticated")
661
+
662
+ return user
663
+
664
+
665
+ # =============================================================================
666
+ # JWT Token Endpoints
667
+ # =============================================================================
668
+
669
+
670
+ class TokenRefreshRequest(BaseModel):
671
+ """Request to refresh access token."""
672
+ refresh_token: str
673
+
674
+
675
+ @router.post(
676
+ "/token/refresh",
677
+ responses={
678
+ 401: {"model": ErrorResponse, "description": "Invalid or expired refresh token"},
679
+ },
680
+ )
681
+ async def refresh_token(body: TokenRefreshRequest):
682
+ """
683
+ Refresh access token using refresh token.
684
+
685
+ Fetches the user's current role/tier from the database to ensure
686
+ the new access token reflects their actual permissions.
687
+
688
+ Args:
689
+ body: TokenRefreshRequest with refresh_token
690
+
691
+ Returns:
692
+ New access token or 401 if refresh token is invalid
693
+ """
694
+ jwt_service = get_jwt_service()
695
+
696
+ # First decode the refresh token to get user_id (without full verification yet)
697
+ payload = jwt_service.decode_without_verification(body.refresh_token)
698
+ if not payload:
699
+ raise HTTPException(
700
+ status_code=401,
701
+ detail="Invalid refresh token format"
702
+ )
703
+
704
+ user_id = payload.get("sub")
705
+ if not user_id:
706
+ raise HTTPException(
707
+ status_code=401,
708
+ detail="Invalid refresh token: missing user ID"
709
+ )
710
+
711
+ # Fetch user from database to get current role/tier
712
+ user_override = None
713
+ if settings.postgres.enabled:
714
+ db = PostgresService()
715
+ try:
716
+ await db.connect()
717
+ user_service = UserService(db)
718
+ user_entity = await user_service.get_user_by_id(user_id)
719
+ if user_entity:
720
+ user_override = {
721
+ "role": user_entity.role or "user",
722
+ "roles": [user_entity.role] if user_entity.role else ["user"],
723
+ "tier": user_entity.tier.value if user_entity.tier else "free",
724
+ "name": user_entity.name,
725
+ }
726
+ logger.debug(f"Refresh token: fetched user {user_id} with role={user_override['role']}, tier={user_override['tier']}")
727
+ except Exception as e:
728
+ logger.warning(f"Could not fetch user for token refresh: {e}")
729
+ # Continue without override - will use defaults
730
+ finally:
731
+ await db.disconnect()
732
+
733
+ # Now do the actual refresh with proper verification
734
+ result = jwt_service.refresh_access_token(body.refresh_token, user_override=user_override)
735
+
736
+ if not result:
737
+ raise HTTPException(
738
+ status_code=401,
739
+ detail="Invalid or expired refresh token"
740
+ )
741
+
742
+ return result
743
+
744
+
745
+ @router.post(
746
+ "/token/verify",
747
+ responses={
748
+ 401: {"model": ErrorResponse, "description": "Missing, invalid, or expired token"},
749
+ },
750
+ )
751
+ async def verify_token(request: Request):
752
+ """
753
+ Verify an access token is valid.
754
+
755
+ Pass the token in the Authorization header: Bearer <token>
756
+
757
+ Returns:
758
+ User info if valid, 401 if invalid
759
+ """
760
+ auth_header = request.headers.get("Authorization")
761
+ if not auth_header or not auth_header.startswith("Bearer "):
762
+ raise HTTPException(
763
+ status_code=401,
764
+ detail="Missing Authorization header"
765
+ )
766
+
767
+ token = auth_header[7:]
768
+ jwt_service = get_jwt_service()
769
+ user = jwt_service.verify_token(token)
770
+
771
+ if not user:
772
+ raise HTTPException(
773
+ status_code=401,
774
+ detail="Invalid or expired token"
775
+ )
776
+
777
+ return {"valid": True, "user": user}
778
+
779
+
780
+ # =============================================================================
781
+ # Development Token Endpoints (non-production only)
782
+ # =============================================================================
783
+
784
+
785
+ def generate_dev_token() -> str:
786
+ """
787
+ Generate a dev token for testing.
788
+
789
+ Token format: dev_<hmac_signature>
790
+ The signature is based on the session secret to ensure only valid tokens work.
791
+ """
792
+ import hashlib
793
+ import hmac
794
+
795
+ # Use session secret as key
796
+ secret = settings.auth.session_secret or "dev-secret"
797
+ message = "test-user:dev-token"
798
+
799
+ signature = hmac.new(
800
+ secret.encode(),
801
+ message.encode(),
802
+ hashlib.sha256
803
+ ).hexdigest()[:32]
804
+
805
+ return f"dev_{signature}"
806
+
807
+
808
+ def verify_dev_token(token: str) -> bool:
809
+ """Verify a dev token is valid."""
810
+ expected = generate_dev_token()
811
+ return token == expected
812
+
813
+
814
+ @router.get(
815
+ "/dev/token",
816
+ responses={
817
+ 401: {"model": ErrorResponse, "description": "Dev tokens not available in production"},
818
+ },
819
+ )
820
+ async def get_dev_token(request: Request):
821
+ """
822
+ Get a development token for testing (non-production only).
823
+
824
+ This token can be used as a Bearer token to authenticate as the
825
+ test user (test-user / test@rem.local) without going through OAuth.
826
+
827
+ Usage:
828
+ curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/...
829
+
830
+ Returns:
831
+ 401 if in production environment
832
+ Token and usage instructions otherwise
833
+ """
834
+ if settings.environment == "production":
835
+ raise HTTPException(
836
+ status_code=401,
837
+ detail="Dev tokens are not available in production"
838
+ )
839
+
840
+ token = generate_dev_token()
841
+
842
+ return {
843
+ "token": token,
844
+ "type": "Bearer",
845
+ "user": {
846
+ "id": "test-user",
847
+ "email": "test@rem.local",
848
+ "name": "Test User",
849
+ },
850
+ "usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
851
+ "warning": "This token is for development/testing only and will not work in production.",
852
+ }
853
+
854
+
855
+ @router.get(
856
+ "/dev/mock-code/{email}",
857
+ responses={
858
+ 401: {"model": ErrorResponse, "description": "Mock codes not available in production"},
859
+ 404: {"model": ErrorResponse, "description": "No code found for email"},
860
+ },
861
+ )
862
+ async def get_mock_code(email: str, request: Request):
863
+ """
864
+ Get the mock login code for testing (non-production only).
865
+
866
+ This endpoint retrieves the code that was "sent" via email in mock mode.
867
+ Use this for automated testing without real email delivery.
868
+
869
+ Usage:
870
+ 1. POST /api/auth/email/send-code with email
871
+ 2. GET /api/auth/dev/mock-code/{email} to retrieve the code
872
+ 3. POST /api/auth/email/verify with email and code
873
+
874
+ Returns:
875
+ 401 if in production environment
876
+ 404 if no code found for the email
877
+ The code and email otherwise
878
+ """
879
+ if settings.environment == "production":
880
+ raise HTTPException(
881
+ status_code=401,
882
+ detail="Mock codes are not available in production"
883
+ )
884
+
885
+ from ...services.email import EmailService
886
+
887
+ code = EmailService.get_mock_code(email)
888
+ if not code:
889
+ raise HTTPException(
890
+ status_code=404,
891
+ detail=f"No mock code found for {email}. Send a code first."
892
+ )
893
+
894
+ return {
895
+ "email": email,
896
+ "code": code,
897
+ "warning": "This endpoint is for testing only and will not work in production.",
898
+ }