skrift 0.1.0a12__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 (74) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +12 -0
  3. skrift/admin/__init__.py +11 -0
  4. skrift/admin/controller.py +452 -0
  5. skrift/admin/navigation.py +105 -0
  6. skrift/alembic/env.py +92 -0
  7. skrift/alembic/script.py.mako +26 -0
  8. skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
  9. skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
  10. skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
  11. skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
  12. skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
  13. skrift/alembic/versions/20260129_add_oauth_accounts.py +141 -0
  14. skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
  15. skrift/alembic.ini +77 -0
  16. skrift/asgi.py +670 -0
  17. skrift/auth/__init__.py +58 -0
  18. skrift/auth/guards.py +130 -0
  19. skrift/auth/roles.py +129 -0
  20. skrift/auth/services.py +184 -0
  21. skrift/cli.py +143 -0
  22. skrift/config.py +259 -0
  23. skrift/controllers/__init__.py +4 -0
  24. skrift/controllers/auth.py +595 -0
  25. skrift/controllers/web.py +67 -0
  26. skrift/db/__init__.py +3 -0
  27. skrift/db/base.py +7 -0
  28. skrift/db/models/__init__.py +7 -0
  29. skrift/db/models/oauth_account.py +50 -0
  30. skrift/db/models/page.py +26 -0
  31. skrift/db/models/role.py +56 -0
  32. skrift/db/models/setting.py +13 -0
  33. skrift/db/models/user.py +36 -0
  34. skrift/db/services/__init__.py +1 -0
  35. skrift/db/services/oauth_service.py +195 -0
  36. skrift/db/services/page_service.py +217 -0
  37. skrift/db/services/setting_service.py +206 -0
  38. skrift/lib/__init__.py +3 -0
  39. skrift/lib/exceptions.py +168 -0
  40. skrift/lib/template.py +108 -0
  41. skrift/setup/__init__.py +14 -0
  42. skrift/setup/config_writer.py +213 -0
  43. skrift/setup/controller.py +888 -0
  44. skrift/setup/middleware.py +89 -0
  45. skrift/setup/providers.py +214 -0
  46. skrift/setup/state.py +315 -0
  47. skrift/static/css/style.css +1003 -0
  48. skrift/templates/admin/admin.html +19 -0
  49. skrift/templates/admin/base.html +24 -0
  50. skrift/templates/admin/pages/edit.html +32 -0
  51. skrift/templates/admin/pages/list.html +62 -0
  52. skrift/templates/admin/settings/site.html +32 -0
  53. skrift/templates/admin/users/list.html +58 -0
  54. skrift/templates/admin/users/roles.html +42 -0
  55. skrift/templates/auth/dummy_login.html +102 -0
  56. skrift/templates/auth/login.html +139 -0
  57. skrift/templates/base.html +52 -0
  58. skrift/templates/error-404.html +19 -0
  59. skrift/templates/error-500.html +19 -0
  60. skrift/templates/error.html +19 -0
  61. skrift/templates/index.html +9 -0
  62. skrift/templates/page.html +26 -0
  63. skrift/templates/setup/admin.html +24 -0
  64. skrift/templates/setup/auth.html +110 -0
  65. skrift/templates/setup/base.html +407 -0
  66. skrift/templates/setup/complete.html +17 -0
  67. skrift/templates/setup/configuring.html +158 -0
  68. skrift/templates/setup/database.html +125 -0
  69. skrift/templates/setup/restart.html +28 -0
  70. skrift/templates/setup/site.html +39 -0
  71. skrift-0.1.0a12.dist-info/METADATA +235 -0
  72. skrift-0.1.0a12.dist-info/RECORD +74 -0
  73. skrift-0.1.0a12.dist-info/WHEEL +4 -0
  74. skrift-0.1.0a12.dist-info/entry_points.txt +2 -0
