mdb-engine 0.1.6__py3-none-any.whl → 0.4.12__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.
Files changed (92) hide show
  1. mdb_engine/__init__.py +116 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +654 -11
  4. mdb_engine/auth/__init__.py +136 -29
  5. mdb_engine/auth/audit.py +592 -0
  6. mdb_engine/auth/base.py +252 -0
  7. mdb_engine/auth/casbin_factory.py +265 -70
  8. mdb_engine/auth/config_defaults.py +5 -5
  9. mdb_engine/auth/config_helpers.py +19 -18
  10. mdb_engine/auth/cookie_utils.py +12 -16
  11. mdb_engine/auth/csrf.py +483 -0
  12. mdb_engine/auth/decorators.py +10 -16
  13. mdb_engine/auth/dependencies.py +69 -71
  14. mdb_engine/auth/helpers.py +3 -3
  15. mdb_engine/auth/integration.py +61 -88
  16. mdb_engine/auth/jwt.py +11 -15
  17. mdb_engine/auth/middleware.py +79 -35
  18. mdb_engine/auth/oso_factory.py +21 -41
  19. mdb_engine/auth/provider.py +270 -171
  20. mdb_engine/auth/rate_limiter.py +505 -0
  21. mdb_engine/auth/restrictions.py +21 -36
  22. mdb_engine/auth/session_manager.py +24 -41
  23. mdb_engine/auth/shared_middleware.py +977 -0
  24. mdb_engine/auth/shared_users.py +775 -0
  25. mdb_engine/auth/token_lifecycle.py +10 -12
  26. mdb_engine/auth/token_store.py +17 -32
  27. mdb_engine/auth/users.py +99 -159
  28. mdb_engine/auth/utils.py +236 -42
  29. mdb_engine/cli/commands/generate.py +546 -10
  30. mdb_engine/cli/commands/validate.py +3 -7
  31. mdb_engine/cli/utils.py +7 -7
  32. mdb_engine/config.py +13 -28
  33. mdb_engine/constants.py +65 -0
  34. mdb_engine/core/README.md +117 -6
  35. mdb_engine/core/__init__.py +39 -7
  36. mdb_engine/core/app_registration.py +31 -50
  37. mdb_engine/core/app_secrets.py +289 -0
  38. mdb_engine/core/connection.py +20 -12
  39. mdb_engine/core/encryption.py +222 -0
  40. mdb_engine/core/engine.py +2862 -115
  41. mdb_engine/core/index_management.py +12 -16
  42. mdb_engine/core/manifest.py +628 -204
  43. mdb_engine/core/ray_integration.py +436 -0
  44. mdb_engine/core/seeding.py +13 -21
  45. mdb_engine/core/service_initialization.py +20 -30
  46. mdb_engine/core/types.py +40 -43
  47. mdb_engine/database/README.md +140 -17
  48. mdb_engine/database/__init__.py +17 -6
  49. mdb_engine/database/abstraction.py +37 -50
  50. mdb_engine/database/connection.py +51 -30
  51. mdb_engine/database/query_validator.py +367 -0
  52. mdb_engine/database/resource_limiter.py +204 -0
  53. mdb_engine/database/scoped_wrapper.py +747 -237
  54. mdb_engine/dependencies.py +427 -0
  55. mdb_engine/di/__init__.py +34 -0
  56. mdb_engine/di/container.py +247 -0
  57. mdb_engine/di/providers.py +206 -0
  58. mdb_engine/di/scopes.py +139 -0
  59. mdb_engine/embeddings/README.md +54 -24
  60. mdb_engine/embeddings/__init__.py +31 -24
  61. mdb_engine/embeddings/dependencies.py +38 -155
  62. mdb_engine/embeddings/service.py +78 -75
  63. mdb_engine/exceptions.py +104 -12
  64. mdb_engine/indexes/README.md +30 -13
  65. mdb_engine/indexes/__init__.py +1 -0
  66. mdb_engine/indexes/helpers.py +11 -11
  67. mdb_engine/indexes/manager.py +59 -123
  68. mdb_engine/memory/README.md +95 -4
  69. mdb_engine/memory/__init__.py +1 -2
  70. mdb_engine/memory/service.py +363 -1168
  71. mdb_engine/observability/README.md +4 -2
  72. mdb_engine/observability/__init__.py +26 -9
  73. mdb_engine/observability/health.py +17 -17
  74. mdb_engine/observability/logging.py +10 -10
  75. mdb_engine/observability/metrics.py +40 -19
  76. mdb_engine/repositories/__init__.py +34 -0
  77. mdb_engine/repositories/base.py +325 -0
  78. mdb_engine/repositories/mongo.py +233 -0
  79. mdb_engine/repositories/unit_of_work.py +166 -0
  80. mdb_engine/routing/README.md +1 -1
  81. mdb_engine/routing/__init__.py +1 -3
  82. mdb_engine/routing/websockets.py +41 -75
  83. mdb_engine/utils/__init__.py +3 -1
  84. mdb_engine/utils/mongo.py +117 -0
  85. mdb_engine-0.4.12.dist-info/METADATA +492 -0
  86. mdb_engine-0.4.12.dist-info/RECORD +97 -0
  87. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
  88. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  89. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  90. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
  91. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
  92. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/top_level.txt +0 -0
mdb_engine/core/engine.py CHANGED
@@ -7,28 +7,55 @@ The core orchestration engine for MDB_ENGINE that manages:
7
7
  - Authentication/authorization
8
8
  - Index management
9
9
  - Resource lifecycle
10
+ - Optional Ray integration for distributed processing
11
+ - FastAPI integration with lifespan management
10
12
 
11
13
  This module is part of MDB_ENGINE - MongoDB Engine.
14
+
15
+ Usage:
16
+ # Simple usage (most common)
17
+ engine = MongoDBEngine(mongo_uri=..., db_name=...)
18
+ await engine.initialize()
19
+ db = engine.get_scoped_db("my_app")
20
+
21
+ # With FastAPI integration
22
+ app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
23
+
24
+ # With Ray support (optional)
25
+ engine = MongoDBEngine(mongo_uri=..., db_name=..., enable_ray=True)
12
26
  """
13
27
 
14
28
  import logging
29
+ import os
30
+ import secrets
31
+ from collections.abc import Awaitable, Callable
32
+ from contextlib import asynccontextmanager
15
33
  from pathlib import Path
16
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
34
+ from typing import TYPE_CHECKING, Any, Optional
17
35
 
18
- from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
36
+ from motor.motor_asyncio import AsyncIOMotorClient
37
+ from pymongo.errors import PyMongoError
19
38
 
20
39
  if TYPE_CHECKING:
40
+ from fastapi import FastAPI
41
+
21
42
  from ..auth import AuthorizationProvider
22
43
  from .types import ManifestDict
23
44
 
24
45
  # Import engine components
25
46
  from ..constants import DEFAULT_MAX_POOL_SIZE, DEFAULT_MIN_POOL_SIZE
26
47
  from ..database import ScopedMongoWrapper
27
- from ..observability import (HealthChecker, check_engine_health,
28
- check_mongodb_health, check_pool_health)
48
+ from ..observability import (
49
+ HealthChecker,
50
+ check_engine_health,
51
+ check_mongodb_health,
52
+ check_pool_health,
53
+ )
29
54
  from ..observability import get_logger as get_contextual_logger
30
55
  from .app_registration import AppRegistrationManager
56
+ from .app_secrets import AppSecretsManager
31
57
  from .connection import ConnectionManager
58
+ from .encryption import EnvelopeEncryptionService
32
59
  from .index_management import IndexManager
33
60
  from .manifest import ManifestParser, ManifestValidator
34
61
  from .service_initialization import ServiceInitializer
@@ -48,16 +75,33 @@ class MongoDBEngine:
48
75
  - App registration
49
76
  - Index management
50
77
  - Authentication/authorization setup
78
+ - Optional Ray integration for distributed processing
79
+ - FastAPI integration with lifespan management
80
+
81
+ Example:
82
+ # Simple usage
83
+ engine = MongoDBEngine(mongo_uri="mongodb://localhost:27017", db_name="mydb")
84
+ await engine.initialize()
85
+ db = engine.get_scoped_db("my_app")
86
+
87
+ # With FastAPI
88
+ app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
89
+
90
+ # With Ray
91
+ engine = MongoDBEngine(..., enable_ray=True)
51
92
  """
52
93
 
53
94
  def __init__(
54
95
  self,
55
96
  mongo_uri: str,
56
97
  db_name: str,
57
- manifests_dir: Optional[Path] = None,
98
+ manifests_dir: Path | None = None,
58
99
  authz_provider: Optional["AuthorizationProvider"] = None,
59
100
  max_pool_size: int = DEFAULT_MAX_POOL_SIZE,
60
101
  min_pool_size: int = DEFAULT_MIN_POOL_SIZE,
102
+ # Optional Ray support
103
+ enable_ray: bool = False,
104
+ ray_namespace: str = "modular_labs",
61
105
  ) -> None:
62
106
  """
63
107
  Initialize the MongoDB Engine.
@@ -69,6 +113,10 @@ class MongoDBEngine:
69
113
  authz_provider: Authorization provider instance (optional, can be set later)
70
114
  max_pool_size: Maximum MongoDB connection pool size
71
115
  min_pool_size: Minimum MongoDB connection pool size
116
+ enable_ray: Enable Ray support for distributed processing.
117
+ Default: False. Only activates if Ray is installed.
118
+ ray_namespace: Ray namespace for actor isolation.
119
+ Default: "modular_labs"
72
120
  """
73
121
  self.mongo_uri = mongo_uri
74
122
  self.db_name = db_name
@@ -77,6 +125,11 @@ class MongoDBEngine:
77
125
  self.max_pool_size = max_pool_size
78
126
  self.min_pool_size = min_pool_size
79
127
 
128
+ # Ray configuration (optional)
129
+ self.enable_ray = enable_ray
130
+ self.ray_namespace = ray_namespace
131
+ self.ray_actor = None # Populated if Ray is enabled and available
132
+
80
133
  # Initialize component managers
