""" Extension hooks for the velxio backend. Routes that stay in OSS (compile, libraries, simulation, iot_gateway) used to import directly from `app.core.dependencies`, `app.database.session`, `app.models.*` and `app.services.metrics`. That made the OSS image impossible to ship without the auth/DB stack — deleting any of those modules would crash the route layer at import time. This module is the seam. OSS routes import only from here. Each hook is a no-op by default; a private overlay (e.g. velxio-prod's `app.pro`) calls the `register_*` setter inside its own `register_pro(app)` to plug in a real implementation. When the overlay is absent, the routes still load and the hooks just return None / yield no events. Adding a new extension point: define a Protocol/Callable type, a module-level slot, a `register_*` setter, and a public callable that invokes the slot if present. Do NOT import from `app.database`, `app.models`, or `app.services` here. """ from __future__ import annotations import logging from typing import Any, Awaitable, Callable, Optional from fastapi import Request logger = logging.getLogger(__name__) # ── record_compile ─────────────────────────────────────────────────────────── # Fires once per compile attempt. Overlay implementations own their own DB # session and decide what to persist. The compile route only knows about # metadata (user_id, project_id, board fqbn, timing, error classification). RecordCompileHook = Callable[ ..., # accepts the kwargs below; using ... avoids over-constraining overlays Awaitable[None], ] _record_compile_hook: Optional[RecordCompileHook] = None def register_record_compile(hook: RecordCompileHook) -> None: """Install the compile-metric recorder. Called by overlays in register_pro.""" global _record_compile_hook _record_compile_hook = hook async def record_compile( *, user_id: Optional[str], project_id: Optional[str], board_fqbn: str, success: bool, duration_ms: int, error_kind: Optional[str], extra: dict, request: Any = None, ) -> None: """Record a compile event. No-op when no overlay is loaded.""" if _record_compile_hook is None: return try: await _record_compile_hook( user_id=user_id, project_id=project_id, board_fqbn=board_fqbn, success=success, duration_ms=duration_ms, error_kind=error_kind, extra=extra, request=request, ) except Exception: # A failing metric must never break the compile response. logger.exception("record_compile hook failed (swallowed)") # ── get_current_user_id ─────────────────────────────────────────────────────── # FastAPI dependency: resolves the current user's id from the request (typically # by decoding a JWT cookie). Returns None for anonymous requests OR when no auth # overlay is loaded. Routes that need an id but accept anonymous use the # returned value directly; routes that require auth wrap with require_auth_hook. GetCurrentUserIdHook = Callable[[Any], Awaitable[Optional[str]]] _get_current_user_id_hook: Optional[GetCurrentUserIdHook] = None def register_get_current_user_id(hook: GetCurrentUserIdHook) -> None: """Install the auth resolver. Called by overlays in register_pro.""" global _get_current_user_id_hook _get_current_user_id_hook = hook async def get_current_user_id(request: Request) -> Optional[str]: # FastAPI dependency if _get_current_user_id_hook is None: return None try: return await _get_current_user_id_hook(request) except Exception: logger.exception("get_current_user_id hook failed (treating as anonymous)") return None # ── get_project_libraries ───────────────────────────────────────────────────── # Returns the declared library manifest (list of library names) SAVED with a # project. The ESP-IDF compiler uses it as the resolution SCOPE so a project # only merges its own declared libraries — never another user's, or another # project's, stray install in the shared library dir. Sourced authoritatively # from the project record (not the client), so it is robust regardless of what # the frontend sends. Returns None for an unknown project, an empty manifest, # or when no overlay is loaded (→ legacy scan-all). GetProjectLibrariesHook = Callable[[str], Awaitable[Optional[list[str]]]] _get_project_libraries_hook: Optional[GetProjectLibrariesHook] = None def register_get_project_libraries(hook: GetProjectLibrariesHook) -> None: """Install the project-manifest resolver. Called by overlays in register_pro.""" global _get_project_libraries_hook _get_project_libraries_hook = hook async def get_project_libraries(project_id: Optional[str]) -> Optional[list[str]]: if _get_project_libraries_hook is None or not project_id: return None try: return await _get_project_libraries_hook(project_id) except Exception: logger.exception("get_project_libraries hook failed (treating as no manifest)") return None # ── materialize_library_scope ───────────────────────────────────────────────── # Given a compile's library manifest (the per-board allowed set), materialize a # per-compile libraries directory the ESP-IDF resolver reads from — instead of # the single shared global libraries volume. The overlay symlinks each declared # library from the content-addressed cache (or, while the global volume is being # retired, the legacy dir as a fallback) into a throwaway dir and returns # (libraries_dir, content_token). The compiler folds the token into its build- # variant hash (so a content change resets the build cache) and removes the dir # after the attempt. Returns None when no overlay is loaded OR the manifest is # empty -> the compiler uses its single default libraries dir (OSS self-host # parity / scan-all). SYNC — pure filesystem (symlink creation). # `owner_id` is the project OWNER's id (NOT the requester's): a shared / embed / # anonymous compile of someone else's project must resolve THAT user's custom # (per-user-store) libraries. The overlay treats it as an opaque key; the OSS # compiler only threads it through. None for unsaved/anon-no-project compiles. MaterializeLibraryScopeHook = Callable[[set, Optional[str]], Optional[tuple]] _materialize_library_scope_hook: Optional[MaterializeLibraryScopeHook] = None def register_materialize_library_scope(hook: MaterializeLibraryScopeHook) -> None: """Install the per-compile library-scope materializer. Called in register_pro.""" global _materialize_library_scope_hook _materialize_library_scope_hook = hook def materialize_library_scope( allowed_libraries: Optional[set], owner_id: Optional[str] = None ) -> Optional[tuple]: """Return (libraries_dir, content_token) for the manifest, or None to use the compiler's default single libraries dir. Never raises (a failing materializer degrades to the default dir).""" if _materialize_library_scope_hook is None or not allowed_libraries: return None try: return _materialize_library_scope_hook(allowed_libraries, owner_id) except Exception: logger.exception("materialize_library_scope hook failed (using default libraries dir)") return None # ── get_project_owner ───────────────────────────────────────────────────────── # Resolve a project's OWNER user-id from its id (the value stored as user_id on # the project record). Used so a compile resolves the OWNER's per-user custom # libraries, not the requester's. Returns None for an unknown project or when no # overlay is loaded. GetProjectOwnerHook = Callable[[str], Awaitable[Optional[str]]] _get_project_owner_hook: Optional[GetProjectOwnerHook] = None def register_get_project_owner(hook: GetProjectOwnerHook) -> None: """Install the project-owner resolver. Called by overlays in register_pro.""" global _get_project_owner_hook _get_project_owner_hook = hook async def get_project_owner(project_id: Optional[str]) -> Optional[str]: if _get_project_owner_hook is None or not project_id: return None try: return await _get_project_owner_hook(project_id) except Exception: logger.exception("get_project_owner hook failed (treating as no owner)") return None # ── lifespan startup ────────────────────────────────────────────────────────── # Overlays that need to run async setup during FastAPI lifespan (DB init, # table creation, legacy column migrations, etc.) register a coroutine here. # main.py invokes run_lifespan_startup() once during lifespan; if no overlay # registered anything, nothing happens. LifespanStartupHook = Callable[[], Awaitable[None]] _lifespan_startup_hooks: list[LifespanStartupHook] = [] def register_lifespan_startup(hook: LifespanStartupHook) -> None: """Queue a coroutine to run during FastAPI lifespan startup.""" _lifespan_startup_hooks.append(hook) async def run_lifespan_startup() -> None: """Invoked once by main.py's lifespan. Runs hooks in registration order; a failing hook is logged but does not abort the others.""" for hook in _lifespan_startup_hooks: try: await hook() except Exception: logger.exception("lifespan startup hook %r failed (swallowed)", hook) # ── iot_gateway_gate ────────────────────────────────────────────────────────── # Decides whether a given request may use the private IoT gateway proxy. # OSS-default: allow everyone (the gateway is a free feature in the open # image). A private overlay (velxio-prod) registers a real implementation # that gates it to paid plans + grandfathered users. Returns None to allow, # or a `detail` dict that the route turns into a 402 response when blocking. IotGatewayGateHook = Callable[[Request], Awaitable[Optional[dict]]] _iot_gateway_gate_hook: Optional[IotGatewayGateHook] = None def register_iot_gateway_gate(hook: IotGatewayGateHook) -> None: """Install the IoT-gateway gate. Called by overlays in register_pro.""" global _iot_gateway_gate_hook _iot_gateway_gate_hook = hook async def iot_gateway_gate(request: Request) -> Optional[dict]: """Return None to allow the gateway request, or a detail dict to block it with 402. No-op (allow) when no overlay is loaded.""" if _iot_gateway_gate_hook is None: return None try: return await _iot_gateway_gate_hook(request) except Exception: # A failing gate must not take the gateway down — fail open. logger.exception("iot_gateway_gate hook failed (allowing request)") return None