skrift/asgi.py ADDED
@@ -0,0 +1,670 @@
1
+ """ASGI application factory for Skrift.
2
+
3
+ This module handles application creation with setup wizard support.
4
+ The application uses a dispatcher architecture:
5
+ - AppDispatcher routes requests to either the setup app or the main app
6
+ - When setup completes, the dispatcher switches all traffic to the main app
7
+ - No server restart required after setup
8
+ """
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import importlib
13
+ import os
14
+ import sys
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+ from advanced_alchemy.config import EngineConfig
20
+ from advanced_alchemy.extensions.litestar import (
21
+ AsyncSessionConfig,
22
+ SQLAlchemyAsyncConfig,
23
+ SQLAlchemyPlugin,
24
+ )
25
+ from litestar import Litestar
26
+ from litestar.config.compression import CompressionConfig
27
+ from litestar.contrib.jinja import JinjaTemplateEngine
28
+ from litestar.exceptions import HTTPException
29
+ from litestar.middleware import DefineMiddleware
30
+ from litestar.middleware.session.client_side import CookieBackendConfig
31
+ from litestar.static_files import create_static_files_router
32
+ from litestar.template import TemplateConfig
33
+ from litestar.types import ASGIApp, Receive, Scope, Send
34
+
35
+ from skrift.config import get_config_path, get_settings, is_config_valid
36
+ from skrift.db.base import Base
37
+ from skrift.db.services.setting_service import (
38
+ load_site_settings_cache,
39
+ get_cached_site_name,
40
+ get_cached_site_tagline,
41
+ get_cached_site_copyright_holder,
42
+ get_cached_site_copyright_start_year,
43
+ get_setting,
44
+ SETUP_COMPLETED_AT_KEY,
45
+ )
46
+ from skrift.lib.exceptions import http_exception_handler, internal_server_error_handler
47
+
48
+
49
+ def load_controllers() -> list:
50
+ """Load controllers from app.yaml configuration."""
51
+ config_path = get_config_path()
52
+
53
+ if not config_path.exists():
54
+ return []
55
+
56
+ with open(config_path, "r") as f:
57
+ config = yaml.safe_load(f)
58
+
59
+ if not config:
60
+ return []
61
+
62
+ # Add working directory to sys.path for local controller imports
63
+ cwd = os.getcwd()
64
+ if cwd not in sys.path:
65
+ sys.path.insert(0, cwd)
66
+
67
+ controllers = []
68
+ for controller_spec in config.get("controllers", []):
69
+ module_path, class_name = controller_spec.split(":")
70
+ module = importlib.import_module(module_path)
71
+ controller_class = getattr(module, class_name)
72
+ controllers.append(controller_class)
73
+
74
+ return controllers
75
+
76
+
77
+ def _load_middleware_factory(spec: str):
78
+ """Import a single middleware factory from a module:name spec.
79
+
80
+ Args:
81
+ spec: String in format "module.path:factory_name"
82
+
83
+ Returns:
84
+ The callable middleware factory
85
+
86
+ Raises:
87
+ ValueError: If spec doesn't contain exactly one colon
88
+ ImportError: If the module cannot be imported
89
+ AttributeError: If the factory doesn't exist in the module
90
+ TypeError: If the factory is not callable
91
+ """
92
+ if ":" not in spec:
93
+ raise ValueError(
94
+ f"Invalid middleware spec '{spec}': must be in format 'module:factory'"
95
+ )
96
+
97
+ parts = spec.split(":")
98
+ if len(parts) != 2:
99
+ raise ValueError(
100
+ f"Invalid middleware spec '{spec}': must contain exactly one colon"
101
+ )
102
+
103
+ module_path, factory_name = parts
104
+ module = importlib.import_module(module_path)
105
+ factory = getattr(module, factory_name)
106
+
107
+ if not callable(factory):
108
+ raise TypeError(
109
+ f"Middleware factory '{spec}' is not callable"
110
+ )
111
+
112
+ return factory
113
+
114
+
115
+ def load_middleware() -> list:
116
+ """Load middleware from app.yaml configuration.
117
+
118
+ Supports two formats in app.yaml:
119
+
120
+ Simple (no args):
121
+ middleware:
122
+ - myapp.middleware:create_logging_middleware
123
+
124
+ With kwargs:
125
+ middleware:
126
+ - factory: myapp.middleware:create_rate_limit_middleware
127
+ kwargs:
128
+ requests_per_minute: 100
129
+
130
+ Returns:
131
+ List of middleware factories or DefineMiddleware instances
132
+ """
133
+ config_path = get_config_path()
134
+
135
+ if not config_path.exists():
136
+ return []
137
+
138
+ with open(config_path, "r") as f:
139
+ config = yaml.safe_load(f)
140
+
141
+ if not config:
142
+ return []
143
+
144
+ middleware_specs = config.get("middleware", [])
145
+ if not middleware_specs:
146
+ return []
147
+
148
+ # Add working directory to sys.path for local middleware imports
149
+ cwd = os.getcwd()
150
+ if cwd not in sys.path:
151
+ sys.path.insert(0, cwd)
152
+
153
+ middleware = []
154
+ for spec in middleware_specs:
155
+ if isinstance(spec, str):
156
+ # Simple format: "module:factory"
157
+ factory = _load_middleware_factory(spec)
158
+ middleware.append(factory)
159
+ elif isinstance(spec, dict):
160
+ # Dict format with optional kwargs
161
+ if "factory" not in spec:
162
+ raise ValueError(
163
+ f"Middleware dict spec must have 'factory' key: {spec}"
164
+ )
165
+ factory = _load_middleware_factory(spec["factory"])
166
+ kwargs = spec.get("kwargs", {})
167
+ if kwargs:
168
+ middleware.append(DefineMiddleware(factory, **kwargs))
169
+ else:
170
+ middleware.append(factory)
171
+ else:
172
+ raise ValueError(
173
+ f"Invalid middleware spec type: {type(spec).__name__}. "
174
+ "Must be string or dict."
175
+ )
176
+
177
+ return middleware
178
+
179
+
180
+ async def check_setup_complete(db_config: SQLAlchemyAsyncConfig) -> bool:
181
+ """Check if setup has been completed."""
182
+ try:
183
+ async with db_config.get_session() as session:
184
+ value = await get_setting(session, SETUP_COMPLETED_AT_KEY)
185
+ return value is not None
186
+ except Exception:
187
+ return False
188
+
189
+
190
+ # Module-level reference to the dispatcher for state updates
191
+ _dispatcher: "AppDispatcher | None" = None
192
+
193
+
194
+ def lock_setup_in_dispatcher() -> None:
195
+ """Lock setup in the dispatcher, making /setup/* return 404.
196
+
197
+ This is called when setup is complete and user visits the main site.
198
+ """
199
+ global _dispatcher
200
+ if _dispatcher is not None:
201
+ _dispatcher.setup_locked = True
202
+
203
+
204
+ class AppDispatcher:
205
+ """ASGI dispatcher that routes between setup and main apps.
206
+
207
+ Uses a simple setup_locked flag:
208
+ - When True: /setup/* returns 404 (via main app), all traffic goes to main app
209
+ - When False: Setup routes work, check DB to determine routing for other paths
210
+
211
+ The main_app can be None at startup if config isn't valid yet. It will be
212
+ lazily created after setup completes.
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ setup_app: ASGIApp,
218
+ db_url: str | None = None,
219
+ main_app: Litestar | None = None,
220
+ ) -> None:
221
+ self._main_app = main_app
222
+ self._main_app_error: str | None = None
223
+ self._main_app_started = main_app is not None # Track if lifespan started
224
+ self.setup_app = setup_app
225
+ self.setup_locked = False # When True, setup is inaccessible
226
+ self._db_url = db_url
227
+ self._lifespan_task: asyncio.Task | None = None
228
+ self._shutdown_event: asyncio.Event | None = None
229
+
230
+ async def _get_or_create_main_app(self) -> Litestar | None:
231
+ """Get the main app, creating it lazily if needed."""
232
+ if self._main_app is None and self._main_app_error is None:
233
+ try:
234
+ self._main_app = create_app()
235
+ # Run lifespan startup for the newly created app
236
+ await self._start_main_app_lifespan()
237
+ except Exception as e:
238
+ self._main_app_error = str(e)
239
+ self._main_app = None
240
+ return self._main_app
241
+
242
+ async def _start_main_app_lifespan(self) -> None:
243
+ """Start the main app's lifespan (runs startup handlers)."""
244
+ if self._main_app is None or self._main_app_started:
245
+ return
246
+
247
+ startup_complete = asyncio.Event()
248
+ startup_failed: str | None = None
249
+ self._shutdown_event = asyncio.Event()
250
+ message_queue: asyncio.Queue = asyncio.Queue()
251
+
252
+ # Queue the startup message
253
+ await message_queue.put({"type": "lifespan.startup"})
254
+
255
+ async def receive():
256
+ # First return startup, then wait for shutdown signal
257
+ msg = await message_queue.get()
258
+ return msg
259
+
260
+ async def send(message):
261
+ nonlocal startup_failed
262
+ if message["type"] == "lifespan.startup.complete":
263
+ startup_complete.set()
264
+ elif message["type"] == "lifespan.startup.failed":
265
+ startup_failed = message.get("message", "Startup failed")
266
+ startup_complete.set()
267
+
268
+ scope = {"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}
269
+
270
+ async def run_lifespan():
271
+ try:
272
+ await self._main_app(scope, receive, send)
273
+ except Exception:
274
+ pass
275
+
276
+ # Start lifespan handler in background
277
+ self._lifespan_task = asyncio.create_task(run_lifespan())
278
+
279
+ # Wait for startup to complete
280
+ await startup_complete.wait()
281
+
282
+ if startup_failed:
283
+ raise RuntimeError(startup_failed)
284
+
285
+ self._main_app_started = True
286
+
287
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
288
+ if scope["type"] != "http":
289
+ # Lifespan events go to setup app if no main app yet
290
+ app = self._main_app or self.setup_app
291
+ await app(scope, receive, send)
292
+ return
293
+
294
+ path = scope.get("path", "")
295
+
296
+ # If setup is locked and we have main app, it handles EVERYTHING
297
+ if self.setup_locked and self._main_app:
298
+ await self._main_app(scope, receive, send)
299
+ return
300
+
301
+ # Setup not locked - /setup/* always goes to setup app
302
+ if path.startswith("/setup") or path.startswith("/static"):
303
+ await self.setup_app(scope, receive, send)
304
+ return
305
+
306
+ # Check if setup is complete in DB
307
+ if await self._is_setup_complete_in_db():
308
+ # Setup complete - try to get/create main app
309
+ main_app = await self._get_or_create_main_app()
310
+ if main_app:
311
+ self.setup_locked = True
312
+ await main_app(scope, receive, send)
313
+ else:
314
+ # Can't create main app - show error
315
+ await self._error_response(
316
+ send,
317
+ f"Setup complete but cannot start application: {self._main_app_error}"
318
+ )
319
+ else:
320
+ # Setup not complete
321
+ # Route /auth/* to setup app for OAuth callbacks during setup
322
+ if path.startswith("/auth"):
323
+ await self.setup_app(scope, receive, send)
324
+ else:
325
+ # Redirect other paths to /setup
326
+ await self._redirect(send, "/setup")
327
+
328
+ async def _is_setup_complete_in_db(self) -> bool:
329
+ """Check if setup is complete in the database."""
330
+ db_url = self._db_url
331
+
332
+ # Try to get db_url dynamically if not set at startup
333
+ # (setup may have configured the database after server started)
334
+ if not db_url:
335
+ try:
336
+ from skrift.setup.state import get_database_url_from_yaml
337
+ db_url = get_database_url_from_yaml()
338
+ if db_url:
339
+ self._db_url = db_url # Cache for future requests
340
+ except Exception:
341
+ pass
342
+
343
+ if not db_url:
344
+ return False
345
+
346
+ try:
347
+ return await check_setup_in_db(db_url)
348
+ except Exception:
349
+ return False
350
+
351
+ async def _redirect(self, send: Send, location: str) -> None:
352
+ """Send a redirect response."""
353
+ await send({
354
+ "type": "http.response.start",
355
+ "status": 302,
356
+ "headers": [(b"location", location.encode()), (b"content-length", b"0")],
357
+ })
358
+ await send({"type": "http.response.body", "body": b""})
359
+
360
+ async def _error_response(self, send: Send, message: str) -> None:
361
+ """Send an error response."""
362
+ body = f"<h1>Application Error</h1><p>{message}</p><p>Please check your configuration and restart the server.</p>".encode()
363
+ await send({
364
+ "type": "http.response.start",
365
+ "status": 500,
366
+ "headers": [
367
+ (b"content-type", b"text/html"),
368
+ (b"content-length", str(len(body)).encode()),
369
+ ],
370
+ })
371
+ await send({"type": "http.response.body", "body": body})
372
+
373
+
374
+ def create_app() -> Litestar:
375
+ """Create and configure the main Litestar application.
376
+
377
+ This app has all routes for normal operation. It is used by the dispatcher
378
+ after setup is complete.
379
+ """
380
+ # CRITICAL: Check for dummy auth in production BEFORE anything else
381
+ from skrift.setup.providers import validate_no_dummy_auth_in_production
382
+ validate_no_dummy_auth_in_production()
383
+
384
+ settings = get_settings()
385
+
386
+ # Load controllers from app.yaml
387
+ controllers = load_controllers()
388
+
389
+ # Load middleware from app.yaml
390
+ user_middleware = load_middleware()
391
+
392
+ # Database configuration
393
+ if "sqlite" in settings.db.url:
394
+ engine_config = EngineConfig(echo=settings.db.echo)
395
+ else:
396
+ engine_config = EngineConfig(
397
+ pool_size=settings.db.pool_size,
398
+ max_overflow=settings.db.pool_overflow,
399
+ pool_timeout=settings.db.pool_timeout,
400
+ echo=settings.db.echo,
401
+ )
402
+
403
+ db_config = SQLAlchemyAsyncConfig(
404
+ connection_string=settings.db.url,
405
+ metadata=Base.metadata,
406
+ create_all=False,
407
+ session_config=AsyncSessionConfig(expire_on_commit=False),
408
+ engine_config=engine_config,
409
+ )
410
+
411
+ # Session configuration (client-side encrypted cookies)
412
+ session_secret = hashlib.sha256(settings.secret_key.encode()).digest()
413
+ session_config = CookieBackendConfig(
414
+ secret=session_secret,
415
+ max_age=60 * 60 * 24 * 7, # 7 days
416
+ httponly=True,
417
+ secure=not settings.debug,
418
+ samesite="lax",
419
+ domain=settings.session.cookie_domain,
420
+ )
421
+
422
+ # Template configuration
423
+ # Search working directory first for user overrides, then package directory
424
+ working_dir_templates = Path(os.getcwd()) / "templates"
425
+ template_dir = Path(__file__).parent / "templates"
426
+ template_config = TemplateConfig(
427
+ directory=[working_dir_templates, template_dir],
428
+ engine=JinjaTemplateEngine,
429
+ engine_callback=lambda engine: engine.engine.globals.update({
430
+ "now": datetime.now,
431
+ "site_name": get_cached_site_name,
432
+ "site_tagline": get_cached_site_tagline,
433
+ "site_copyright_holder": get_cached_site_copyright_holder,
434
+ "site_copyright_start_year": get_cached_site_copyright_start_year,
435
+ }),
436
+ )
437
+
438
+ # Static files - working directory first for user overrides, then package directory
439
+ working_dir_static = Path(os.getcwd()) / "static"
440
+ static_files_router = create_static_files_router(
441
+ path="/static",
442
+ directories=[working_dir_static, Path(__file__).parent / "static"],
443
+ )
444
+
445
+ from skrift.auth import sync_roles_to_database
446
+
447
+ async def on_startup(_app: Litestar) -> None:
448
+ """Sync roles and load site settings on startup."""
449
+ try:
450
+ async with db_config.get_session() as session:
451
+ await sync_roles_to_database(session)
452
+ await load_site_settings_cache(session)
453
+ except Exception:
454
+ # Database might not exist yet during setup
455
+ pass
456
+
457
+ return Litestar(
458
+ on_startup=[on_startup],
459
+ route_handlers=[*controllers, static_files_router],
460
+ plugins=[SQLAlchemyPlugin(config=db_config)],
461
+ middleware=[session_config.middleware, *user_middleware],
462
+ template_config=template_config,
463
+ compression_config=CompressionConfig(backend="gzip"),
464
+ exception_handlers={
465
+ HTTPException: http_exception_handler,
466
+ Exception: internal_server_error_handler,
467
+ },
468
+ debug=settings.debug,
469
+ )
470
+
471
+
472
+ def create_setup_app() -> Litestar:
473
+ """Create an application for the setup wizard.
474
+
475
+ This app handles only setup routes (/setup/*, /auth/*, /static/*).
476
+ The AppDispatcher handles routing non-setup paths.
477
+ """
478
+ from pydantic_settings import BaseSettings
479
+ from skrift.setup.state import get_database_url_from_yaml
480
+
481
+ class MinimalSettings(BaseSettings):
482
+ debug: bool = True
483
+ secret_key: str = "setup-wizard-temporary-secret-key-change-me"
484
+
485
+ settings = MinimalSettings()
486
+
487
+ # Session configuration
488
+ session_secret = hashlib.sha256(settings.secret_key.encode()).digest()
489
+ session_config = CookieBackendConfig(
490
+ secret=session_secret,
491
+ max_age=60 * 60 * 24, # 1 day
492
+ httponly=True,
493
+ secure=False,
494
+ samesite="lax",
495
+ )
496
+
497
+ # Template configuration
498
+ # Search working directory first for user overrides, then package directory
499
+ working_dir_templates = Path(os.getcwd()) / "templates"
500
+ template_dir = Path(__file__).parent / "templates"
501
+ template_config = TemplateConfig(
502
+ directory=[working_dir_templates, template_dir],
503
+ engine=JinjaTemplateEngine,
504
+ engine_callback=lambda engine: engine.engine.globals.update({
505
+ "now": datetime.now,
506
+ "site_name": lambda: "Skrift",
507
+ "site_tagline": lambda: "Setup",
508
+ "site_copyright_holder": lambda: "",
509
+ "site_copyright_start_year": lambda: None,
510
+ }),
511
+ )
512
+
513
+ # Static files - working directory first for user overrides, then package directory
514
+ working_dir_static = Path(os.getcwd()) / "static"
515
+ static_files_router = create_static_files_router(
516
+ path="/static",
517
+ directories=[working_dir_static, Path(__file__).parent / "static"],
518
+ )
519
+
520
+ # Import controllers
521
+ from skrift.setup.controller import SetupController, SetupAuthController
522
+
523
+ # Check if database is configured - if so, include SQLAlchemy
524
+ db_url = get_database_url_from_yaml()
525
+
526
+ # Also try to get the raw db URL from config (before env var resolution)
527
+ if not db_url:
528
+ config_path = get_config_path()
529
+ if config_path.exists():
530
+ try:
531
+ with open(config_path, "r") as f:
532
+ raw_config = yaml.safe_load(f)
533
+ raw_db_url = raw_config.get("db", {}).get("url", "")
534
+ # If it's an env var reference but env var isn't set,
535
+ # check if there's a local SQLite fallback we can use
536
+ if raw_db_url.startswith("$"):
537
+ for db_file in ["./app.db", "./data.db", "./skrift.db"]:
538
+ if Path(db_file).exists():
539
+ db_url = f"sqlite+aiosqlite:///{db_file}"
540
+ break
541
+ except Exception:
542
+ pass
543
+
544
+ plugins = []
545
+ route_handlers = [SetupController, SetupAuthController, static_files_router]
546
+ db_config: SQLAlchemyAsyncConfig | None = None
547
+
548
+ if db_url:
549
+ # Database is configured, add SQLAlchemy plugin
550
+ if "sqlite" in db_url:
551
+ engine_config = EngineConfig(echo=False)
552
+ else:
553
+ engine_config = EngineConfig(
554
+ pool_size=5,
555
+ max_overflow=10,
556
+ pool_timeout=30,
557
+ echo=False,
558
+ )
559
+
560
+ db_config = SQLAlchemyAsyncConfig(
561
+ connection_string=db_url,
562
+ metadata=Base.metadata,
563
+ create_all=False,
564
+ session_config=AsyncSessionConfig(expire_on_commit=False),
565
+ engine_config=engine_config,
566
+ )
567
+ plugins.append(SQLAlchemyPlugin(config=db_config))
568
+
569
+ async def on_startup(_app: Litestar) -> None:
570
+ """Initialize setup state and sync roles if database is available."""
571
+ if db_config is not None:
572
+ try:
573
+ from skrift.auth import sync_roles_to_database
574
+ async with db_config.get_session() as session:
575
+ await sync_roles_to_database(session)
576
+ except Exception:
577
+ pass
578
+
579
+ return Litestar(
580
+ on_startup=[on_startup],
581
+ route_handlers=route_handlers,
582
+ plugins=plugins,
583
+ middleware=[session_config.middleware],
584
+ template_config=template_config,
585
+ compression_config=CompressionConfig(backend="gzip"),
586
+ exception_handlers={
587
+ HTTPException: http_exception_handler,
588
+ Exception: internal_server_error_handler,
589
+ },
590
+ debug=settings.debug,
591
+ )
592
+
593
+
594
+ async def check_setup_in_db(db_url: str) -> bool:
595
+ """Check if setup is complete by querying the database directly."""
596
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
597
+
598
+ engine = create_async_engine(db_url)
599
+ async_session = async_sessionmaker(engine, expire_on_commit=False)
600
+
601
+ try:
602
+ async with async_session() as session:
603
+ value = await get_setting(session, SETUP_COMPLETED_AT_KEY)
604
+ return value is not None
605
+ except Exception:
606
+ return False
607
+ finally:
608
+ await engine.dispose()
609
+
610
+
611
+ def create_dispatcher() -> ASGIApp:
612
+ """Create the ASGI app dispatcher.
613
+
614
+ This is the main entry point. The dispatcher handles routing between
615
+ setup and main apps, with lazy creation of the main app after setup completes.
616
+ """
617
+ # CRITICAL: Check for dummy auth in production BEFORE anything else
618
+ from skrift.setup.providers import validate_no_dummy_auth_in_production
619
+ validate_no_dummy_auth_in_production()
620
+
621
+ global _dispatcher
622
+ from skrift.setup.state import get_database_url_from_yaml
623
+
624
+ # Get database URL first
625
+ db_url: str | None = None
626
+ try:
627
+ db_url = get_database_url_from_yaml()
628
+ except Exception:
629
+ pass
630
+
631
+ # Check if setup is already complete
632
+ initial_setup_complete = False
633
+ if db_url:
634
+ try:
635
+ initial_setup_complete = asyncio.get_event_loop().run_until_complete(
636
+ check_setup_in_db(db_url)
637
+ )
638
+ except RuntimeError:
639
+ # No running event loop, try creating one
640
+ try:
641
+ initial_setup_complete = asyncio.run(check_setup_in_db(db_url))
642
+ except Exception:
643
+ pass
644
+ except Exception:
645
+ pass
646
+
647
+ # Also check if config is valid
648
+ config_valid, _ = is_config_valid()
649
+
650
+ if initial_setup_complete and config_valid:
651
+ # Setup already done - just return the main app directly
652
+ return create_app()
653
+
654
+ # Try to create main app if config is valid
655
+ main_app: Litestar | None = None
656
+ if config_valid:
657
+ try:
658
+ main_app = create_app()
659
+ except Exception:
660
+ pass
661
+
662
+ # Always use dispatcher - it handles lazy main app creation
663
+ setup_app = create_setup_app()
664
+ dispatcher = AppDispatcher(setup_app=setup_app, db_url=db_url, main_app=main_app)
665
+ dispatcher.setup_locked = initial_setup_complete
666
+ _dispatcher = dispatcher # Store reference for later updates
667
+ return dispatcher
668
+
669
+
670
+ app = create_dispatcher()