81
134
  self._connection_manager = ConnectionManager(
82
135
  mongo_uri=mongo_uri,
@@ -90,9 +143,23 @@ class MongoDBEngine:
90
143
  self.manifest_parser = ManifestParser()
91
144
 
92
145
  # Initialize managers (will be set up after connection is established)
93
- self._app_registration_manager: Optional[AppRegistrationManager] = None
94
- self._index_manager: Optional[IndexManager] = None
95
- self._service_initializer: Optional[ServiceInitializer] = None
146
+ self._app_registration_manager: AppRegistrationManager | None = None
147
+ self._index_manager: IndexManager | None = None
148
+ self._service_initializer: ServiceInitializer | None = None
149
+ self._encryption_service: EnvelopeEncryptionService | None = None
150
+ self._app_secrets_manager: AppSecretsManager | None = None
151
+
152
+ # Store app read_scopes mapping for validation
153
+ self._app_read_scopes: dict[str, list[str]] = {}
154
+
155
+ # Store app token cache for auto-retrieval
156
+ self._app_token_cache: dict[str, str] = {}
157
+
158
+ # Async lock for thread-safe shared user pool initialization
159
+ import asyncio
160
+
161
+ self._shared_user_pool_lock = asyncio.Lock()
162
+ self._shared_user_pool_initializing = False
96
163
 
97
164
  async def initialize(self) -> None:
98
165
  """
@@ -102,6 +169,7 @@ class MongoDBEngine:
102
169
  1. Connects to MongoDB
103
170
  2. Validates the connection
104
171
  3. Sets up initial state
172
+ 4. Initializes Ray if enabled and available
105
173
 
106
174
  Raises:
107
175
  InitializationError: If initialization fails (subclass of RuntimeError
@@ -111,6 +179,29 @@ class MongoDBEngine:
111
179
  # Initialize connection
112
180
  await self._connection_manager.initialize()
113
181
 
182
+ # Initialize encryption service
183
+ try:
184
+ from .encryption import MASTER_KEY_ENV_VAR
185
+
186
+ self._encryption_service = EnvelopeEncryptionService()
187
+ except ValueError as e:
188
+ from .encryption import MASTER_KEY_ENV_VAR
189
+
190
+ logger.warning(
191
+ f"Encryption service not initialized: {e}. "
192
+ "App-level authentication will not be available. "
193
+ f"Set {MASTER_KEY_ENV_VAR} environment variable."
194
+ )
195
+ # Continue without encryption (backward compatibility)
196
+ self._encryption_service = None
197
+
198
+ # Initialize app secrets manager (only if encryption service available)
199
+ if self._encryption_service:
200
+ self._app_secrets_manager = AppSecretsManager(
201
+ mongo_db=self._connection_manager.mongo_db,
202
+ encryption_service=self._encryption_service,
203
+ )
204
+
114
205
  # Set up component managers
115
206
  self._app_registration_manager = AppRegistrationManager(
116
207
  mongo_db=self._connection_manager.mongo_db,
@@ -126,42 +217,99 @@ class MongoDBEngine:
126
217
  get_scoped_db_fn=self.get_scoped_db,
127
218
  )
128
219
 
220
+ # Initialize Ray if enabled
221
+ if self.enable_ray:
222
+ await self._initialize_ray()
223
+
224
+ async def _initialize_ray(self) -> None:
225
+ """
226
+ Initialize Ray support (only if enabled and available).
227
+
228
+ This is called automatically during initialize() if enable_ray=True.
229
+ Gracefully degrades if Ray is not installed.
230
+ """
231
+ try:
232
+ from .ray_integration import RAY_AVAILABLE, get_ray_actor_handle
233
+
234
+ if not RAY_AVAILABLE:
235
+ logger.warning("Ray enabled but not installed. " "Install with: pip install ray")
236
+ return
237
+
238
+ # Initialize base Ray actor for this engine
239
+ self.ray_actor = await get_ray_actor_handle(
240
+ app_slug="engine",
241
+ namespace=self.ray_namespace,
242
+ mongo_uri=self.mongo_uri,
243
+ db_name=self.db_name,
244
+ create_if_missing=True,
245
+ )
246
+
247
+ if self.ray_actor:
248
+ logger.info(f"Ray initialized in namespace '{self.ray_namespace}'")
249
+ else:
250
+ logger.warning("Failed to initialize Ray actor")
251
+
252
+ except ImportError:
253
+ logger.warning("Ray integration module not available")
254
+
255
+ @property
256
+ def has_ray(self) -> bool:
257
+ """Check if Ray is enabled and initialized."""
258
+ return self.enable_ray and self.ray_actor is not None
259
+
129
260
  @property
130
261
  def mongo_client(self) -> AsyncIOMotorClient:
131
262
  """
132
- Get the MongoDB client.
263
+ Get the MongoDB client for observability and health checks.
264
+
265
+ **SECURITY WARNING:** This property exposes the raw MongoDB client.
266
+ It should ONLY be used for:
267
+ - Health checks and observability (`check_mongodb_health`, `get_pool_metrics`)
268
+ - Administrative operations that don't involve data access
269
+
270
+ **DO NOT use this for data access.** Always use `get_scoped_db()` for
271
+ all data operations to ensure proper app scoping and security.
133
272
 
134
273
  Returns:
135
274
  AsyncIOMotorClient instance
136
275
 
137
276
  Raises:
138
277
  RuntimeError: If engine is not initialized
278
+
279
+ Example:
280
+ # ✅ CORRECT: Use for health checks
281
+ health = await check_mongodb_health(engine.mongo_client)
282
+
283
+ # ❌ WRONG: Don't use for data access
284
+ db = engine.mongo_client["my_database"] # Bypasses scoping!
139
285
  """
140
286
  return self._connection_manager.mongo_client
141
287
 
142
288
  @property
143
- def mongo_db(self) -> AsyncIOMotorDatabase:
289
+ def _initialized(self) -> bool:
290
+ """Check if engine is initialized (internal)."""
291
+ return self._connection_manager.initialized
292
+
293
+ @property
294
+ def initialized(self) -> bool:
144
295
  """
145
- Get the MongoDB database.
296
+ Check if engine is initialized.
146
297
 
147
298
  Returns:
148
- AsyncIOMotorDatabase instance
299
+ True if the engine has been initialized, False otherwise.
149
300
 
150
- Raises:
151
- RuntimeError: If engine is not initialized
301
+ Example:
302
+ if engine.initialized:
303
+ db = engine.get_scoped_db("my_app")
152
304
  """
153
- return self._connection_manager.mongo_db
154
-
155
- @property
156
- def _initialized(self) -> bool:
157
- """Check if engine is initialized."""
158
305
  return self._connection_manager.initialized
159
306
 
160
307
  def get_scoped_db(
161
308
  self,
162
309
  app_slug: str,
163
- read_scopes: Optional[List[str]] = None,
164
- write_scope: Optional[str] = None,
310
+ app_token: str | None = None,
311
+ read_scopes: list[str] | None = None,
312
+ write_scope: str | None = None,
165
313
  auto_index: bool = True,
166
314
  ) -> ScopedMongoWrapper:
167
315
  """
@@ -174,8 +322,12 @@ class MongoDBEngine:
174
322
 
175
323
  Args:
176
324
  app_slug: App slug (used as default for both read and write scopes)
177
- read_scopes: List of app slugs to read from. If None, defaults to
178
- [app_slug]. Allows cross-app data access when needed.
325
+ app_token: App secret token for authentication. Required if app
326
+ secrets manager is initialized. If None and app has stored secret,
327
+ will attempt migration (backward compatibility).
328
+ read_scopes: List of app slugs to read from. If None, uses manifest
329
+ read_scopes or defaults to [app_slug]. Allows cross-app data access
330
+ when needed.
179
331
  write_scope: App slug to write to. If None, defaults to app_slug.
180
332
  All documents inserted through this wrapper will have this as their
181
333
  app_id.
@@ -187,32 +339,215 @@ class MongoDBEngine:
187
339
 
188
340
  Raises:
189
341
  RuntimeError: If engine is not initialized.
342
+ ValueError: If app_token is invalid or read_scopes are unauthorized.
190
343
 
191
344
  Example:
192
- >>> db = engine.get_scoped_db("my_app")
345
+ >>> db = engine.get_scoped_db("my_app", app_token="secret-token")
193
346
  >>> # All queries are automatically scoped to "my_app"
194
347
  >>> doc = await db.my_collection.find_one({"name": "test"})
195
348
  """
196
349
  if not self._initialized:
197
- raise RuntimeError(
198
- "MongoDBEngine not initialized. Call initialize() first."
199
- )
350
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
351
+
352
+ # Verify app token if secrets manager is available
353
+ # Token verification will happen lazily in ScopedMongoWrapper if called from async context
354
+ if self._app_secrets_manager:
355
+ if app_token is None:
356
+ # Check if app has stored secret (backward compatibility)
357
+ # Use sync wrapper that handles async context
358
+ has_secret = self._app_secrets_manager.app_secret_exists_sync(app_slug)
359
+ if has_secret:
360
+ # Log detailed info
361
+ logger.warning(f"App token required for '{app_slug}'")
362
+ # Generic error message
363
+ raise ValueError("App token required. Provide app_token parameter.")
364
+ # No stored secret - allow (backward compatibility for apps without secrets)
365
+ logger.debug(
366
+ f"App '{app_slug}' has no stored secret, "
367
+ f"allowing access (backward compatibility)"
368
+ )
369
+ else:
370
+ # Try to verify synchronously if possible, otherwise pass to wrapper
371
+ # for lazy verification
372
+ import asyncio
373
+
374
+ try:
375
+ # Check if we're in an async context
376
+ asyncio.get_running_loop()
377
+ # We're in async context - can't verify synchronously without blocking
378
+ # Pass token to wrapper for lazy verification on first database operation
379
+ logger.debug(
380
+ f"Token verification deferred to first database operation for '{app_slug}' "
381
+ f"(async context detected)"
382
+ )
383
+ except RuntimeError:
384
+ # No event loop - safe to use sync verification
385
+ is_valid = self._app_secrets_manager.verify_app_secret_sync(app_slug, app_token)
386
+ if not is_valid:
387
+ # Log detailed info with app_slug
388
+ logger.warning(f"Security: Invalid app token for '{app_slug}'")
389
+ # Generic error message (from None: unrelated to RuntimeError)
390
+ raise ValueError("Invalid app token") from None
391
+
392
+ # Validate read_scopes type FIRST (before authorization check)
393
+ if read_scopes is not None:
394
+ if not isinstance(read_scopes, list):
395
+ raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
396
+ if len(read_scopes) == 0:
397
+ raise ValueError("read_scopes cannot be empty")
398
+
399
+ # Use manifest read_scopes if not provided
400
+ if read_scopes is None:
401
+ read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
402
+
403
+ if write_scope is None:
404
+ write_scope = app_slug
405
+
406
+ # Validate requested read_scopes against manifest authorization
407
+ authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
408
+ for scope in read_scopes:
409
+ if not isinstance(scope, str) or len(scope) == 0:
410
+ logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
411
+ raise ValueError("Invalid app slug in read_scopes")
412
+ if scope not in authorized_scopes:
413
+ logger.warning(
414
+ f"App '{app_slug}' not authorized to read from '{scope}'. "
415
+ f"Authorized scopes: {authorized_scopes}"
416
+ )
417
+ raise ValueError(
418
+ "App not authorized to read from requested scope. "
419
+ "Update manifest data_access.read_scopes to grant access."
420
+ )
421
+ if not read_scopes:
422
+ raise ValueError("read_scopes cannot be empty")
423
+ for scope in read_scopes:
424
+ if not isinstance(scope, str) or not scope:
425
+ logger.warning(f"Invalid app slug in read_scopes: {scope}")
426
+ raise ValueError("Invalid app slug in read_scopes")
427
+
428
+ # Validate write_scope
429
+ if not isinstance(write_scope, str) or not write_scope:
430
+ raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
431
+
432
+ return ScopedMongoWrapper(
433
+ real_db=self._connection_manager.mongo_db,
434
+ read_scopes=read_scopes,
435
+ write_scope=write_scope,
436
+ auto_index=auto_index,
437
+ app_slug=app_slug,
438
+ app_token=app_token,
439
+ app_secrets_manager=self._app_secrets_manager,
440
+ )
441
+
442
+ async def get_scoped_db_async(
443
+ self,
444
+ app_slug: str,
445
+ app_token: str | None = None,
446
+ read_scopes: list[str] | None = None,
447
+ write_scope: str | None = None,
448
+ auto_index: bool = True,
449
+ ) -> ScopedMongoWrapper:
450
+ """
451
+ Asynchronous version of get_scoped_db that properly verifies tokens.
452
+
453
+ This method is preferred in async contexts to ensure token verification
454
+ happens correctly.
455
+
456
+ Args:
457
+ app_slug: App slug (used as default for both read and write scopes)
458
+ app_token: App secret token for authentication. Required if app
459
+ secrets manager is initialized.
460
+ read_scopes: List of app slugs to read from. If None, uses manifest
461
+ read_scopes or defaults to [app_slug].
462
+ write_scope: App slug to write to. If None, defaults to app_slug.
463
+ auto_index: Whether to enable automatic index creation.
464
+
465
+ Returns:
466
+ ScopedMongoWrapper instance configured with the specified scopes.
200
467
 
468
+ Raises:
469
+ RuntimeError: If engine is not initialized.
470
+ ValueError: If app_token is invalid or read_scopes are unauthorized.
471
+ """
472
+ if not self._initialized:
473
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
474
+
475
+ # Verify app token if secrets manager is available
476
+ if self._app_secrets_manager:
477
+ if app_token is None:
478
+ # Check if app has stored secret
479
+ has_secret = await self._app_secrets_manager.app_secret_exists(app_slug)
480
+ if has_secret:
481
+ raise ValueError(
482
+ f"App token required for '{app_slug}'. " "Provide app_token parameter."
483
+ )
484
+ # No stored secret - allow (backward compatibility)
485
+ logger.debug(
486
+ f"App '{app_slug}' has no stored secret, "
487
+ f"allowing access (backward compatibility)"
488
+ )
489
+ else:
490
+ # Verify token asynchronously
491
+ is_valid = await self._app_secrets_manager.verify_app_secret(app_slug, app_token)
492
+ if not is_valid:
493
+ # Log detailed info with app_slug
494
+ logger.warning(f"Security: Invalid app token for '{app_slug}'")
495
+ # Generic error message
496
+ raise ValueError("Invalid app token")
497
+
498
+ # Validate read_scopes type FIRST (before authorization check)
499
+ if read_scopes is not None:
500
+ if not isinstance(read_scopes, list):
501
+ raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
502
+ if len(read_scopes) == 0:
503
+ raise ValueError("read_scopes cannot be empty")
504
+
505
+ # Use manifest read_scopes if not provided
201
506
  if read_scopes is None:
202
- read_scopes = [app_slug]
507
+ read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
508
+
203
509
  if write_scope is None:
204
510
  write_scope = app_slug
205
511
 
512
+ # Validate requested read_scopes against manifest authorization
513
+ authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
514
+ for scope in read_scopes:
515
+ if not isinstance(scope, str) or len(scope) == 0:
516
+ logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
517
+ raise ValueError("Invalid app slug in read_scopes")
518
+ if scope not in authorized_scopes:
519
+ logger.warning(
520
+ f"App '{app_slug}' not authorized to read from '{scope}'. "
521
+ f"Authorized scopes: {authorized_scopes}"
522
+ )
523
+ raise ValueError(
524
+ "App not authorized to read from requested scope. "
525
+ "Update manifest data_access.read_scopes to grant access."
526
+ )
527
+ if not read_scopes:
528
+ raise ValueError("read_scopes cannot be empty")
529
+ for scope in read_scopes:
530
+ if not isinstance(scope, str) or not scope:
531
+ logger.warning(f"Invalid app slug in read_scopes: {scope}")
532
+ raise ValueError("Invalid app slug in read_scopes")
533
+
534
+ # Validate write_scope
535
+ if not isinstance(write_scope, str) or not write_scope:
536
+ raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
537
+
206
538
  return ScopedMongoWrapper(
207
539
  real_db=self._connection_manager.mongo_db,
208
540
  read_scopes=read_scopes,
209
541
  write_scope=write_scope,
210
542
  auto_index=auto_index,
543
+ app_slug=app_slug,
544
+ app_token=app_token,
545
+ app_secrets_manager=self._app_secrets_manager,
211
546
  )
212
547
 
213
548
  async def validate_manifest(
214
549
  self, manifest: "ManifestDict"
215
- ) -> Tuple[bool, Optional[str], Optional[List[str]]]:
550
+ ) -> tuple[bool, str | None, list[str] | None]:
216
551
  """
217
552
  Validate a manifest against the schema.
218
553
 
@@ -227,9 +562,7 @@ class MongoDBEngine:
227
562
  - error_paths: List of JSON paths with validation errors, None if valid
228
563
  """
229
564
  if not self._app_registration_manager:
230
- raise RuntimeError(
231
- "MongoDBEngine not initialized. Call initialize() first."
232
- )
565
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
233
566
  return await self._app_registration_manager.validate_manifest(manifest)
234
567
 
235
568
  async def load_manifest(self, path: Path) -> "ManifestDict":
@@ -247,14 +580,10 @@ class MongoDBEngine:
247
580
  ValueError: If validation fails
248
581
  """
249
582
  if not self._app_registration_manager:
250
- raise RuntimeError(
251
- "MongoDBEngine not initialized. Call initialize() first."
252
- )
583
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
253
584
  return await self._app_registration_manager.load_manifest(path)
254
585
 
255
- async def register_app(
256
- self, manifest: "ManifestDict", create_indexes: bool = True
257
- ) -> bool:
586
+ async def register_app(self, manifest: "ManifestDict", create_indexes: bool = True) -> bool:
258
587
  """
259
588
  Register an app from its manifest.
260
589
 
@@ -275,46 +604,39 @@ class MongoDBEngine:
275
604
  RuntimeError: If engine is not initialized.
276
605
  """
277
606
  if not self._app_registration_manager:
278
- raise RuntimeError(
279
- "MongoDBEngine not initialized. Call initialize() first."
280
- )
607
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
281
608
 
282
609
  # Create callbacks for service initialization
283
610
  async def create_indexes_callback(slug: str, manifest: "ManifestDict") -> None:
284
611
  if self._index_manager and create_indexes:
285
612
  await self._index_manager.create_app_indexes(slug, manifest)
286
613
 
287
- async def seed_data_callback(slug: str, initial_data: Dict[str, Any]) -> None:
614
+ async def seed_data_callback(slug: str, initial_data: dict[str, Any]) -> None:
288
615
  if self._service_initializer:
289
616
  await self._service_initializer.seed_initial_data(slug, initial_data)
290
617
 
291
- async def initialize_memory_callback(
292
- slug: str, memory_config: Dict[str, Any]
293
- ) -> None:
618
+ async def initialize_memory_callback(slug: str, memory_config: dict[str, Any]) -> None:
294
619
  if self._service_initializer:
295
- await self._service_initializer.initialize_memory_service(
296
- slug, memory_config
297
- )
620
+ await self._service_initializer.initialize_memory_service(slug, memory_config)
298
621
 
299
622
  async def register_websockets_callback(
300
- slug: str, websockets_config: Dict[str, Any]
623
+ slug: str, websockets_config: dict[str, Any]
301
624
  ) -> None:
302
625
  if self._service_initializer:
303
- await self._service_initializer.register_websockets(
304
- slug, websockets_config
305
- )
626
+ await self._service_initializer.register_websockets(slug, websockets_config)
306
627
 
307
628
  async def setup_observability_callback(
308
629
  slug: str,
309
630
  manifest: "ManifestDict",
310
- observability_config: Dict[str, Any],
631
+ observability_config: dict[str, Any],
311
632
  ) -> None:
312
633
  if self._service_initializer:
313
634
  await self._service_initializer.setup_observability(
314
635
  slug, manifest, observability_config
315
636
  )
316
637
 
317
- return await self._app_registration_manager.register_app(
638
+ # Register app first (this validates and stores the manifest)
639
+ result = await self._app_registration_manager.register_app(
318
640
  manifest=manifest,
319
641
  create_indexes_callback=create_indexes_callback if create_indexes else None,
320
642
  seed_data_callback=seed_data_callback,
@@ -323,7 +645,34 @@ class MongoDBEngine:
323
645
  setup_observability_callback=setup_observability_callback,
324
646
  )
325
647
 
326
- def get_websocket_config(self, slug: str) -> Optional[Dict[str, Any]]:
648
+ # Extract and store data_access configuration AFTER registration
649
+ slug = manifest.get("slug")
650
+ if slug:
651
+ data_access = manifest.get("data_access", {})
652
+ read_scopes = data_access.get("read_scopes")
653
+ if read_scopes:
654
+ self._app_read_scopes[slug] = read_scopes
655
+ else:
656
+ # Default to app_slug if not specified
657
+ self._app_read_scopes[slug] = [slug]
658
+
659
+ # Generate and store app secret if secrets manager is available
660
+ if self._app_secrets_manager:
661
+ # Check if secret already exists (don't overwrite)
662
+ secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
663
+ if not secret_exists:
664
+ app_secret = secrets.token_urlsafe(32)
665
+ await self._app_secrets_manager.store_app_secret(slug, app_secret)
666
+ logger.info(
667
+ f"Generated and stored encrypted secret for app '{slug}'. "
668
+ "Store this secret securely and provide it as app_token in get_scoped_db()."
669
+ )
670
+ # Note: In production, the secret should be retrieved via rotation API
671
+ # For now, we log it (in production, this should be handled differently)
672
+
673
+ return result
674
+
675
+ def get_websocket_config(self, slug: str) -> dict[str, Any] | None:
327
676
  """
328
677
  Get WebSocket configuration for an app.
329
678
 
@@ -428,15 +777,9 @@ class MongoDBEngine:
428
777
  # Include the router in the app
429
778
  app.include_router(ws_router)
430
779
 
431
- print(
432
- f" Registered WebSocket route '{path}' for app '{slug}' using APIRouter"
433
- )
434
- print(
435
- f" Handler type: {type(handler).__name__}, Callable: {callable(handler)}"
436
- )
437
- print(
438
- f" Route name: {slug}_{endpoint_name}, Auth required: {require_auth}"
439
- )
780
+ print(f"✅ Registered WebSocket route '{path}' for app '{slug}' using APIRouter")
781
+ print(f" Handler type: {type(handler).__name__}, Callable: {callable(handler)}")
782
+ print(f" Route name: {slug}_{endpoint_name}, Auth required: {require_auth}")
440
783
  print(f" Route path: {path}, Full route count: {len(app.routes)}")
441
784
  contextual_logger.info(
442
785
  f"✅ Registered WebSocket route '{path}' for app '{slug}' "
@@ -459,9 +802,7 @@ class MongoDBEngine:
459
802
  "error": str(e),
460
803
  },
461
804
  )
462
- print(
463
- f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}"
464
- )
805
+ print(f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}")
465
806
  import traceback
466
807
 
467
808
  traceback.print_exc()
@@ -483,9 +824,7 @@ class MongoDBEngine:
483
824
  RuntimeError: If engine is not initialized.
484
825
  """
485
826
  if not self._app_registration_manager:
486
- raise RuntimeError(
487
- "MongoDBEngine not initialized. Call initialize() first."
488
- )
827
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
489
828
 
490
829
  return await self._app_registration_manager.reload_apps(
491
830
  register_app_callback=self.register_app
@@ -502,9 +841,7 @@ class MongoDBEngine:
502
841
  App manifest dict or None if not found
503
842
  """
504
843
  if not self._app_registration_manager:
505
- raise RuntimeError(
506
- "MongoDBEngine not initialized. Call initialize() first."
507
- )
844
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
508
845
  return self._app_registration_manager.get_app(slug)
509
846
 
510
847
  async def get_manifest(self, slug: str) -> Optional["ManifestDict"]:
@@ -518,21 +855,10 @@ class MongoDBEngine:
518
855
  App manifest dict or None if not found
519
856
  """
520
857
  if not self._app_registration_manager:
521
- raise RuntimeError(
522
- "MongoDBEngine not initialized. Call initialize() first."
523
- )
858
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
524
859
  return await self._app_registration_manager.get_manifest(slug)
525
860
 
526
- def get_database(self) -> AsyncIOMotorDatabase:
527
- """
528
- Get the MongoDB database instance.
529
-
530
- Returns:
531
- AsyncIOMotorDatabase instance
532
- """
533
- return self.mongo_db
534
-
535
- def get_memory_service(self, slug: str) -> Optional[Any]:
861
+ def get_memory_service(self, slug: str) -> Any | None:
536
862
  """
537
863
  Get Mem0 memory service for an app.
538
864
 
@@ -556,8 +882,32 @@ class MongoDBEngine:
556
882
  return self._service_initializer.get_memory_service(slug)
557
883
  return None
558
884
 
885
+ def get_embedding_service(self, slug: str) -> Any | None:
886
+ """
887
+ Get EmbeddingService for an app.
888
+
889
+ Auto-detects OpenAI or AzureOpenAI from environment variables.
890
+ Uses embedding_config from manifest.json if available.
891
+
892
+ Args:
893
+ slug: App slug
894
+
895
+ Returns:
896
+ EmbeddingService instance if embedding is enabled for this app, None otherwise
897
+
898
+ Example:
899
+ ```python
900
+ embedding_service = engine.get_embedding_service("my_app")
901
+ if embedding_service:
902
+ vectors = await embedding_service.embed_chunks(["Hello world"])
903
+ ```
904
+ """
905
+ from ..embeddings.dependencies import get_embedding_service_for_app
906
+
907
+ return get_embedding_service_for_app(slug, self)
908
+
559
909
  @property
560
- def _apps(self) -> Dict[str, Any]:
910
+ def _apps(self) -> dict[str, Any]:
561
911
  """
562
912
  Get the apps dictionary (for backward compatibility with tests).
563
913
 
@@ -568,12 +918,10 @@ class MongoDBEngine:
568
918
  RuntimeError: If engine is not initialized
569
919
  """
570
920
  if not self._app_registration_manager:
571
- raise RuntimeError(
572
- "MongoDBEngine not initialized. Call initialize() first."
573
- )
921
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
574
922
  return self._app_registration_manager._apps
575
923
 
576
- def list_apps(self) -> List[str]:
924
+ def list_apps(self) -> list[str]:
577
925
  """
578
926
  List all registered app slugs.
579
927
 
@@ -581,9 +929,7 @@ class MongoDBEngine:
581
929
  List of app slugs
582
930
  """
583
931
  if not self._app_registration_manager:
584
- raise RuntimeError(
585
- "MongoDBEngine not initialized. Call initialize() first."
586
- )
932
+ raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
587
933
  return self._app_registration_manager.list_apps()
588
934
 
589
935
  async def shutdown(self) -> None:
@@ -619,9 +965,9 @@ class MongoDBEngine:
619
965
 
620
966
  def __exit__(
621
967
  self,
622
- exc_type: Optional[type[BaseException]],
623
- exc_val: Optional[BaseException],
624
- exc_tb: Optional[Any],
968
+ exc_type: type[BaseException] | None,
969
+ exc_val: BaseException | None,
970
+ exc_tb: Any | None,
625
971
  ) -> None:
626
972
  """
627
973
  Context manager exit (synchronous).
@@ -652,9 +998,9 @@ class MongoDBEngine:
652
998
 
653
999
  async def __aexit__(
654
1000
  self,
655
- exc_type: Optional[type[BaseException]],
656
- exc_val: Optional[BaseException],
657
- exc_tb: Optional[Any],
1001
+ exc_type: type[BaseException] | None,
1002
+ exc_val: BaseException | None,
1003
+ exc_tb: Any | None,
658
1004
  ) -> None:
659
1005
  """
660
1006
  Async context manager exit.
@@ -668,7 +1014,7 @@ class MongoDBEngine:
668
1014
  """
669
1015
  await self.shutdown()
670
1016
 
671
- async def get_health_status(self) -> Dict[str, Any]:
1017
+ async def get_health_status(self) -> dict[str, Any]:
672
1018
  """
673
1019
  Get health status of the MongoDB Engine.
674
1020
 
@@ -693,20 +1039,12 @@ class MongoDBEngine:
693
1039
  # This follows MongoDB best practice: monitor the actual client
694
1040
  # being used
695
1041
  async def get_metrics():
696
- metrics = await get_pool_metrics(
697
- self._connection_manager.mongo_client
698
- )
1042
+ metrics = await get_pool_metrics(self._connection_manager.mongo_client)
699
1043
  # Add MongoDBEngine's pool configuration if not already in metrics
700
1044
  if metrics.get("status") == "connected":
701
- if (
702
- "max_pool_size" not in metrics
703
- or metrics.get("max_pool_size") is None
704
- ):
1045
+ if "max_pool_size" not in metrics or metrics.get("max_pool_size") is None:
705
1046
  metrics["max_pool_size"] = self.max_pool_size
706
- if (
707
- "min_pool_size" not in metrics
708
- or metrics.get("min_pool_size") is None
709
- ):
1047
+ if "min_pool_size" not in metrics or metrics.get("min_pool_size") is None:
710
1048
  metrics["min_pool_size"] = self.min_pool_size
711
1049
  return metrics
712
1050
 
@@ -719,8 +1057,10 @@ class MongoDBEngine:
719
1057
  usage = details.get("pool_usage_percent", 0)
720
1058
  if usage <= 90 and details.get("status") == "connected":
721
1059
  # Not critical, downgrade to degraded
722
- from ..observability.health import (HealthCheckResult,
723
- HealthStatus)
1060
+ from ..observability.health import (
1061
+ HealthCheckResult,
1062
+ HealthStatus,
1063
+ )
724
1064
 
725
1065
  return HealthCheckResult(
726
1066
  name=result.name,
@@ -736,7 +1076,7 @@ class MongoDBEngine:
736
1076
 
737
1077
  return await health_checker.check_all()
738
1078
 
739
- def get_metrics(self) -> Dict[str, Any]:
1079
+ def get_metrics(self) -> dict[str, Any]:
740
1080
  """
741
1081
  Get metrics for the MongoDB Engine.
742
1082
 
@@ -747,3 +1087,2410 @@ class MongoDBEngine:
747
1087
 
748
1088
  collector = get_metrics_collector()
749
1089
  return collector.get_summary()
1090
+
1091
+ # =========================================================================
1092
+ # FastAPI Integration Methods
1093
+ # =========================================================================
1094
+
1095
+ def create_app(
1096
+ self,
1097
+ slug: str,
1098
+ manifest: Path,
1099
+ title: str | None = None,
1100
+ on_startup: Callable[["FastAPI", "MongoDBEngine", dict[str, Any]], Awaitable[None]]
1101
+ | None = None,
1102
+ on_shutdown: Callable[["FastAPI", "MongoDBEngine", dict[str, Any]], Awaitable[None]]
1103
+ | None = None,
1104
+ is_sub_app: bool = False,
1105
+ **fastapi_kwargs: Any,
1106
+ ) -> "FastAPI":
1107
+ """
1108
+ Create a FastAPI application with proper lifespan management.
1109
+
1110
+ This method creates a FastAPI app that:
1111
+ 1. Initializes the engine on startup (unless is_sub_app=True)
1112
+ 2. Loads and registers the manifest
1113
+ 3. Auto-detects multi-site mode from manifest
1114
+ 4. Auto-configures auth based on manifest auth.mode:
1115
+ - "app" (default): Per-app token authentication
1116
+ - "shared": Shared user pool with SSO, auto-adds SharedAuthMiddleware
1117
+ 5. Auto-retrieves app tokens (for "app" mode)
1118
+ 6. Calls on_startup callback (if provided)
1119
+ 7. Shuts down the engine on shutdown (calls on_shutdown first if provided)
1120
+
1121
+ Args:
1122
+ slug: Application slug (must match manifest slug)
1123
+ manifest: Path to manifest.json file
1124
+ title: FastAPI app title. Defaults to app name from manifest
1125
+ on_startup: Optional async callback called after engine initialization.
1126
+ Signature: async def callback(app, engine, manifest) -> None
1127
+ on_shutdown: Optional async callback called before engine shutdown.
1128
+ Signature: async def callback(app, engine, manifest) -> None
1129
+ is_sub_app: If True, skip engine initialization and lifespan management.
1130
+ Used when mounting as a child app in create_multi_app().
1131
+ Defaults to False for backward compatibility.
1132
+ **fastapi_kwargs: Additional arguments passed to FastAPI()
1133
+
1134
+ Returns:
1135
+ Configured FastAPI application
1136
+
1137
+ Example:
1138
+ async def my_startup(app, engine, manifest):
1139
+ db = engine.get_scoped_db("my_app")
1140
+ await db.config.insert_one({"initialized": True})
1141
+
1142
+ engine = MongoDBEngine(mongo_uri=..., db_name=...)
1143
+ app = engine.create_app(
1144
+ slug="my_app",
1145
+ manifest=Path("manifest.json"),
1146
+ on_startup=my_startup,
1147
+ )
1148
+
1149
+ @app.get("/")
1150
+ async def index():
1151
+ db = engine.get_scoped_db("my_app")
1152
+ return {"status": "ok"}
1153
+
1154
+ Auth Modes (configured in manifest.json):
1155
+ # Per-app auth (default)
1156
+ {"auth": {"mode": "app"}}
1157
+
1158
+ # Shared user pool with SSO
1159
+ {"auth": {"mode": "shared", "roles": ["viewer", "editor", "admin"],
1160
+ "require_role": "viewer", "public_routes": ["/health"]}}
1161
+ """
1162
+ import json
1163
+
1164
+ from fastapi import FastAPI
1165
+
1166
+ engine = self
1167
+ manifest_path = Path(manifest)
1168
+
1169
+ # Pre-load manifest synchronously to detect auth mode BEFORE creating app
1170
+ # This allows us to add middleware at app creation time (before startup)
1171
+ with open(manifest_path) as f:
1172
+ pre_manifest = json.load(f)
1173
+
1174
+ # Extract auth configuration
1175
+ auth_config = pre_manifest.get("auth", {})
1176
+ auth_mode = auth_config.get("mode", "app")
1177
+
1178
+ # Determine title from pre-loaded manifest or slug
1179
+ app_title = title or pre_manifest.get("name", slug)
1180
+
1181
+ # State that will be populated during initialization
1182
+ app_manifest: dict[str, Any] = {}
1183
+ is_multi_site = False
1184
+
1185
+ @asynccontextmanager
1186
+ async def lifespan(app: FastAPI):
1187
+ """Lifespan context manager for initialization and cleanup."""
1188
+ nonlocal app_manifest, is_multi_site
1189
+
1190
+ # Initialize engine (skip if sub-app - parent manages lifecycle)
1191
+ if not is_sub_app:
1192
+ await engine.initialize()
1193
+
1194
+ # Load and register manifest
1195
+ app_manifest = await engine.load_manifest(manifest_path)
1196
+ await engine.register_app(app_manifest)
1197
+
1198
+ # Auto-detect multi-site mode from manifest
1199
+ data_access = app_manifest.get("data_access", {})
1200
+ read_scopes = data_access.get("read_scopes", [slug])
1201
+ cross_app_policy = data_access.get("cross_app_policy", "none")
1202
+
1203
+ # Multi-site if: cross_app_policy is "explicit" OR read_scopes has multiple apps
1204
+ is_multi_site = cross_app_policy == "explicit" or (
1205
+ len(read_scopes) > 1 and read_scopes != [slug]
1206
+ )
1207
+
1208
+ if is_multi_site:
1209
+ logger.info(
1210
+ f"Multi-site mode detected for '{slug}': "
1211
+ f"read_scopes={read_scopes}, cross_app_policy={cross_app_policy}"
1212
+ )
1213
+ else:
1214
+ logger.info(f"Single-app mode for '{slug}'")
1215
+
1216
+ # Handle auth based on mode
1217
+ if auth_mode == "shared":
1218
+ logger.info(f"Shared auth mode for '{slug}' - SSO enabled")
1219
+ # Initialize shared user pool and set on app.state
1220
+ # Middleware was already added at app creation time (lazy version)
1221
+ # For sub-apps, check if parent already initialized user pool
1222
+ if is_sub_app:
1223
+ # Check if parent app has user_pool (set by parent's initialization)
1224
+ # If not, initialize it (shouldn't happen, but handle gracefully)
1225
+ if not hasattr(app.state, "user_pool") or app.state.user_pool is None:
1226
+ logger.warning(
1227
+ f"Sub-app '{slug}' uses shared auth but user_pool not found. "
1228
+ "Initializing now (parent should have initialized it)."
1229
+ )
1230
+ await engine._initialize_shared_user_pool(app, app_manifest)
1231
+ else:
1232
+ logger.debug(f"Sub-app '{slug}' using shared user_pool from parent app")
1233
+ else:
1234
+ await engine._initialize_shared_user_pool(app, app_manifest)
1235
+ else:
1236
+ logger.info(f"Per-app auth mode for '{slug}'")
1237
+ # Auto-retrieve app token for "app" mode
1238
+ await engine.auto_retrieve_app_token(slug)
1239
+
1240
+ # Auto-initialize authorization provider from manifest config
1241
+ try:
1242
+ logger.info(
1243
+ f"🔍 Checking auth config for '{slug}': "
1244
+ f"auth_config keys={list(auth_config.keys())}"
1245
+ )
1246
+ auth_policy = auth_config.get("policy", {})
1247
+ logger.info(f"🔍 Auth policy for '{slug}': {auth_policy}")
1248
+ authz_provider_type = auth_policy.get("provider")
1249
+ logger.info(f"🔍 Authz provider type for '{slug}': {authz_provider_type}")
1250
+ except (KeyError, AttributeError, TypeError) as e:
1251
+ logger.exception(f"❌ Error reading auth config for '{slug}': {e}")
1252
+ authz_provider_type = None
1253
+
1254
+ if authz_provider_type == "oso":
1255
+ # Initialize OSO Cloud provider
1256
+ try:
1257
+ from ..auth.oso_factory import initialize_oso_from_manifest
1258
+
1259
+ authz_provider = await initialize_oso_from_manifest(engine, slug, app_manifest)
1260
+ if authz_provider:
1261
+ app.state.authz_provider = authz_provider
1262
+ logger.info(f"✅ OSO Cloud provider auto-initialized for '{slug}'")
1263
+ else:
1264
+ logger.warning(
1265
+ f"⚠️ OSO provider not initialized for '{slug}' - "
1266
+ "check OSO_AUTH and OSO_URL environment variables"
1267
+ )
1268
+ except ImportError as e:
1269
+ logger.warning(
1270
+ f"⚠️ OSO Cloud SDK not available for '{slug}': {e}. "
1271
+ "Install with: pip install oso-cloud"
1272
+ )
1273
+ except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
1274
+ logger.exception(f"❌ Failed to initialize OSO provider for '{slug}': {e}")
1275
+
1276
+ elif authz_provider_type == "casbin":
1277
+ # Initialize Casbin provider
1278
+ logger.info(f"🔧 Initializing Casbin provider for '{slug}'...")
1279
+ try:
1280
+ from ..auth.casbin_factory import initialize_casbin_from_manifest
1281
+
1282
+ logger.debug(f"Calling initialize_casbin_from_manifest for '{slug}'")
1283
+ authz_provider = await initialize_casbin_from_manifest(
1284
+ engine, slug, app_manifest
1285
+ )
1286
+ logger.debug(
1287
+ f"initialize_casbin_from_manifest returned: {authz_provider is not None}"
1288
+ )
1289
+ if authz_provider:
1290
+ app.state.authz_provider = authz_provider
1291
+ logger.info(
1292
+ f"✅ Casbin provider auto-initialized for '{slug}' "
1293
+ f"and set on app.state"
1294
+ )
1295
+ logger.info(
1296
+ f"✅ Provider type: {type(authz_provider).__name__}, "
1297
+ f"initialized: {getattr(authz_provider, '_initialized', 'unknown')}"
1298
+ )
1299
+ # Verify it's actually set
1300
+ if hasattr(app.state, "authz_provider") and app.state.authz_provider:
1301
+ logger.info("✅ Verified: app.state.authz_provider is set and not None")
1302
+ else:
1303
+ logger.error(
1304
+ "❌ CRITICAL: app.state.authz_provider was set but is now "
1305
+ "None or missing!"
1306
+ )
1307
+ else:
1308
+ logger.error(
1309
+ f"❌ Casbin provider initialization returned None for '{slug}' - "
1310
+ f"check logs above for errors"
1311
+ )
1312
+ logger.error(f"❌ This means authorization will NOT work for '{slug}'")
1313
+ except ImportError as e:
1314
+ # ImportError is expected if Casbin is not installed
1315
+ logger.warning(
1316
+ f"❌ Casbin not available for '{slug}': {e}. "
1317
+ "Install with: pip install mdb-engine[casbin]"
1318
+ )
1319
+ except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
1320
+ logger.exception(f"❌ Failed to initialize Casbin provider for '{slug}': {e}")
1321
+ # Informational message, not exception logging
1322
+ logger.error( # noqa: TRY400
1323
+ f"❌ This means authorization will NOT work for '{slug}' - "
1324
+ f"app.state.authz_provider will remain None"
1325
+ )
1326
+ except (
1327
+ RuntimeError,
1328
+ ValueError,
1329
+ AttributeError,
1330
+ TypeError,
1331
+ ConnectionError,
1332
+ OSError,
1333
+ ) as e:
1334
+ # Catch specific exceptions that might occur during initialization
1335
+ logger.exception(
1336
+ f"❌ Unexpected error initializing Casbin provider for '{slug}': {e}"
1337
+ )
1338
+ # Informational message, not exception logging
1339
+ logger.error( # noqa: TRY400
1340
+ f"❌ This means authorization will NOT work for '{slug}' - "
1341
+ f"app.state.authz_provider will remain None"
1342
+ )
1343
+
1344
+ elif authz_provider_type is None and auth_policy:
1345
+ # Default to Casbin if provider not specified but auth.policy exists
1346
+ logger.info(
1347
+ f"⚠️ No provider specified in auth.policy for '{slug}', "
1348
+ f"defaulting to Casbin"
1349
+ )
1350
+ try:
1351
+ from ..auth.casbin_factory import initialize_casbin_from_manifest
1352
+
1353
+ authz_provider = await initialize_casbin_from_manifest(
1354
+ engine, slug, app_manifest
1355
+ )
1356
+ if authz_provider:
1357
+ app.state.authz_provider = authz_provider
1358
+ logger.info(f"✅ Casbin provider auto-initialized for '{slug}' (default)")
1359
+ else:
1360
+ logger.warning(
1361
+ f"⚠️ Casbin provider not initialized for '{slug}' "
1362
+ f"(default attempt failed)"
1363
+ )
1364
+ except ImportError as e:
1365
+ logger.warning(
1366
+ f"⚠️ Casbin not available for '{slug}': {e}. "
1367
+ "Install with: pip install mdb-engine[casbin]"
1368
+ )
1369
+ except (
1370
+ ValueError,
1371
+ TypeError,
1372
+ RuntimeError,
1373
+ AttributeError,
1374
+ KeyError,
1375
+ ) as e:
1376
+ logger.exception(
1377
+ f"❌ Failed to initialize Casbin provider for '{slug}' (default): {e}"
1378
+ )
1379
+ elif authz_provider_type:
1380
+ logger.warning(
1381
+ f"⚠️ Unknown authz provider type '{authz_provider_type}' for '{slug}' - "
1382
+ f"skipping initialization"
1383
+ )
1384
+
1385
+ # Auto-seed demo users if configured in manifest
1386
+ users_config = auth_config.get("users", {})
1387
+ if users_config.get("enabled") and users_config.get("demo_users"):
1388
+ try:
1389
+ from ..auth import ensure_demo_users_exist
1390
+
1391
+ db = engine.get_scoped_db(slug)
1392
+ demo_users = await ensure_demo_users_exist(
1393
+ db=db,
1394
+ slug_id=slug,
1395
+ config=app_manifest,
1396
+ )
1397
+ if demo_users:
1398
+ logger.info(f"✅ Seeded {len(demo_users)} demo user(s) for '{slug}'")
1399
+ except (
1400
+ ImportError,
1401
+ ValueError,
1402
+ TypeError,
1403
+ RuntimeError,
1404
+ AttributeError,
1405
+ KeyError,
1406
+ ) as e:
1407
+ logger.warning(f"⚠️ Failed to seed demo users for '{slug}': {e}")
1408
+
1409
+ # Expose engine state on app.state
1410
+ app.state.engine = engine
1411
+ app.state.app_slug = slug
1412
+ app.state.manifest = app_manifest
1413
+ app.state.is_multi_site = is_multi_site
1414
+ app.state.auth_mode = auth_mode
1415
+ app.state.ray_actor = engine.ray_actor
1416
+
1417
+ # Initialize DI container (if not already set)
1418
+ from ..di import Container
1419
+
1420
+ if not hasattr(app.state, "container") or app.state.container is None:
1421
+ app.state.container = Container()
1422
+ logger.debug(f"DI Container initialized for '{slug}'")
1423
+
1424
+ # Call on_startup callback if provided
1425
+ if on_startup:
1426
+ try:
1427
+ await on_startup(app, engine, app_manifest)
1428
+ logger.info(f"on_startup callback completed for '{slug}'")
1429
+ except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
1430
+ logger.exception(f"on_startup callback failed for '{slug}': {e}")
1431
+ raise
1432
+
1433
+ yield
1434
+
1435
+ # Call on_shutdown callback if provided
1436
+ if on_shutdown:
1437
+ try:
1438
+ await on_shutdown(app, engine, app_manifest)
1439
+ logger.info(f"on_shutdown callback completed for '{slug}'")
1440
+ except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
1441
+ logger.warning(f"on_shutdown callback failed for '{slug}': {e}")
1442
+
1443
+ # Shutdown engine (skip if sub-app - parent manages lifecycle)
1444
+ if not is_sub_app:
1445
+ await engine.shutdown()
1446
+
1447
+ # Create FastAPI app
1448
+ app = FastAPI(title=app_title, lifespan=lifespan, **fastapi_kwargs)
1449
+
1450
+ # Add request scope middleware (innermost layer - runs first on request)
1451
+ # This sets up the DI request scope for each request
1452
+ from starlette.middleware.base import BaseHTTPMiddleware
1453
+
1454
+ from ..di import ScopeManager
1455
+
1456
+ class RequestScopeMiddleware(BaseHTTPMiddleware):
1457
+ """Middleware that manages request-scoped DI instances."""
1458
+
1459
+ async def dispatch(self, request, call_next):
1460
+ ScopeManager.begin_request()
1461
+ try:
1462
+ response = await call_next(request)
1463
+ return response
1464
+ finally:
1465
+ ScopeManager.end_request()
1466
+
1467
+ app.add_middleware(RequestScopeMiddleware)
1468
+ logger.debug(f"RequestScopeMiddleware added for '{slug}'")
1469
+
1470
+ # Add rate limiting middleware FIRST (outermost layer)
1471
+ # This ensures rate limiting happens before auth validation
1472
+ rate_limits_config = auth_config.get("rate_limits", {})
1473
+ if rate_limits_config or auth_mode == "shared":
1474
+ from ..auth.rate_limiter import create_rate_limit_middleware
1475
+
1476
+ rate_limit_middleware = create_rate_limit_middleware(
1477
+ manifest_auth=auth_config,
1478
+ )
1479
+ app.add_middleware(rate_limit_middleware)
1480
+ logger.info(
1481
+ f"AuthRateLimitMiddleware added for '{slug}' "
1482
+ f"(endpoints: {list(rate_limits_config.keys()) or 'defaults'})"
1483
+ )
1484
+
1485
+ # Add shared auth middleware (after rate limiting)
1486
+ # Uses lazy version that reads user_pool from app.state
1487
+ if auth_mode == "shared":
1488
+ from ..auth.shared_middleware import create_shared_auth_middleware_lazy
1489
+
1490
+ middleware_class = create_shared_auth_middleware_lazy(
1491
+ app_slug=slug,
1492
+ manifest_auth=auth_config,
1493
+ )
1494
+ app.add_middleware(middleware_class)
1495
+ logger.info(
1496
+ f"LazySharedAuthMiddleware added for '{slug}' "
1497
+ f"(require_role={auth_config.get('require_role')})"
1498
+ )
1499
+
1500
+ # Add CSRF middleware (after auth - auto-enabled for shared mode)
1501
+ # CSRF protection is enabled by default for shared auth mode
1502
+ # SKIP for sub-apps in multi-app setups - parent app handles CSRF
1503
+ csrf_config = auth_config.get("csrf_protection", True if auth_mode == "shared" else False)
1504
+ if csrf_config and not is_sub_app: # Don't add CSRF to child apps
1505
+ from ..auth.csrf import create_csrf_middleware
1506
+
1507
+ csrf_middleware = create_csrf_middleware(
1508
+ manifest_auth=auth_config,
1509
+ )
1510
+ app.add_middleware(csrf_middleware)
1511
+ logger.info(f"CSRFMiddleware added for '{slug}'")
1512
+ elif csrf_config and is_sub_app:
1513
+ logger.debug(
1514
+ f"CSRFMiddleware skipped for child app '{slug}' - "
1515
+ f"parent app handles CSRF protection for WebSocket routes"
1516
+ )
1517
+
1518
+ # Add security middleware (HSTS, headers)
1519
+ security_config = auth_config.get("security", {})
1520
+ hsts_config = security_config.get("hsts", {})
1521
+ if hsts_config.get("enabled", True) or auth_mode == "shared":
1522
+ from ..auth.middleware import SecurityMiddleware
1523
+
1524
+ app.add_middleware(
1525
+ SecurityMiddleware,
1526
+ require_https=False, # HSTS handles this in production
1527
+ csrf_protection=False, # Handled by CSRFMiddleware above
1528
+ security_headers=True,
1529
+ hsts_config=hsts_config,
1530
+ )
1531
+ logger.info(f"SecurityMiddleware added for '{slug}'")
1532
+
1533
+ logger.debug(f"FastAPI app created for '{slug}'")
1534
+
1535
+ return app
1536
+
1537
+ def _validate_path_prefixes(self, apps: list[dict[str, Any]]) -> tuple[bool, list[str]]:
1538
+ """
1539
+ Validate path prefixes for multi-app mounting.
1540
+
1541
+ Checks:
1542
+ - All prefixes start with '/'
1543
+ - No prefix is a prefix of another (e.g., '/app' conflicts with '/app/v2')
1544
+ - No conflicts with reserved paths ('/health', '/docs', '/openapi.json', '/_mdb')
1545
+ - Slug matches manifest slug (if manifest is readable)
1546
+
1547
+ Args:
1548
+ apps: List of app configs with 'path_prefix' keys
1549
+
1550
+ Returns:
1551
+ Tuple of (is_valid, list_of_errors)
1552
+ """
1553
+
1554
+ errors: list[str] = []
1555
+ reserved_paths = {"/health", "/docs", "/openapi.json", "/redoc", "/_mdb"}
1556
+
1557
+ # Extract path prefixes
1558
+ path_prefixes: list[str] = []
1559
+ for app_config in apps:
1560
+ slug = app_config.get("slug", "unknown")
1561
+ path_prefix = app_config.get("path_prefix", f"/{slug}")
1562
+
1563
+ if not path_prefix.startswith("/"):
1564
+ errors.append(f"Path prefix '{path_prefix}' must start with '/' (app: '{slug}')")
1565
+ continue
1566
+
1567
+ # Check for common mistakes
1568
+ if path_prefix.endswith("/") and path_prefix != "/":
1569
+ logger.warning(
1570
+ f"Path prefix '{path_prefix}' ends with '/'. "
1571
+ f"Consider removing trailing slash for app '{slug}'"
1572
+ )
1573
+
1574
+ path_prefixes.append(path_prefix)
1575
+
1576
+ # Check for conflicts with reserved paths
1577
+ for prefix in path_prefixes:
1578
+ if prefix in reserved_paths:
1579
+ errors.append(
1580
+ f"Path prefix '{prefix}' conflicts with reserved path. "
1581
+ "Reserved paths: /health, /docs, /openapi.json, /redoc, /_mdb"
1582
+ )
1583
+
1584
+ # Check for prefix conflicts (one prefix being a prefix of another)
1585
+ path_prefixes_sorted = sorted(path_prefixes)
1586
+ for i, prefix1 in enumerate(path_prefixes_sorted):
1587
+ for prefix2 in path_prefixes_sorted[i + 1 :]:
1588
+ # Normalize by ensuring both end with / for comparison
1589
+ p1_norm = prefix1 if prefix1.endswith("/") else prefix1 + "/"
1590
+ p2_norm = prefix2 if prefix2.endswith("/") else prefix2 + "/"
1591
+
1592
+ if p1_norm.startswith(p2_norm) or p2_norm.startswith(p1_norm):
1593
+ # Find which apps these belong to for better error message
1594
+ app1_slug = next(
1595
+ (a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix1),
1596
+ "unknown",
1597
+ )
1598
+ app2_slug = next(
1599
+ (a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix2),
1600
+ "unknown",
1601
+ )
1602
+ errors.append(
1603
+ f"Path prefix conflict: '{prefix1}' (app: '{app1_slug}') and "
1604
+ f"'{prefix2}' (app: '{app2_slug}') overlap. "
1605
+ "One cannot be a prefix of another."
1606
+ )
1607
+
1608
+ # Check for duplicates
1609
+ if len(path_prefixes) != len(set(path_prefixes)):
1610
+ seen = {}
1611
+ for app_config in apps:
1612
+ prefix = app_config.get("path_prefix")
1613
+ slug = app_config.get("slug", "unknown")
1614
+ if prefix in seen:
1615
+ first_slug = seen[prefix]
1616
+ errors.append(
1617
+ f"Duplicate path prefix: '{prefix}' used by both "
1618
+ f"'{first_slug}' and '{slug}'"
1619
+ )
1620
+ else:
1621
+ seen[prefix] = slug
1622
+
1623
+ return len(errors) == 0, errors
1624
+
1625
+ def _discover_apps_from_directory(
1626
+ self,
1627
+ apps_dir: Path,
1628
+ path_prefix_template: str | None = None,
1629
+ ) -> list[dict[str, Any]]:
1630
+ """
1631
+ Auto-discover apps by scanning directory for manifest.json files.
1632
+
1633
+ Args:
1634
+ apps_dir: Directory to scan for apps
1635
+ path_prefix_template: Template for path prefixes (e.g., "/app-{index}")
1636
+
1637
+ Returns:
1638
+ List of app configurations
1639
+ """
1640
+ import json
1641
+
1642
+ apps_dir = Path(apps_dir)
1643
+ if not apps_dir.exists():
1644
+ raise ValueError(f"Apps directory does not exist: {apps_dir}")
1645
+
1646
+ discovered_apps = []
1647
+ manifest_files = list(apps_dir.rglob("manifest.json"))
1648
+
1649
+ if not manifest_files:
1650
+ raise ValueError(f"No manifest.json files found in {apps_dir}")
1651
+
1652
+ for idx, manifest_path in enumerate(sorted(manifest_files), start=1):
1653
+ try:
1654
+ with open(manifest_path) as f:
1655
+ manifest_data = json.load(f)
1656
+
1657
+ slug = manifest_data.get("slug")
1658
+ if not slug:
1659
+ logger.warning(f"Skipping manifest without slug: {manifest_path}")
1660
+ continue
1661
+
1662
+ # Generate path prefix
1663
+ if path_prefix_template:
1664
+ path_prefix = path_prefix_template.format(index=idx, slug=slug)
1665
+ else:
1666
+ path_prefix = f"/{slug}"
1667
+
1668
+ discovered_apps.append(
1669
+ {
1670
+ "slug": slug,
1671
+ "manifest": manifest_path,
1672
+ "path_prefix": path_prefix,
1673
+ }
1674
+ )
1675
+ logger.info(
1676
+ f"Discovered app '{slug}' at {manifest_path} " f"(will mount at {path_prefix})"
1677
+ )
1678
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
1679
+ logger.warning(f"Failed to read manifest at {manifest_path}: {e}")
1680
+ continue
1681
+
1682
+ if not discovered_apps:
1683
+ raise ValueError(f"No valid apps discovered in {apps_dir}")
1684
+
1685
+ return discovered_apps
1686
+
1687
+ def _validate_manifests(self, apps: list[dict[str, Any]], strict: bool) -> list[str]:
1688
+ """Validate all app manifests."""
1689
+ import json
1690
+
1691
+ logger.info("Validating all manifests before mounting...")
1692
+ validation_errors = []
1693
+ for app_config in apps:
1694
+ slug = app_config.get("slug", "unknown")
1695
+ manifest_path = app_config.get("manifest")
1696
+ try:
1697
+ with open(manifest_path) as f:
1698
+ manifest_data = json.load(f)
1699
+
1700
+ # Validate manifest
1701
+ from .manifest import validate_manifest
1702
+
1703
+ is_valid, error_msg, error_paths = validate_manifest(manifest_data)
1704
+
1705
+ if not is_valid:
1706
+ error_detail = f"App '{slug}' at {manifest_path}: {error_msg}"
1707
+ if error_paths:
1708
+ error_detail += f" (paths: {', '.join(error_paths)})"
1709
+ validation_errors.append(error_detail)
1710
+ if strict:
1711
+ raise ValueError(
1712
+ f"Manifest validation failed for app '{slug}': {error_msg}"
1713
+ ) from None
1714
+
1715
+ # Validate slug matches manifest slug
1716
+ manifest_slug = manifest_data.get("slug")
1717
+ if manifest_slug and manifest_slug != slug:
1718
+ error_msg = (
1719
+ f"Slug mismatch: config slug '{slug}' does not match "
1720
+ f"manifest slug '{manifest_slug}' in {manifest_path}"
1721
+ )
1722
+ validation_errors.append(error_msg)
1723
+ if strict:
1724
+ raise ValueError(error_msg) from None
1725
+ except FileNotFoundError as e:
1726
+ error_msg = f"Manifest file not found for app '{slug}': {manifest_path}"
1727
+ validation_errors.append(error_msg)
1728
+ if strict:
1729
+ raise ValueError(error_msg) from e
1730
+ except json.JSONDecodeError as e:
1731
+ error_msg = f"Invalid JSON in manifest for app '{slug}' at {manifest_path}: {e}"
1732
+ validation_errors.append(error_msg)
1733
+ if strict:
1734
+ raise ValueError(error_msg) from e
1735
+
1736
+ return validation_errors
1737
+
1738
+ def _import_app_routes(self, child_app: "FastAPI", manifest_path: Path, slug: str) -> None:
1739
+ """
1740
+ Automatically discover and import route modules for a child app.
1741
+
1742
+ This method looks for route modules (web.py, routes.py) in the same directory
1743
+ as the manifest and imports them so that route decorators are executed and
1744
+ routes are registered on the child app.
1745
+
1746
+ Args:
1747
+ child_app: The FastAPI child app to register routes on
1748
+ manifest_path: Path to the manifest.json file
1749
+ slug: App slug for logging
1750
+
1751
+ The method tries multiple strategies:
1752
+ 1. Look for 'web.py' in the manifest directory
1753
+ 2. Look for 'routes.py' in the manifest directory
1754
+ 3. Check manifest for explicit 'routes_module' field (future support)
1755
+
1756
+ When importing, the method ensures that route decorators in the imported module
1757
+ reference the child_app by temporarily injecting it into the module namespace.
1758
+ """
1759
+ import importlib.util
1760
+ import sys
1761
+
1762
+ manifest_dir = manifest_path.parent
1763
+
1764
+ # Try to find route modules in order of preference
1765
+ route_module_paths = [
1766
+ manifest_dir / "web.py",
1767
+ manifest_dir / "routes.py",
1768
+ ]
1769
+
1770
+ # Also check for routes_module in manifest (future support)
1771
+ try:
1772
+ import json
1773
+
1774
+ with open(manifest_path) as f:
1775
+ manifest_data = json.load(f)
1776
+ routes_module = manifest_data.get("routes_module")
1777
+ if routes_module:
1778
+ # Support both relative (to manifest dir) and absolute paths
1779
+ if routes_module.startswith("/"):
1780
+ route_module_paths.insert(0, Path(routes_module))
1781
+ else:
1782
+ route_module_paths.insert(0, manifest_dir / routes_module)
1783
+ except (FileNotFoundError, json.JSONDecodeError, KeyError):
1784
+ pass
1785
+
1786
+ imported = False
1787
+ module_name = None
1788
+ route_module = None
1789
+ manifest_dir_str = None
1790
+ path_inserted = False
1791
+
1792
+ for route_module_path in route_module_paths:
1793
+ if not route_module_path.exists():
1794
+ continue
1795
+
1796
+ # Create a unique module name to avoid conflicts
1797
+ module_name = f"mdb_engine_imported_routes_{slug}_{id(child_app)}"
1798
+
1799
+ try:
1800
+ # Validate file is actually a Python file
1801
+ if not route_module_path.suffix == ".py":
1802
+ logger.debug(f"Skipping non-Python file '{route_module_path}' for app '{slug}'")
1803
+ continue
1804
+
1805
+ # Load the module spec
1806
+ spec = importlib.util.spec_from_file_location(module_name, route_module_path)
1807
+ if spec is None or spec.loader is None:
1808
+ logger.warning(
1809
+ f"Could not create spec for route module '{route_module_path}' "
1810
+ f"for app '{slug}'"
1811
+ )
1812
+ continue
1813
+
1814
+ route_module = importlib.util.module_from_spec(spec)
1815
+
1816
+ # CRITICAL: Inject child_app into module namespace BEFORE loading
1817
+ # This ensures that @app.get(), @app.post(), etc. decorators in the
1818
+ # imported module will reference our child_app instead of creating a new one
1819
+ route_module.app = child_app
1820
+ route_module.engine = self # Also provide engine reference for dependencies
1821
+
1822
+ # Add to sys.modules temporarily to handle relative imports
1823
+ # Use a try-finally to ensure cleanup even on exceptions
1824
+ sys.modules[module_name] = route_module
1825
+
1826
+ # Store route count before import
1827
+ routes_before = len(child_app.routes)
1828
+
1829
+ # Add manifest directory to Python path temporarily for relative imports
1830
+ # This allows route modules to import sibling modules
1831
+ manifest_dir_str = str(manifest_dir.resolve())
1832
+ path_inserted = manifest_dir_str not in sys.path
1833
+ if path_inserted:
1834
+ sys.path.insert(0, manifest_dir_str)
1835
+
1836
+ try:
1837
+ # Execute the module (runs route decorators with injected app)
1838
+ spec.loader.exec_module(route_module)
1839
+ except SyntaxError as e:
1840
+ logger.warning(
1841
+ f"Syntax error in route module '{route_module_path}' "
1842
+ f"for app '{slug}': {e}. Skipping this module."
1843
+ )
1844
+ continue
1845
+ except ImportError as e:
1846
+ # ImportError might be due to missing dependencies - log but don't fail
1847
+ logger.debug(
1848
+ f"Import error in route module '{route_module_path}' "
1849
+ f"for app '{slug}': {e}. "
1850
+ "This may be OK if dependencies are optional."
1851
+ )
1852
+ # Check if it's a critical import (like FastAPI) vs optional dependency
1853
+ error_str = str(e).lower()
1854
+ if "fastapi" in error_str or "starlette" in error_str:
1855
+ logger.warning(
1856
+ f"Route module '{route_module_path}' for app '{slug}' "
1857
+ "requires FastAPI/Starlette but they're not available. "
1858
+ "Routes will not be registered."
1859
+ )
1860
+ continue
1861
+ finally:
1862
+ # Remove from path only if we added it
1863
+ if path_inserted and manifest_dir_str and manifest_dir_str in sys.path:
1864
+ try:
1865
+ sys.path.remove(manifest_dir_str)
1866
+ except ValueError:
1867
+ # Path might have been removed already - ignore
1868
+ pass
1869
+
1870
+ # Check if module overwrote app (shouldn't happen in well-structured modules)
1871
+ module_app = getattr(route_module, "app", None)
1872
+ if module_app is not None and module_app is not child_app:
1873
+ import warnings
1874
+
1875
+ warning_msg = (
1876
+ f"Route module '{route_module_path.name}' for app '{slug}' "
1877
+ "created its own app instance. Routes defined before app creation "
1878
+ "are registered, but routes defined after may not be. "
1879
+ "Consider restructuring the module to use the injected 'app' variable."
1880
+ )
1881
+ logger.warning(warning_msg)
1882
+ warnings.warn(warning_msg, UserWarning, stacklevel=2)
1883
+
1884
+ routes_after = len(child_app.routes)
1885
+ routes_added = routes_after - routes_before
1886
+
1887
+ if routes_added > 0:
1888
+ logger.info(
1889
+ f"✅ Auto-imported routes from '{route_module_path.name}' "
1890
+ f"for app '{slug}'. Added {routes_added} route(s) "
1891
+ f"(total: {routes_after})"
1892
+ )
1893
+ else:
1894
+ logger.debug(
1895
+ f"Route module '{route_module_path.name}' for app '{slug}' "
1896
+ "was imported but no new routes were registered. "
1897
+ "This may be expected if routes are registered conditionally."
1898
+ )
1899
+
1900
+ imported = True
1901
+ break
1902
+
1903
+ except (ValueError, TypeError, AttributeError, RuntimeError, OSError) as e:
1904
+ logger.warning(
1905
+ f"Unexpected error importing route module '{route_module_path}' "
1906
+ f"for app '{slug}': {e}",
1907
+ exc_info=True,
1908
+ )
1909
+ continue
1910
+ finally:
1911
+ # Clean up temporary module from sys.modules
1912
+ if module_name and module_name in sys.modules:
1913
+ try:
1914
+ del sys.modules[module_name]
1915
+ except KeyError:
1916
+ # Already removed - ignore
1917
+ pass
1918
+ # Ensure path is cleaned up even if exception occurred
1919
+ if path_inserted and manifest_dir_str and manifest_dir_str in sys.path:
1920
+ try:
1921
+ sys.path.remove(manifest_dir_str)
1922
+ except ValueError:
1923
+ pass
1924
+
1925
+ if not imported:
1926
+ logger.debug(
1927
+ f"No route modules found for app '{slug}' in {manifest_dir}. "
1928
+ "Routes may be defined elsewhere or app may not have HTTP routes."
1929
+ )
1930
+
1931
+ def create_multi_app( # noqa: C901
1932
+ self,
1933
+ apps: list[dict[str, Any]] | None = None,
1934
+ multi_app_manifest: Path | None = None,
1935
+ apps_dir: Path | None = None,
1936
+ path_prefix_template: str | None = None,
1937
+ validate: bool = False,
1938
+ strict: bool = False,
1939
+ title: str = "Multi-App API",
1940
+ root_path: str = "",
1941
+ **fastapi_kwargs: Any,
1942
+ ) -> "FastAPI":
1943
+ """
1944
+ Create a parent FastAPI app that mounts multiple child apps.
1945
+
1946
+ Each child app is mounted at a path prefix (e.g., /auth-hub, /pwd-zero) and
1947
+ maintains its own routes, middleware, and state while sharing the engine instance.
1948
+
1949
+ Args:
1950
+ apps: List of app configurations. Each dict should have:
1951
+ - slug: App slug (required)
1952
+ - manifest: Path to manifest.json (required)
1953
+ - path_prefix: Optional path prefix (defaults to /{slug})
1954
+ - on_startup: Optional startup callback function
1955
+ - on_shutdown: Optional shutdown callback function
1956
+ multi_app_manifest: Path to a multi-app manifest.json that defines all apps.
1957
+ Format:
1958
+ {
1959
+ "multi_app": {
1960
+ "enabled": true,
1961
+ "apps": [
1962
+ {
1963
+ "slug": "app1",
1964
+ "manifest": "./app1/manifest.json",
1965
+ "path_prefix": "/app1",
1966
+ }
1967
+ ]
1968
+ }
1969
+ }
1970
+ apps_dir: Directory to scan for apps (auto-discovery). If provided and
1971
+ apps is None, will recursively scan for manifest.json files and
1972
+ auto-discover apps. Takes precedence over multi_app_manifest.
1973
+ path_prefix_template: Template for auto-generated path prefixes when using
1974
+ apps_dir. Use {index} for app index and {slug} for app slug.
1975
+ Example: "/app-{index}" or "/{slug}"
1976
+ validate: If True, validate all manifests before mounting (default: False)
1977
+ strict: If True, fail fast on any validation error (default: False).
1978
+ Only used when validate=True.
1979
+ title: Title for the parent FastAPI app
1980
+ root_path: Root path prefix for all mounted apps (optional)
1981
+ **fastapi_kwargs: Additional arguments passed to FastAPI()
1982
+
1983
+ Returns:
1984
+ Parent FastAPI application with all child apps mounted
1985
+
1986
+ Raises:
1987
+ ValueError: If configuration is invalid or path prefixes conflict
1988
+ RuntimeError: If engine is not initialized
1989
+
1990
+ Features:
1991
+ - Built-in app context helpers: Each mounted app has access to:
1992
+ - request.state.app_base_path: Path prefix (e.g., "/app-1")
1993
+ - request.state.auth_hub_url: Auth hub URL from manifest or env
1994
+ - request.state.app_slug: App slug
1995
+ - request.state.mounted_apps: Dict of all mounted apps with paths
1996
+ - request.state.engine: MongoDBEngine instance
1997
+ - request.state.manifest: App's manifest.json
1998
+ - Unified health check: GET /health aggregates health from all apps
1999
+ - Route introspection: GET /_mdb/routes lists all routes from all apps
2000
+ - OpenAPI aggregation: /docs combines docs from all apps
2001
+ - Per-app docs: /docs/{app_slug} for individual app documentation
2002
+
2003
+ Example:
2004
+ # Programmatic approach
2005
+ engine = MongoDBEngine(mongo_uri=..., db_name=...)
2006
+ app = engine.create_multi_app(
2007
+ apps=[
2008
+ {
2009
+ "slug": "auth-hub",
2010
+ "manifest": Path("./auth-hub/manifest.json"),
2011
+ "path_prefix": "/auth-hub",
2012
+ },
2013
+ {
2014
+ "slug": "pwd-zero",
2015
+ "manifest": Path("./pwd-zero/manifest.json"),
2016
+ "path_prefix": "/pwd-zero",
2017
+ },
2018
+ ]
2019
+ )
2020
+
2021
+ # Manifest-based approach
2022
+ app = engine.create_multi_app(
2023
+ multi_app_manifest=Path("./multi_app_manifest.json")
2024
+ )
2025
+
2026
+ # Auto-discovery approach
2027
+ app = engine.create_multi_app(
2028
+ apps_dir=Path("./apps"),
2029
+ path_prefix_template="/app-{index}",
2030
+ validate=True,
2031
+ )
2032
+
2033
+ # Access app context in routes
2034
+ @app.get("/my-route")
2035
+ async def my_route(request: Request):
2036
+ base_path = request.state.app_base_path # "/app-1"
2037
+ auth_url = request.state.auth_hub_url # "/auth-hub"
2038
+ slug = request.state.app_slug # "my-app"
2039
+ all_apps = request.state.mounted_apps # Dict of all apps
2040
+ """
2041
+ import json
2042
+
2043
+ from fastapi import FastAPI
2044
+
2045
+ engine = self
2046
+
2047
+ # Auto-discovery: if apps_dir is provided and apps is None, discover apps
2048
+ if apps_dir and apps is None:
2049
+ logger.info(f"Auto-discovering apps from directory: {apps_dir}")
2050
+ apps = self._discover_apps_from_directory(
2051
+ apps_dir=apps_dir,
2052
+ path_prefix_template=path_prefix_template,
2053
+ )
2054
+
2055
+ # Load configuration from manifest or apps parameter
2056
+ if multi_app_manifest:
2057
+ manifest_path = Path(multi_app_manifest)
2058
+ with open(manifest_path) as f:
2059
+ multi_app_config = json.load(f)
2060
+
2061
+ multi_app_section = multi_app_config.get("multi_app", {})
2062
+ if not multi_app_section.get("enabled", False):
2063
+ raise ValueError(
2064
+ "multi_app.enabled must be True in multi_app_manifest to use multi-app mode"
2065
+ )
2066
+
2067
+ apps_config = multi_app_section.get("apps", [])
2068
+ if not apps_config:
2069
+ raise ValueError("multi_app.apps must contain at least one app")
2070
+
2071
+ # Resolve manifest paths relative to multi_app_manifest location
2072
+ manifest_dir = manifest_path.parent
2073
+ apps = []
2074
+ for app_config in apps_config:
2075
+ manifest_rel_path = app_config.get("manifest")
2076
+ if not manifest_rel_path:
2077
+ raise ValueError(f"App '{app_config.get('slug')}' missing 'manifest' field")
2078
+
2079
+ # Resolve relative to multi_app_manifest location
2080
+ manifest_full_path = (manifest_dir / manifest_rel_path).resolve()
2081
+ slug = app_config.get("slug")
2082
+ path_prefix = app_config.get("path_prefix", f"/{slug}")
2083
+
2084
+ apps.append(
2085
+ {
2086
+ "slug": slug,
2087
+ "manifest": manifest_full_path,
2088
+ "path_prefix": path_prefix,
2089
+ }
2090
+ )
2091
+
2092
+ elif apps is not None:
2093
+ apps_config = apps
2094
+ # Convert Path objects to Path if they're strings
2095
+ apps = []
2096
+ for app_config in apps_config:
2097
+ manifest = app_config.get("manifest")
2098
+ if isinstance(manifest, str):
2099
+ manifest = Path(manifest)
2100
+ apps.append(
2101
+ {
2102
+ "slug": app_config.get("slug"),
2103
+ "manifest": manifest,
2104
+ "path_prefix": app_config.get("path_prefix", f"/{app_config.get('slug')}"),
2105
+ "on_startup": app_config.get("on_startup"),
2106
+ "on_shutdown": app_config.get("on_shutdown"),
2107
+ }
2108
+ )
2109
+ else:
2110
+ raise ValueError("Either 'apps', 'multi_app_manifest', or 'apps_dir' must be provided")
2111
+
2112
+ if not apps:
2113
+ raise ValueError("At least one app must be configured")
2114
+
2115
+ # Validate manifests if requested
2116
+ if validate:
2117
+ validation_errors = self._validate_manifests(apps, strict)
2118
+ if validation_errors:
2119
+ logger.warning(
2120
+ "Manifest validation found issues:\n"
2121
+ + "\n".join(f" - {e}" for e in validation_errors)
2122
+ )
2123
+ if strict:
2124
+ raise ValueError(
2125
+ "Manifest validation failed (strict mode):\n"
2126
+ + "\n".join(f" - {e}" for e in validation_errors)
2127
+ )
2128
+
2129
+ # Validate path prefixes (enhanced)
2130
+ is_valid, errors = self._validate_path_prefixes(apps)
2131
+ if not is_valid:
2132
+ raise ValueError(
2133
+ "Path prefix validation failed:\n" + "\n".join(f" - {e}" for e in errors)
2134
+ )
2135
+
2136
+ # Check if any app uses shared auth and collect public routes for CSRF exemption
2137
+ has_shared_auth = False
2138
+ all_public_routes = [
2139
+ "/health",
2140
+ "/docs",
2141
+ "/openapi.json",
2142
+ "/_mdb/routes",
2143
+ ] # Base exempt routes
2144
+ for app_config in apps:
2145
+ try:
2146
+ manifest_path = app_config["manifest"]
2147
+ path_prefix = app_config.get("path_prefix", f"/{app_config.get('slug')}")
2148
+ with open(manifest_path) as f:
2149
+ app_manifest_pre = json.load(f)
2150
+ auth_config = app_manifest_pre.get("auth", {})
2151
+ if auth_config.get("mode") == "shared":
2152
+ has_shared_auth = True
2153
+ # Collect public routes with path prefix for CSRF exemption
2154
+ child_public_routes = auth_config.get("public_routes", [])
2155
+ for route in child_public_routes:
2156
+ # Add path prefix to make route absolute on parent app
2157
+ if route.startswith("/"):
2158
+ prefixed_route = f"{path_prefix.rstrip('/')}{route}"
2159
+ else:
2160
+ prefixed_route = f"{path_prefix.rstrip('/')}/{route}"
2161
+ if prefixed_route not in all_public_routes:
2162
+ all_public_routes.append(prefixed_route)
2163
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
2164
+ logger.warning(f"Could not check auth mode for app '{app_config.get('slug')}': {e}")
2165
+
2166
+ # Validate hooks before creating lifespan (fail fast)
2167
+ for app_config in apps:
2168
+ slug = app_config.get("slug", "unknown")
2169
+ on_startup = app_config.get("on_startup")
2170
+ on_shutdown = app_config.get("on_shutdown")
2171
+
2172
+ if on_startup is not None and not callable(on_startup):
2173
+ raise ValueError(
2174
+ f"on_startup hook for app '{slug}' must be callable, "
2175
+ f"got {type(on_startup).__name__}"
2176
+ )
2177
+ if on_shutdown is not None and not callable(on_shutdown):
2178
+ raise ValueError(
2179
+ f"on_shutdown hook for app '{slug}' must be callable, "
2180
+ f"got {type(on_shutdown).__name__}"
2181
+ )
2182
+
2183
+ # State for parent app
2184
+ # Build initial mounted_apps metadata synchronously so get_mounted_apps() works
2185
+ # immediately after create_multi_app() returns (before lifespan runs)
2186
+ mounted_apps: list[dict[str, Any]] = [
2187
+ {
2188
+ "slug": app_config["slug"],
2189
+ "path_prefix": app_config["path_prefix"],
2190
+ "status": "pending", # Will be updated in lifespan to "mounted" or "failed"
2191
+ "manifest_path": str(app_config["manifest"]),
2192
+ }
2193
+ for app_config in apps
2194
+ ]
2195
+ shared_user_pool_initialized = False
2196
+
2197
+ def _find_mounted_app_entry(slug: str) -> dict[str, Any] | None:
2198
+ """Find mounted app entry by slug."""
2199
+ for entry in mounted_apps:
2200
+ if entry.get("slug") == slug:
2201
+ return entry
2202
+ return None
2203
+
2204
+ async def _merge_cors_config_to_parent(
2205
+ parent_app: "FastAPI",
2206
+ child_app: "FastAPI",
2207
+ child_manifest: dict[str, Any],
2208
+ slug: str,
2209
+ ) -> None:
2210
+ """Merge CORS config from child app to parent app."""
2211
+ child_cors = None
2212
+ if hasattr(child_app.state, "cors_config"):
2213
+ child_cors = child_app.state.cors_config
2214
+ else:
2215
+ # CORS config might not be set yet (lifespan runs asynchronously)
2216
+ # Get it from manifest directly
2217
+ cors_config_from_manifest = child_manifest.get("cors", {})
2218
+ if cors_config_from_manifest:
2219
+ from ..auth.config_helpers import (
2220
+ CORS_DEFAULTS,
2221
+ merge_config_with_defaults,
2222
+ )
2223
+
2224
+ child_cors = merge_config_with_defaults(
2225
+ cors_config_from_manifest, CORS_DEFAULTS
2226
+ )
2227
+ # Also set it on child app state for future reference
2228
+ child_app.state.cors_config = child_cors
2229
+
2230
+ if child_cors:
2231
+ if hasattr(parent_app.state, "cors_config"):
2232
+ # Merge child CORS into parent (child takes precedence for its routes)
2233
+ parent_cors = parent_app.state.cors_config
2234
+ # Merge allow_origins lists
2235
+ child_origins = child_cors.get("allow_origins", [])
2236
+ parent_origins = parent_cors.get("allow_origins", [])
2237
+
2238
+ # CRITICAL: Handle wildcard origins correctly
2239
+ # If any child or parent has wildcard, merged config gets wildcard
2240
+ if "*" in child_origins:
2241
+ merged_origins = ["*"] # Wildcard takes precedence
2242
+ elif "*" in parent_origins:
2243
+ merged_origins = ["*"] # Keep wildcard if already set
2244
+ else:
2245
+ # Merge unique origins
2246
+ merged_origins = list(set(parent_origins + child_origins))
2247
+ if not merged_origins:
2248
+ merged_origins = ["*"] # Default to wildcard if empty
2249
+
2250
+ # CRITICAL: If ANY child app requires credentials, parent must allow them
2251
+ # This is essential for SSO cookie-based authentication
2252
+ child_requires_credentials = child_cors.get("allow_credentials", False)
2253
+ parent_allows_credentials = parent_cors.get("allow_credentials", False)
2254
+ merged_allow_credentials = (
2255
+ child_requires_credentials or parent_allows_credentials
2256
+ )
2257
+
2258
+ parent_app.state.cors_config = {
2259
+ **parent_cors,
2260
+ **child_cors,
2261
+ "allow_origins": merged_origins,
2262
+ # If ANY child requires credentials, parent gets True (for SSO)
2263
+ "allow_credentials": merged_allow_credentials,
2264
+ }
2265
+ else:
2266
+ # Parent has no CORS config, use child's
2267
+ parent_app.state.cors_config = child_cors
2268
+ logger.info(
2269
+ f"✅ Merged CORS config from '{slug}': "
2270
+ f"origins={parent_app.state.cors_config.get('allow_origins')}, "
2271
+ f"credentials={parent_app.state.cors_config.get('allow_credentials')}"
2272
+ )
2273
+
2274
+ async def _register_websocket_routes(
2275
+ parent_app: "FastAPI",
2276
+ child_manifest: dict[str, Any],
2277
+ slug: str,
2278
+ path_prefix: str,
2279
+ ) -> None:
2280
+ """Register WebSocket routes on parent app for a child app."""
2281
+ websockets_config = child_manifest.get("websockets")
2282
+ if not websockets_config:
2283
+ logger.debug(f"No WebSocket configuration found for app '{slug}'")
2284
+ return
2285
+
2286
+ try:
2287
+ from fastapi import APIRouter
2288
+
2289
+ from ..routing.websockets import create_websocket_endpoint
2290
+
2291
+ registered_count = 0
2292
+ failed_count = 0
2293
+
2294
+ for endpoint_name, endpoint_config in websockets_config.items():
2295
+ ws_path = endpoint_config.get("path", f"/{endpoint_name}")
2296
+ # Combine mount prefix with WebSocket path
2297
+ full_ws_path = f"{path_prefix.rstrip('/')}{ws_path}"
2298
+
2299
+ # Handle auth configuration
2300
+ auth_config = endpoint_config.get("auth", {})
2301
+ if isinstance(auth_config, dict) and "required" in auth_config:
2302
+ require_auth = auth_config.get("required", True)
2303
+ elif "require_auth" in endpoint_config:
2304
+ require_auth = endpoint_config.get("require_auth", True)
2305
+ else:
2306
+ # Use app's auth_policy if available
2307
+ if "auth_policy" in child_manifest:
2308
+ require_auth = child_manifest["auth_policy"].get("required", True)
2309
+ else:
2310
+ require_auth = True
2311
+
2312
+ ping_interval = endpoint_config.get("ping_interval", 30)
2313
+
2314
+ try:
2315
+ # Create WebSocket handler
2316
+ handler = create_websocket_endpoint(
2317
+ app_slug=slug,
2318
+ path=ws_path,
2319
+ endpoint_name=endpoint_name,
2320
+ handler=None,
2321
+ require_auth=require_auth,
2322
+ ping_interval=ping_interval,
2323
+ )
2324
+
2325
+ # Register on parent app with full path
2326
+ ws_router = APIRouter()
2327
+ ws_router.websocket(full_ws_path)(handler)
2328
+ parent_app.include_router(ws_router)
2329
+
2330
+ logger.info(
2331
+ f"✅ Registered WebSocket route '{full_ws_path}' "
2332
+ f"for mounted app '{slug}' (mounted at '{path_prefix}', "
2333
+ f"auth: {require_auth}, ping: {ping_interval}s)"
2334
+ )
2335
+
2336
+ # Verify route was actually registered
2337
+ registered_routes = [
2338
+ r
2339
+ for r in parent_app.routes
2340
+ if hasattr(r, "path") and full_ws_path in str(getattr(r, "path", ""))
2341
+ ]
2342
+ if registered_routes:
2343
+ registered_count += 1
2344
+ logger.debug(
2345
+ f"✅ Verified WebSocket route '{full_ws_path}' "
2346
+ f"registered for '{slug}'"
2347
+ )
2348
+ else:
2349
+ failed_count += 1
2350
+ logger.warning(
2351
+ f"⚠️ WebSocket route '{full_ws_path}' not found after registration "
2352
+ f"for '{slug}' - route may not be accessible"
2353
+ )
2354
+ except (ValueError, TypeError, AttributeError, RuntimeError) as e:
2355
+ failed_count += 1
2356
+ logger.error(
2357
+ f"Failed to register WebSocket route '{full_ws_path}' "
2358
+ f"for mounted app '{slug}': {e}",
2359
+ exc_info=True,
2360
+ )
2361
+
2362
+ # Summary logging
2363
+ total_routes = len(websockets_config)
2364
+ if registered_count > 0:
2365
+ logger.info(
2366
+ f"✅ WebSocket registration summary for '{slug}': "
2367
+ f"{registered_count}/{total_routes} routes registered successfully"
2368
+ )
2369
+ if failed_count > 0:
2370
+ logger.warning(
2371
+ f"⚠️ WebSocket registration issues for '{slug}': "
2372
+ f"{failed_count}/{total_routes} routes failed to register"
2373
+ )
2374
+ except ImportError:
2375
+ logger.warning(
2376
+ f"WebSocket support not available - skipping WebSocket routes "
2377
+ f"for mounted app '{slug}'"
2378
+ )
2379
+ except (ValueError, TypeError, AttributeError, RuntimeError) as e:
2380
+ logger.error(
2381
+ f"Failed to register WebSocket routes for mounted app '{slug}': {e}",
2382
+ exc_info=True,
2383
+ )
2384
+
2385
+ @asynccontextmanager
2386
+ async def lifespan(app: FastAPI):
2387
+ """Lifespan context manager for parent app."""
2388
+ nonlocal mounted_apps, shared_user_pool_initialized
2389
+
2390
+ # Initialize engine
2391
+ await engine.initialize()
2392
+
2393
+ # Initialize shared user pool once if any app uses shared auth
2394
+ if has_shared_auth:
2395
+ logger.info("Initializing shared user pool for multi-app deployment")
2396
+ # Find first app with shared auth to get manifest for initialization
2397
+ for app_config in apps:
2398
+ try:
2399
+ manifest_path = app_config["manifest"]
2400
+ with open(manifest_path) as f:
2401
+ app_manifest_pre = json.load(f)
2402
+ auth_config = app_manifest_pre.get("auth", {})
2403
+ if auth_config.get("mode") == "shared":
2404
+ await engine._initialize_shared_user_pool(app, app_manifest_pre)
2405
+ shared_user_pool_initialized = True
2406
+ logger.info("Shared user pool initialized for multi-app deployment")
2407
+ break
2408
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
2409
+ app_slug = app_config.get("slug", "unknown")
2410
+ logger.warning(
2411
+ f"Could not initialize shared user pool from app '{app_slug}': {e}"
2412
+ )
2413
+
2414
+ # Mount each child app
2415
+ for app_config in apps:
2416
+ slug = app_config["slug"]
2417
+ manifest_path = app_config["manifest"]
2418
+ path_prefix = app_config["path_prefix"]
2419
+ on_startup = app_config.get("on_startup")
2420
+ on_shutdown = app_config.get("on_shutdown")
2421
+
2422
+ try:
2423
+ # Load manifest for context helpers
2424
+ try:
2425
+ with open(manifest_path) as f:
2426
+ app_manifest_data = json.load(f)
2427
+ except (FileNotFoundError, json.JSONDecodeError) as e:
2428
+ raise ValueError(
2429
+ f"Failed to load manifest for app '{slug}' at {manifest_path}: {e}"
2430
+ ) from e
2431
+
2432
+ # Log app configuration
2433
+ auth_config = app_manifest_data.get("auth", {})
2434
+ auth_mode = auth_config.get("mode", "app")
2435
+ public_routes = auth_config.get("public_routes", [])
2436
+ logger.info(
2437
+ f"Mounting app '{slug}' at '{path_prefix}': "
2438
+ f"auth_mode={auth_mode}, "
2439
+ f"public_routes={len(public_routes)} routes"
2440
+ )
2441
+ if public_routes:
2442
+ logger.debug(f" Public routes for '{slug}': {public_routes}")
2443
+ else:
2444
+ logger.warning(
2445
+ f" App '{slug}' has no public routes configured. "
2446
+ "All routes will require authentication."
2447
+ )
2448
+
2449
+ # Create child app as sub-app (shares engine and lifecycle)
2450
+ child_app = engine.create_app(
2451
+ slug=slug,
2452
+ manifest=manifest_path,
2453
+ is_sub_app=True, # Important: marks as sub-app
2454
+ on_startup=on_startup,
2455
+ on_shutdown=on_shutdown,
2456
+ )
2457
+
2458
+ # CRITICAL: Set engine state BEFORE importing routes
2459
+ # Routes may use dependencies that need request.app.state.engine
2460
+ # This must be set before route decorators execute
2461
+ child_app.state.engine = engine
2462
+ child_app.state.app_slug = slug
2463
+
2464
+ # Automatically import routes from app module
2465
+ # This discovers and imports route modules (web.py, routes.py, etc.)
2466
+ # so that route decorators are executed and routes are registered
2467
+ try:
2468
+ self._import_app_routes(child_app, manifest_path, slug)
2469
+ except (
2470
+ ValueError,
2471
+ TypeError,
2472
+ AttributeError,
2473
+ RuntimeError,
2474
+ ImportError,
2475
+ SyntaxError,
2476
+ OSError,
2477
+ ) as e:
2478
+ logger.warning(
2479
+ f"Failed to auto-import routes for app '{slug}': {e}. "
2480
+ "Routes may need to be imported manually.",
2481
+ exc_info=True,
2482
+ )
2483
+
2484
+ # Share user_pool with child app if shared auth is enabled
2485
+ if shared_user_pool_initialized and hasattr(app.state, "user_pool"):
2486
+ child_app.state.user_pool = app.state.user_pool
2487
+ # Also share audit_log if available
2488
+ if hasattr(app.state, "audit_log"):
2489
+ child_app.state.audit_log = app.state.audit_log
2490
+ logger.debug(f"Shared user_pool with child app '{slug}'")
2491
+
2492
+ # Add middleware for app context helpers
2493
+ from starlette.middleware.base import BaseHTTPMiddleware
2494
+ from starlette.requests import Request
2495
+
2496
+ # Get auth_hub_url from manifest or env
2497
+ auth_hub_url = None
2498
+ if auth_config.get("mode") == "shared":
2499
+ auth_hub_url = auth_config.get("auth_hub_url")
2500
+ if not auth_hub_url:
2501
+ auth_hub_url = os.getenv("AUTH_HUB_URL", "/auth-hub")
2502
+
2503
+ # Store parent app reference and current app info for middleware
2504
+ # Note: engine and app_slug are already set above (before route import)
2505
+ child_app.state.parent_app = app
2506
+ child_app.state.app_base_path = path_prefix
2507
+ child_app.state.app_auth_hub_url = auth_hub_url
2508
+ child_app.state.app_manifest = app_manifest_data
2509
+
2510
+ # Create middleware factory to properly capture loop variables
2511
+ def create_app_context_middleware(
2512
+ app_slug: str,
2513
+ app_path_prefix: str,
2514
+ app_auth_hub_url_val: str,
2515
+ app_manifest_data_val: dict[str, Any],
2516
+ ) -> type[BaseHTTPMiddleware]:
2517
+ """Create middleware class with captured variables."""
2518
+
2519
+ class _AppContextMiddleware(BaseHTTPMiddleware):
2520
+ """Middleware that sets app context helpers on request.state."""
2521
+
2522
+ async def dispatch(self, request: Request, call_next):
2523
+ # Get parent app from child app state
2524
+ parent_app = getattr(request.app.state, "parent_app", None)
2525
+
2526
+ # Set app context helpers
2527
+ request.state.app_base_path = getattr(
2528
+ request.app.state,
2529
+ "app_base_path",
2530
+ app_path_prefix,
2531
+ )
2532
+ request.state.auth_hub_url = getattr(
2533
+ request.app.state,
2534
+ "app_auth_hub_url",
2535
+ app_auth_hub_url_val,
2536
+ )
2537
+ request.state.app_slug = getattr(
2538
+ request.app.state, "app_slug", app_slug
2539
+ )
2540
+ request.state.engine = engine
2541
+ request.state.manifest = getattr(
2542
+ request.app.state,
2543
+ "app_manifest",
2544
+ app_manifest_data_val,
2545
+ )
2546
+
2547
+ # Get mounted apps from parent app state
2548
+ if parent_app and hasattr(parent_app.state, "mounted_apps"):
2549
+ mounted_apps_list = parent_app.state.mounted_apps
2550
+ request.state.mounted_apps = {
2551
+ ma["slug"]: {
2552
+ "slug": ma["slug"],
2553
+ "path_prefix": ma.get("path_prefix"),
2554
+ "status": ma.get("status", "unknown"),
2555
+ }
2556
+ for ma in mounted_apps_list
2557
+ }
2558
+ else:
2559
+ # Fallback: create minimal dict with current app
2560
+ request.state.mounted_apps = {
2561
+ app_slug: {
2562
+ "slug": app_slug,
2563
+ "path_prefix": app_path_prefix,
2564
+ "status": "mounted",
2565
+ }
2566
+ }
2567
+
2568
+ response = await call_next(request)
2569
+ return response
2570
+
2571
+ return _AppContextMiddleware
2572
+
2573
+ middleware_class = create_app_context_middleware(
2574
+ slug, path_prefix, auth_hub_url, app_manifest_data
2575
+ )
2576
+ child_app.add_middleware(middleware_class)
2577
+ logger.debug(f"Added AppContextMiddleware to child app '{slug}'")
2578
+
2579
+ # Mount child app at path prefix
2580
+ app.mount(path_prefix, child_app)
2581
+
2582
+ # CRITICAL FIX: Merge CORS config from child app to parent app
2583
+ await _merge_cors_config_to_parent(app, child_app, app_manifest_data, slug)
2584
+
2585
+ # CRITICAL FIX: Register WebSocket routes on parent app with full path
2586
+ await _register_websocket_routes(app, app_manifest_data, slug, path_prefix)
2587
+
2588
+ # Update existing entry instead of appending
2589
+ entry = _find_mounted_app_entry(slug)
2590
+ if entry:
2591
+ entry.update(
2592
+ {
2593
+ "status": "mounted",
2594
+ "manifest": app_manifest_data,
2595
+ }
2596
+ )
2597
+ else:
2598
+ # Fallback: append if entry not found (shouldn't happen)
2599
+ mounted_apps.append(
2600
+ {
2601
+ "slug": slug,
2602
+ "path_prefix": path_prefix,
2603
+ "status": "mounted",
2604
+ "manifest": app_manifest_data,
2605
+ }
2606
+ )
2607
+ logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
2608
+
2609
+ except FileNotFoundError as e:
2610
+ error_msg = (
2611
+ f"Failed to mount app '{slug}' at {path_prefix}: "
2612
+ f"manifest.json not found at {manifest_path}"
2613
+ )
2614
+ logger.error(error_msg, exc_info=True)
2615
+ # Update existing entry instead of appending
2616
+ entry = _find_mounted_app_entry(slug)
2617
+ if entry:
2618
+ entry.update(
2619
+ {
2620
+ "status": "failed",
2621
+ "error": error_msg,
2622
+ }
2623
+ )
2624
+ else:
2625
+ # Fallback: append if entry not found (shouldn't happen)
2626
+ mounted_apps.append(
2627
+ {
2628
+ "slug": slug,
2629
+ "path_prefix": path_prefix,
2630
+ "status": "failed",
2631
+ "error": error_msg,
2632
+ "manifest_path": str(manifest_path),
2633
+ }
2634
+ )
2635
+ if strict:
2636
+ raise ValueError(error_msg) from e
2637
+ continue
2638
+ except json.JSONDecodeError as e:
2639
+ error_msg = (
2640
+ f"Failed to mount app '{slug}' at {path_prefix}: "
2641
+ f"Invalid JSON in manifest.json at {manifest_path}: {e}"
2642
+ )
2643
+ logger.error(error_msg, exc_info=True)
2644
+ # Update existing entry instead of appending
2645
+ entry = _find_mounted_app_entry(slug)
2646
+ if entry:
2647
+ entry.update(
2648
+ {
2649
+ "status": "failed",
2650
+ "error": error_msg,
2651
+ }
2652
+ )
2653
+ else:
2654
+ # Fallback: append if entry not found (shouldn't happen)
2655
+ mounted_apps.append(
2656
+ {
2657
+ "slug": slug,
2658
+ "path_prefix": path_prefix,
2659
+ "status": "failed",
2660
+ "error": error_msg,
2661
+ "manifest_path": str(manifest_path),
2662
+ }
2663
+ )
2664
+ if strict:
2665
+ raise ValueError(error_msg) from e
2666
+ continue
2667
+ except ValueError as e:
2668
+ error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2669
+ logger.error(error_msg, exc_info=True)
2670
+ # Update existing entry instead of appending
2671
+ entry = _find_mounted_app_entry(slug)
2672
+ if entry:
2673
+ entry.update(
2674
+ {
2675
+ "status": "failed",
2676
+ "error": error_msg,
2677
+ }
2678
+ )
2679
+ else:
2680
+ # Fallback: append if entry not found (shouldn't happen)
2681
+ mounted_apps.append(
2682
+ {
2683
+ "slug": slug,
2684
+ "path_prefix": path_prefix,
2685
+ "status": "failed",
2686
+ "error": error_msg,
2687
+ "manifest_path": str(manifest_path),
2688
+ }
2689
+ )
2690
+ if strict:
2691
+ raise ValueError(error_msg) from e
2692
+ continue
2693
+ except (KeyError, RuntimeError) as e:
2694
+ error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2695
+ logger.error(error_msg, exc_info=True)
2696
+ # Update existing entry instead of appending
2697
+ entry = _find_mounted_app_entry(slug)
2698
+ if entry:
2699
+ entry.update(
2700
+ {
2701
+ "status": "failed",
2702
+ "error": error_msg,
2703
+ }
2704
+ )
2705
+ else:
2706
+ # Fallback: append if entry not found (shouldn't happen)
2707
+ mounted_apps.append(
2708
+ {
2709
+ "slug": slug,
2710
+ "path_prefix": path_prefix,
2711
+ "status": "failed",
2712
+ "error": error_msg,
2713
+ "manifest_path": str(manifest_path),
2714
+ }
2715
+ )
2716
+ if strict:
2717
+ raise RuntimeError(error_msg) from e
2718
+ continue
2719
+ except (OSError, PermissionError, ImportError, AttributeError, TypeError) as e:
2720
+ error_msg = f"Unexpected error mounting app '{slug}' at {path_prefix}: {e}"
2721
+ logger.error(error_msg, exc_info=True)
2722
+ # Update existing entry instead of appending
2723
+ entry = _find_mounted_app_entry(slug)
2724
+ if entry:
2725
+ entry.update(
2726
+ {
2727
+ "status": "failed",
2728
+ "error": error_msg,
2729
+ }
2730
+ )
2731
+ else:
2732
+ # Fallback: append if entry not found (shouldn't happen)
2733
+ mounted_apps.append(
2734
+ {
2735
+ "slug": slug,
2736
+ "path_prefix": path_prefix,
2737
+ "status": "failed",
2738
+ "error": error_msg,
2739
+ "manifest_path": str(manifest_path),
2740
+ }
2741
+ )
2742
+ if strict:
2743
+ raise RuntimeError(error_msg) from e
2744
+ continue
2745
+
2746
+ # Update app.state.mounted_apps with final status (entries already updated in place)
2747
+ # This ensures the state reflects the final mounted_apps list
2748
+ app.state.mounted_apps = mounted_apps
2749
+
2750
+ # VERIFICATION: Log final configuration state
2751
+ logger.info("=" * 60)
2752
+ logger.info("MDB-Engine Multi-App Configuration Verification")
2753
+ logger.info("=" * 60)
2754
+
2755
+ # Verify CORS config
2756
+ cors_config = getattr(app.state, "cors_config", None)
2757
+ if cors_config:
2758
+ logger.info(
2759
+ f"✅ CORS Config: enabled={cors_config.get('enabled')}, "
2760
+ f"origins={cors_config.get('allow_origins')}, "
2761
+ f"credentials={cors_config.get('allow_credentials')}"
2762
+ )
2763
+ else:
2764
+ logger.warning("⚠️ No CORS config found on parent app")
2765
+
2766
+ # Verify WebSocket routes
2767
+ ws_routes = [
2768
+ r for r in app.routes if hasattr(r, "path") and "/ws" in str(getattr(r, "path", ""))
2769
+ ]
2770
+ if ws_routes:
2771
+ logger.info(f"✅ Found {len(ws_routes)} WebSocket route(s):")
2772
+ for route in ws_routes:
2773
+ route_path = getattr(route, "path", "unknown")
2774
+ logger.info(f" 🔌 {route_path}")
2775
+ else:
2776
+ logger.warning(
2777
+ "⚠️ No WebSocket routes found - check manifest.json websockets config"
2778
+ )
2779
+
2780
+ logger.info("=" * 60)
2781
+
2782
+ yield
2783
+
2784
+ # Shutdown is handled by parent app
2785
+ await engine.shutdown()
2786
+
2787
+ # Create parent FastAPI app
2788
+ parent_app = FastAPI(title=title, lifespan=lifespan, root_path=root_path, **fastapi_kwargs)
2789
+
2790
+ # Set mounted_apps immediately so get_mounted_apps() works before lifespan runs
2791
+ parent_app.state.mounted_apps = mounted_apps
2792
+ parent_app.state.is_multi_app = True
2793
+ parent_app.state.engine = engine
2794
+
2795
+ # Set default CORS config on parent app for WebSocket origin validation
2796
+ # This ensures CSRF middleware can validate WebSocket origins even if child apps
2797
+ # don't configure CORS
2798
+ # NOTE: allow_credentials defaults to False here, but will be set to True
2799
+ # during merge if any child app requires it (essential for SSO cookie-based auth)
2800
+ from ..auth.config_helpers import CORS_DEFAULTS
2801
+
2802
+ parent_app.state.cors_config = CORS_DEFAULTS.copy()
2803
+ parent_app.state.cors_config["enabled"] = True
2804
+ parent_app.state.cors_config["allow_origins"] = ["*"] # Default to allow all for WebSocket
2805
+ # Keep allow_credentials as False initially - will be merged from child apps
2806
+ logger.debug("Set default CORS config on parent app for WebSocket origin validation")
2807
+
2808
+ # Store app reference in engine for get_mounted_apps()
2809
+ engine._multi_app_instance = parent_app
2810
+
2811
+ # Add request scope middleware
2812
+ from starlette.middleware.base import BaseHTTPMiddleware
2813
+
2814
+ from ..di import ScopeManager
2815
+
2816
+ class RequestScopeMiddleware(BaseHTTPMiddleware):
2817
+ """Middleware that manages request-scoped DI instances."""
2818
+
2819
+ async def dispatch(self, request, call_next):
2820
+ ScopeManager.begin_request()
2821
+ try:
2822
+ response = await call_next(request)
2823
+ return response
2824
+ finally:
2825
+ ScopeManager.end_request()
2826
+
2827
+ parent_app.add_middleware(RequestScopeMiddleware)
2828
+ logger.debug("RequestScopeMiddleware added for parent app")
2829
+
2830
+ # CRITICAL: Add CSRF middleware to parent app if any child app uses shared auth
2831
+ # WebSocket routes are registered on parent app, so parent app middleware runs first
2832
+ # CSRF middleware on parent app validates WebSocket origin using parent app's CORS config
2833
+ if has_shared_auth:
2834
+ from ..auth.csrf import create_csrf_middleware
2835
+
2836
+ # Create CSRF middleware with default config (will use parent app's CORS config)
2837
+ # Exempt routes that don't need CSRF (health checks, public routes from child apps)
2838
+ # all_public_routes includes base routes + child app public routes with path prefixes
2839
+ parent_csrf_config = {
2840
+ "csrf_protection": True,
2841
+ "public_routes": all_public_routes,
2842
+ }
2843
+ csrf_middleware = create_csrf_middleware(parent_csrf_config)
2844
+ parent_app.add_middleware(csrf_middleware)
2845
+ logger.info("CSRFMiddleware added to parent app for WebSocket origin validation")
2846
+
2847
+ # Add shared CORS middleware if configured
2848
+ # NOTE: We create a dynamic CORS middleware that reads from app.state.cors_config
2849
+ # This allows the config to be updated after child apps are mounted and merged
2850
+ try:
2851
+ from starlette.middleware.base import BaseHTTPMiddleware
2852
+ from starlette.requests import Request
2853
+ from starlette.responses import Response
2854
+
2855
+ class DynamicCORSMiddleware(BaseHTTPMiddleware):
2856
+ """
2857
+ Dynamic CORS middleware that reads config from app.state.cors_config.
2858
+
2859
+ This allows CORS config to be updated after child apps are mounted
2860
+ and their configs are merged, which is essential for SSO multi-app
2861
+ setups where allow_credentials must be True for cookie-based auth.
2862
+ """
2863
+
2864
+ async def dispatch(self, request: Request, call_next):
2865
+ # Read CORS config from app.state (may have been merged from child apps)
2866
+ cors_config = getattr(request.app.state, "cors_config", {})
2867
+
2868
+ if not cors_config.get("enabled", False):
2869
+ # CORS not enabled, pass through
2870
+ return await call_next(request)
2871
+
2872
+ # Handle preflight OPTIONS request
2873
+ if request.method == "OPTIONS":
2874
+ origin = request.headers.get("origin")
2875
+ allowed_origins = cors_config.get("allow_origins", ["*"])
2876
+ allow_credentials = cors_config.get("allow_credentials", False)
2877
+
2878
+ # Check if origin is allowed
2879
+ origin_allowed = False
2880
+ if "*" in allowed_origins:
2881
+ origin_allowed = True
2882
+ elif origin in allowed_origins:
2883
+ origin_allowed = True
2884
+
2885
+ if origin_allowed:
2886
+ headers = {
2887
+ "Access-Control-Allow-Methods": ", ".join(
2888
+ cors_config.get("allow_methods", ["*"])
2889
+ ),
2890
+ "Access-Control-Allow-Headers": ", ".join(
2891
+ cors_config.get("allow_headers", ["*"])
2892
+ ),
2893
+ "Access-Control-Max-Age": str(cors_config.get("max_age", 3600)),
2894
+ }
2895
+ if allow_credentials:
2896
+ headers["Access-Control-Allow-Credentials"] = "true"
2897
+ if origin:
2898
+ headers["Access-Control-Allow-Origin"] = origin
2899
+
2900
+ expose_headers = cors_config.get("expose_headers", [])
2901
+ if expose_headers:
2902
+ headers["Access-Control-Expose-Headers"] = ", ".join(expose_headers)
2903
+
2904
+ return Response(status_code=200, headers=headers)
2905
+ else:
2906
+ return Response(status_code=403)
2907
+
2908
+ # Handle actual request
2909
+ response = await call_next(request)
2910
+
2911
+ # Add CORS headers to response
2912
+ origin = request.headers.get("origin")
2913
+ allowed_origins = cors_config.get("allow_origins", ["*"])
2914
+ allow_credentials = cors_config.get("allow_credentials", False)
2915
+
2916
+ # Check if origin is allowed
2917
+ origin_allowed = False
2918
+ if "*" in allowed_origins:
2919
+ origin_allowed = True
2920
+ elif origin and origin in allowed_origins:
2921
+ origin_allowed = True
2922
+
2923
+ if origin_allowed:
2924
+ if origin:
2925
+ response.headers["Access-Control-Allow-Origin"] = origin
2926
+ if allow_credentials:
2927
+ response.headers["Access-Control-Allow-Credentials"] = "true"
2928
+
2929
+ expose_headers = cors_config.get("expose_headers", [])
2930
+ if expose_headers:
2931
+ response.headers["Access-Control-Expose-Headers"] = ", ".join(
2932
+ expose_headers
2933
+ )
2934
+
2935
+ return response
2936
+
2937
+ parent_app.add_middleware(DynamicCORSMiddleware)
2938
+ logger.debug(
2939
+ "Dynamic CORS middleware added for parent app (reads from app.state.cors_config)"
2940
+ )
2941
+ except ImportError:
2942
+ logger.warning("CORS middleware not available")
2943
+
2944
+ # Add unified health check endpoint
2945
+ @parent_app.get("/health")
2946
+ async def health_check():
2947
+ """Unified health check for all mounted apps."""
2948
+ import time
2949
+
2950
+ from ..observability import check_engine_health, check_mongodb_health
2951
+
2952
+ # Both are async functions
2953
+ start_time = time.time()
2954
+ engine_health = await check_engine_health(engine)
2955
+ mongo_health = await check_mongodb_health(engine.mongo_client)
2956
+ engine_response_time = int((time.time() - start_time) * 1000)
2957
+
2958
+ # Check each mounted app's status
2959
+ mounted_status = {}
2960
+ for mounted_app_info in mounted_apps:
2961
+ app_slug = mounted_app_info["slug"]
2962
+ path_prefix = mounted_app_info["path_prefix"]
2963
+ status = mounted_app_info["status"]
2964
+
2965
+ app_status = {
2966
+ "path_prefix": path_prefix,
2967
+ "status": status,
2968
+ }
2969
+
2970
+ if "error" in mounted_app_info:
2971
+ app_status["error"] = mounted_app_info["error"]
2972
+ app_status["status"] = "unhealthy"
2973
+ elif status == "mounted":
2974
+ # App is mounted successfully
2975
+ app_status["status"] = "healthy"
2976
+ # Try to get response time by checking if app has routes
2977
+ try:
2978
+ # Find the mounted app and check its route count
2979
+ for route in parent_app.routes:
2980
+ if hasattr(route, "path") and route.path == path_prefix:
2981
+ if hasattr(route, "app"):
2982
+ mounted_app = route.app
2983
+ route_count = len(mounted_app.routes)
2984
+ app_status["route_count"] = route_count
2985
+ break
2986
+ except (AttributeError, TypeError, KeyError):
2987
+ pass
2988
+
2989
+ mounted_status[app_slug] = app_status
2990
+
2991
+ # Determine overall status
2992
+ all_healthy = (
2993
+ engine_health.status.value == "healthy"
2994
+ and mongo_health.status.value == "healthy"
2995
+ and all(
2996
+ app_info.get("status") in ("healthy", "mounted")
2997
+ for app_info in mounted_status.values()
2998
+ )
2999
+ )
3000
+
3001
+ overall_status = "healthy" if all_healthy else "unhealthy"
3002
+
3003
+ return {
3004
+ "status": overall_status,
3005
+ "engine": {
3006
+ "status": engine_health.status.value,
3007
+ "message": engine_health.message,
3008
+ "response_time_ms": engine_response_time,
3009
+ },
3010
+ "mongodb": {
3011
+ "status": mongo_health.status.value,
3012
+ "message": mongo_health.message,
3013
+ },
3014
+ "apps": mounted_status,
3015
+ }
3016
+
3017
+ # Add route introspection endpoint
3018
+ @parent_app.get("/_mdb/routes")
3019
+ async def list_routes():
3020
+ """List all routes from all mounted apps."""
3021
+ routes_info = {
3022
+ "parent_app": {
3023
+ "routes": [],
3024
+ },
3025
+ "mounted_apps": {},
3026
+ }
3027
+
3028
+ # Get parent app routes
3029
+ for route in parent_app.routes:
3030
+ route_info = {
3031
+ "path": getattr(route, "path", str(route)),
3032
+ "methods": list(getattr(route, "methods", set())),
3033
+ "name": getattr(route, "name", None),
3034
+ }
3035
+ routes_info["parent_app"]["routes"].append(route_info)
3036
+
3037
+ # Get routes from mounted apps
3038
+ for mounted_app_info in mounted_apps:
3039
+ app_slug = mounted_app_info["slug"]
3040
+ path_prefix = mounted_app_info["path_prefix"]
3041
+ status = mounted_app_info["status"]
3042
+
3043
+ if status != "mounted":
3044
+ routes_info["mounted_apps"][app_slug] = {
3045
+ "path_prefix": path_prefix,
3046
+ "status": status,
3047
+ "routes": [],
3048
+ "error": mounted_app_info.get("error"),
3049
+ }
3050
+ continue
3051
+
3052
+ # Find the mounted app
3053
+ app_routes = []
3054
+ for route in parent_app.routes:
3055
+ # Check if this route belongs to the mounted app
3056
+ # Mounted apps appear as Mount routes
3057
+ if hasattr(route, "path") and route.path == path_prefix:
3058
+ # This is the mount point
3059
+ if hasattr(route, "app"):
3060
+ # Get routes from the mounted app
3061
+ mounted_app = route.app
3062
+ for child_route in mounted_app.routes:
3063
+ route_path = getattr(child_route, "path", str(child_route))
3064
+ # Prepend path prefix
3065
+ full_path = (
3066
+ f"{path_prefix}{route_path}"
3067
+ if route_path != "/"
3068
+ else path_prefix
3069
+ )
3070
+
3071
+ route_info = {
3072
+ "path": full_path,
3073
+ "relative_path": route_path,
3074
+ "methods": list(getattr(child_route, "methods", set())),
3075
+ "name": getattr(child_route, "name", None),
3076
+ }
3077
+ app_routes.append(route_info)
3078
+
3079
+ routes_info["mounted_apps"][app_slug] = {
3080
+ "path_prefix": path_prefix,
3081
+ "status": status,
3082
+ "routes": app_routes,
3083
+ "route_count": len(app_routes),
3084
+ }
3085
+
3086
+ return routes_info
3087
+
3088
+ # Aggregate OpenAPI docs from all mounted apps
3089
+ def custom_openapi():
3090
+ """Generate aggregated OpenAPI schema from all mounted apps."""
3091
+ from fastapi.openapi.utils import get_openapi
3092
+
3093
+ if parent_app.openapi_schema:
3094
+ return parent_app.openapi_schema
3095
+
3096
+ # Get base schema from parent app
3097
+ openapi_schema = get_openapi(
3098
+ title=title,
3099
+ version=fastapi_kwargs.get("version", "1.0.0"),
3100
+ description=fastapi_kwargs.get("description", ""),
3101
+ routes=parent_app.routes,
3102
+ )
3103
+
3104
+ # Aggregate schemas from mounted apps
3105
+ for mounted_app_info in mounted_apps:
3106
+ if mounted_app_info.get("status") != "mounted":
3107
+ continue
3108
+
3109
+ app_slug = mounted_app_info["slug"]
3110
+ path_prefix = mounted_app_info["path_prefix"]
3111
+
3112
+ # Find the mounted app
3113
+ for route in parent_app.routes:
3114
+ if hasattr(route, "path") and route.path == path_prefix:
3115
+ if hasattr(route, "app"):
3116
+ mounted_app = route.app
3117
+ try:
3118
+ # Get OpenAPI schema from mounted app
3119
+ child_schema = get_openapi(
3120
+ title=getattr(mounted_app, "title", app_slug),
3121
+ version=getattr(mounted_app, "version", "1.0.0"),
3122
+ description=getattr(mounted_app, "description", ""),
3123
+ routes=mounted_app.routes,
3124
+ )
3125
+
3126
+ # Merge paths with prefix
3127
+ if "paths" in child_schema:
3128
+ for path, methods in child_schema["paths"].items():
3129
+ # Prepend path prefix
3130
+ prefixed_path = (
3131
+ f"{path_prefix}{path}" if path != "/" else path_prefix
3132
+ )
3133
+ openapi_schema["paths"][prefixed_path] = methods
3134
+
3135
+ # Merge components/schemas
3136
+ if "components" in child_schema:
3137
+ if "components" not in openapi_schema:
3138
+ openapi_schema["components"] = {}
3139
+ if "schemas" in child_schema["components"]:
3140
+ if "schemas" not in openapi_schema["components"]:
3141
+ openapi_schema["components"]["schemas"] = {}
3142
+ openapi_schema["components"]["schemas"].update(
3143
+ child_schema["components"]["schemas"]
3144
+ )
3145
+
3146
+ logger.debug(f"Aggregated OpenAPI schema from app '{app_slug}'")
3147
+ except (AttributeError, TypeError, KeyError, ValueError) as e:
3148
+ logger.warning(
3149
+ f"Failed to aggregate OpenAPI schema from app '{app_slug}': {e}"
3150
+ )
3151
+ break
3152
+
3153
+ parent_app.openapi_schema = openapi_schema
3154
+ return openapi_schema
3155
+
3156
+ parent_app.openapi = custom_openapi
3157
+
3158
+ # Add per-app docs endpoint
3159
+ @parent_app.get("/docs/{app_slug}")
3160
+ async def app_docs(app_slug: str):
3161
+ """Get OpenAPI docs for a specific app."""
3162
+ from fastapi.openapi.docs import get_swagger_ui_html
3163
+
3164
+ # Find the app
3165
+ mounted_app = None
3166
+ path_prefix = None
3167
+ for mounted_app_info in mounted_apps:
3168
+ if mounted_app_info["slug"] == app_slug:
3169
+ path_prefix = mounted_app_info["path_prefix"]
3170
+ # Find the mounted app
3171
+ for route in parent_app.routes:
3172
+ if hasattr(route, "path") and route.path == path_prefix:
3173
+ if hasattr(route, "app"):
3174
+ mounted_app = route.app
3175
+ break
3176
+ break
3177
+
3178
+ if not mounted_app:
3179
+ from fastapi import HTTPException
3180
+
3181
+ raise HTTPException(404, f"App '{app_slug}' not found or not mounted")
3182
+
3183
+ # Generate OpenAPI JSON for this app
3184
+ from fastapi.openapi.utils import get_openapi
3185
+
3186
+ openapi_schema = get_openapi(
3187
+ title=getattr(mounted_app, "title", app_slug),
3188
+ version=getattr(mounted_app, "version", "1.0.0"),
3189
+ description=getattr(mounted_app, "description", ""),
3190
+ routes=mounted_app.routes,
3191
+ )
3192
+
3193
+ # Modify paths to include prefix
3194
+ if "paths" in openapi_schema:
3195
+ new_paths = {}
3196
+ for path, methods in openapi_schema["paths"].items():
3197
+ prefixed_path = f"{path_prefix}{path}" if path != "/" else path_prefix
3198
+ new_paths[prefixed_path] = methods
3199
+ openapi_schema["paths"] = new_paths
3200
+
3201
+ # Return Swagger UI HTML
3202
+ openapi_url = f"/_mdb/openapi/{app_slug}.json"
3203
+
3204
+ # Store schema temporarily for the JSON endpoint
3205
+ if not hasattr(parent_app.state, "app_openapi_schemas"):
3206
+ parent_app.state.app_openapi_schemas = {}
3207
+ parent_app.state.app_openapi_schemas[app_slug] = openapi_schema
3208
+
3209
+ return get_swagger_ui_html(
3210
+ openapi_url=openapi_url,
3211
+ title=f"{app_slug} - API Documentation",
3212
+ )
3213
+
3214
+ @parent_app.get("/_mdb/openapi/{app_slug}.json")
3215
+ async def app_openapi_json(app_slug: str):
3216
+ """Get OpenAPI JSON for a specific app."""
3217
+ from fastapi import HTTPException
3218
+
3219
+ if not hasattr(parent_app.state, "app_openapi_schemas"):
3220
+ raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
3221
+
3222
+ schema = parent_app.state.app_openapi_schemas.get(app_slug)
3223
+ if not schema:
3224
+ raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
3225
+
3226
+ return schema
3227
+
3228
+ logger.info(f"Multi-app parent created with {len(apps)} app(s) configured")
3229
+
3230
+ return parent_app
3231
+
3232
+ def get_mounted_apps(self, app: Optional["FastAPI"] = None) -> list[dict[str, Any]]:
3233
+ """
3234
+ Get metadata about all mounted apps.
3235
+
3236
+ Args:
3237
+ app: FastAPI app instance (optional, will use engine's tracked app if available)
3238
+
3239
+ Returns:
3240
+ List of dicts with app metadata:
3241
+ - slug: App slug
3242
+ - path_prefix: Path prefix where app is mounted
3243
+ - status: Mount status ("mounted", "failed", etc.)
3244
+ - manifest: App manifest (if available)
3245
+ - error: Error message (if status is "failed")
3246
+
3247
+ Example:
3248
+ mounted_apps = engine.get_mounted_apps(app)
3249
+ for app_info in mounted_apps:
3250
+ print(f"App {app_info['slug']} at {app_info['path_prefix']}")
3251
+ """
3252
+ if app is None:
3253
+ # Try to get from engine state if available
3254
+ if hasattr(self, "_multi_app_instance"):
3255
+ app = self._multi_app_instance
3256
+ else:
3257
+ raise ValueError(
3258
+ "App instance required. Pass app parameter or use "
3259
+ "app.state.mounted_apps directly."
3260
+ )
3261
+
3262
+ mounted_apps = getattr(app.state, "mounted_apps", [])
3263
+ return mounted_apps
3264
+
3265
+ async def _initialize_shared_user_pool(
3266
+ self,
3267
+ app: "FastAPI",
3268
+ manifest: dict[str, Any] | None = None,
3269
+ ) -> None:
3270
+ """
3271
+ Initialize shared user pool, audit log, and set them on app.state.
3272
+
3273
+ Called during lifespan startup for apps using "shared" auth mode.
3274
+ The lazy middleware (added at app creation time) will read the
3275
+ user_pool from app.state at request time.
3276
+
3277
+ Security Features:
3278
+ - JWT secret required (fails fast if not configured)
3279
+ - allow_insecure_dev mode for local development only
3280
+ - Audit logging for compliance and forensics
3281
+
3282
+ Args:
3283
+ app: FastAPI application instance
3284
+ manifest: Optional manifest dict for seeding demo users
3285
+ """
3286
+ from ..auth.audit import AuthAuditLog
3287
+ from ..auth.shared_users import SharedUserPool
3288
+
3289
+ # Determine if we're in development mode
3290
+ # Development = allow insecure auto-generated JWT secret
3291
+ is_dev = (
3292
+ os.getenv("MDB_ENGINE_ENV", "").lower() in ("dev", "development", "local")
3293
+ or os.getenv("ENVIRONMENT", "").lower() in ("dev", "development", "local")
3294
+ or os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
3295
+ )
3296
+
3297
+ # Thread-safe initialization with async lock to prevent race conditions
3298
+ async with self._shared_user_pool_lock:
3299
+ # Check if another coroutine is initializing
3300
+ if self._shared_user_pool_initializing:
3301
+ # Wait for other initialization to complete
3302
+ while self._shared_user_pool_initializing:
3303
+ import asyncio
3304
+
3305
+ await asyncio.sleep(0.01) # Small delay to avoid busy-waiting
3306
+ # After waiting, check if pool was initialized
3307
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
3308
+ app.state.user_pool = self._shared_user_pool
3309
+ return
3310
+
3311
+ # Check if already initialized (double-check pattern)
3312
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
3313
+ app.state.user_pool = self._shared_user_pool
3314
+ return
3315
+
3316
+ # Mark as initializing
3317
+ self._shared_user_pool_initializing = True
3318
+ try:
3319
+ # Create shared user pool
3320
+ self._shared_user_pool = SharedUserPool(
3321
+ self._connection_manager.mongo_db,
3322
+ allow_insecure_dev=is_dev,
3323
+ )
3324
+ await self._shared_user_pool.ensure_indexes()
3325
+ logger.info("SharedUserPool initialized")
3326
+
3327
+ # Expose user pool on app.state for middleware to access
3328
+ app.state.user_pool = self._shared_user_pool
3329
+ finally:
3330
+ # Always clear the initializing flag
3331
+ self._shared_user_pool_initializing = False
3332
+
3333
+ # Seed demo users to SharedUserPool if configured in manifest
3334
+ if manifest:
3335
+ auth_config = manifest.get("auth", {})
3336
+ users_config = auth_config.get("users", {})
3337
+ demo_users = users_config.get("demo_users", [])
3338
+
3339
+ if demo_users and users_config.get("demo_user_seed_strategy", "auto") != "disabled":
3340
+ for demo in demo_users:
3341
+ try:
3342
+ email = demo.get("email")
3343
+ password = demo.get("password")
3344
+ app_roles = demo.get("app_roles", {})
3345
+
3346
+ existing = await self._shared_user_pool.get_user_by_email(email)
3347
+
3348
+ if not existing:
3349
+ await self._shared_user_pool.create_user(
3350
+ email=email,
3351
+ password=password,
3352
+ app_roles=app_roles,
3353
+ )
3354
+ logger.info(f"✅ Created shared demo user: {email}")
3355
+ else:
3356
+ logger.debug(f"ℹ️ Shared demo user exists: {email}")
3357
+ except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
3358
+ logger.warning(
3359
+ f"⚠️ Failed to create shared demo user {demo.get('email')}: {e}"
3360
+ )
3361
+
3362
+ # Initialize audit logging if enabled
3363
+ auth_config = (manifest or {}).get("auth", {})
3364
+ audit_config = auth_config.get("audit", {})
3365
+ audit_enabled = audit_config.get("enabled", True) # Default: enabled for shared auth
3366
+
3367
+ if audit_enabled:
3368
+ retention_days = audit_config.get("retention_days", 90)
3369
+ if not hasattr(self, "_auth_audit_log") or self._auth_audit_log is None:
3370
+ self._auth_audit_log = AuthAuditLog(
3371
+ self._connection_manager.mongo_db,
3372
+ retention_days=retention_days,
3373
+ )
3374
+ await self._auth_audit_log.ensure_indexes()
3375
+ logger.info(f"AuthAuditLog initialized (retention: {retention_days} days)")
3376
+
3377
+ app.state.audit_log = self._auth_audit_log
3378
+
3379
+ logger.info("SharedUserPool and AuditLog attached to app.state")
3380
+
3381
+ def lifespan(
3382
+ self,
3383
+ slug: str,
3384
+ manifest: Path,
3385
+ ) -> Callable:
3386
+ """
3387
+ Create a lifespan context manager for use with FastAPI.
3388
+
3389
+ Use this when you want more control over FastAPI app creation
3390
+ but still want automatic engine lifecycle management.
3391
+
3392
+ Args:
3393
+ slug: Application slug
3394
+ manifest: Path to manifest.json file
3395
+
3396
+ Returns:
3397
+ Async context manager for FastAPI lifespan
3398
+
3399
+ Example:
3400
+ engine = MongoDBEngine(...)
3401
+ app = FastAPI(lifespan=engine.lifespan("my_app", Path("manifest.json")))
3402
+ """
3403
+ engine = self
3404
+ manifest_path = Path(manifest)
3405
+
3406
+ @asynccontextmanager
3407
+ async def _lifespan(app: Any):
3408
+ """Lifespan context manager."""
3409
+ # Initialize engine
3410
+ await engine.initialize()
3411
+
3412
+ # Load and register manifest
3413
+ app_manifest = await engine.load_manifest(manifest_path)
3414
+ await engine.register_app(app_manifest)
3415
+
3416
+ # Auto-retrieve app token
3417
+ await engine.auto_retrieve_app_token(slug)
3418
+
3419
+ # Expose on app.state
3420
+ app.state.engine = engine
3421
+ app.state.app_slug = slug
3422
+ app.state.manifest = app_manifest
3423
+
3424
+ yield
3425
+
3426
+ await engine.shutdown()
3427
+
3428
+ return _lifespan
3429
+
3430
+ async def auto_retrieve_app_token(self, slug: str) -> str | None:
3431
+ """
3432
+ Auto-retrieve app token from environment or database.
3433
+
3434
+ Follows convention: {SLUG_UPPER}_SECRET environment variable.
3435
+ Falls back to database retrieval via secrets manager.
3436
+
3437
+ Args:
3438
+ slug: Application slug
3439
+
3440
+ Returns:
3441
+ App token if found, None otherwise
3442
+
3443
+ Example:
3444
+ # Set MY_APP_SECRET environment variable, or
3445
+ # let the engine retrieve from database
3446
+ token = await engine.auto_retrieve_app_token("my_app")
3447
+ """
3448
+ # Check cache first
3449
+ if slug in self._app_token_cache:
3450
+ logger.debug(f"Using cached token for '{slug}'")
3451
+ return self._app_token_cache[slug]
3452
+
3453
+ # Try environment variable first (convention: {SLUG}_SECRET)
3454
+ env_var_name = f"{slug.upper().replace('-', '_')}_SECRET"
3455
+ token = os.getenv(env_var_name)
3456
+
3457
+ if token:
3458
+ logger.info(f"App token for '{slug}' loaded from {env_var_name}")
3459
+ self._app_token_cache[slug] = token
3460
+ return token
3461
+
3462
+ # Try to retrieve from database
3463
+ if self._app_secrets_manager:
3464
+ try:
3465
+ secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
3466
+ if secret_exists:
3467
+ token = await self._app_secrets_manager.get_app_secret(slug)
3468
+ if token:
3469
+ logger.info(f"App token for '{slug}' retrieved from database")
3470
+ self._app_token_cache[slug] = token
3471
+ return token
3472
+ else:
3473
+ logger.debug(f"No stored secret found for '{slug}'")
3474
+ except PyMongoError as e:
3475
+ logger.warning(f"Error retrieving app token for '{slug}': {e}")
3476
+
3477
+ logger.debug(
3478
+ f"No app token found for '{slug}'. "
3479
+ f"Set {env_var_name} environment variable or register app to generate one."
3480
+ )
3481
+ return None
3482
+
3483
+ def get_app_token(self, slug: str) -> str | None:
3484
+ """
3485
+ Get cached app token for a slug.
3486
+
3487
+ Returns token from cache if available. Use auto_retrieve_app_token()
3488
+ to populate the cache first.
3489
+
3490
+ Args:
3491
+ slug: Application slug
3492
+
3493
+ Returns:
3494
+ Cached app token or None
3495
+ """
3496
+ return self._app_token_cache.get(slug)