Просмотр исходного кода

Merge pull request #2845 from pascalheidmann/refactor/modular-import

    (Refactor): modularize import ("Makerworld tab")
maziggy 4 дней назад
Родитель
Сommit
857647596a
33 измененных файлов с 2838 добавлено и 819 удалено
  1. 5 0
      .gitignore
  2. 2 4
      backend/app/api/routes/auth.py
  3. 17 96
      backend/app/api/routes/cloud.py
  4. 248 230
      backend/app/api/routes/makerworld.py
  5. 2 1
      backend/app/api/routes/slicer_presets.py
  6. 1 1
      backend/app/main.py
  7. 10 0
      backend/app/schemas/makerworld.py
  8. 118 0
      backend/app/services/bambu_cloud_credentials.py
  9. 1 1
      backend/app/services/github_backup.py
  10. 51 0
      backend/app/services/model_providers/__init__.py
  11. 327 0
      backend/app/services/model_providers/base.py
  12. 12 0
      backend/app/services/model_providers/makerworld/__init__.py
  13. 18 0
      backend/app/services/model_providers/makerworld/auth.py
  14. 44 0
      backend/app/services/model_providers/makerworld/errors.py
  15. 166 0
      backend/app/services/model_providers/makerworld/http.py
  16. 117 0
      backend/app/services/model_providers/makerworld/provider.py
  17. 187 230
      backend/app/services/model_providers/makerworld/service.py
  18. 82 0
      backend/app/services/model_providers/makerworld/url.py
  19. 60 0
      backend/app/services/model_providers/registry.py
  20. 1 1
      backend/app/services/preset_resolver.py
  21. 17 9
      backend/tests/integration/test_cloud_auth.py
  22. 5 5
      backend/tests/integration/test_cloud_token_auth_migration.py
  23. 60 38
      backend/tests/integration/test_makerworld_apikey_auth.py
  24. 180 0
      backend/tests/integration/test_makerworld_permission_gate.py
  25. 106 0
      backend/tests/unit/services/test_bambu_cloud_credentials.py
  26. 287 27
      backend/tests/unit/services/test_makerworld.py
  27. 221 0
      backend/tests/unit/services/test_model_provider_interface.py
  28. 2 1
      backend/tests/unit/test_cloud_captcha_2790.py
  29. 5 6
      backend/tests/unit/test_cloud_token_expiry.py
  30. 6 6
      backend/tests/unit/test_github_backup_cloud_profiles.py
  31. 408 161
      backend/tests/unit/test_makerworld_routes.py
  32. 2 2
      backend/tests/unit/test_makerworld_s3_tls.py
  33. 70 0
      backend/tests/unit/test_model_provider_registry.py

+ 5 - 0
.gitignore

@@ -98,3 +98,8 @@ security/
 
 
 test_pipeline_archive_source.3mf
 test_pipeline_archive_source.3mf
 test_pipeline_run_1.3mf
 test_pipeline_run_1.3mf
+
+# Python coverage artifacts
+.coverage
+.coverage.*
+htmlcov/

+ 2 - 4
backend/app/api/routes/auth.py

@@ -348,10 +348,8 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
             # (#2530). Only migrate when there is exactly one obvious owner:
             # (#2530). Only migrate when there is exactly one obvious owner:
             # handing another admin's session a Bambu credential is not a
             # handing another admin's session a Bambu credential is not a
             # guess worth making.
             # guess worth making.
-            from backend.app.api.routes.cloud import (
-                get_stored_token,
-                migrate_global_cloud_token_to_user,
-            )
+            from backend.app.api.routes.cloud import migrate_global_cloud_token_to_user
+            from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
             if admin_created:
             if admin_created:
                 cloud_owner = admin_user
                 cloud_owner = admin_user

+ 17 - 96
backend/app/api/routes/cloud.py

@@ -7,7 +7,6 @@ Handles authentication and profile management with Bambu Cloud.
 import asyncio
 import asyncio
 import json
 import json
 import logging
 import logging
-from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 from typing import Literal
 from typing import Literal
 
 
@@ -23,7 +22,7 @@ from backend.app.core.auth import (
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
     security,
     security,
 )
 )
-from backend.app.core.database import async_session, get_db
+from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -50,6 +49,22 @@ from backend.app.services.bambu_cloud import (
     BambuCloudService,
     BambuCloudService,
     invalidate_validation_cache,
     invalidate_validation_cache,
 )
 )
+
+# Credential read/write lives in the services layer so feature packages can
+# consume it without importing the route layer. Imported here for this
+# module's own use; consumers should import from bambu_cloud_credentials
+# directly rather than through this route module.
+from backend.app.services.bambu_cloud_credentials import (
+    CLOUD_EMAIL_KEY,
+    CLOUD_REGION_KEY,
+    CLOUD_TOKEN_INVALID_KEY,
+    CLOUD_TOKEN_KEY,
+    _clear_cloud_token_invalid,
+    _normalise_region,
+    get_stored_token,
+    is_cloud_token_invalid,
+    mark_cloud_token_invalid,
+)
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -166,100 +181,6 @@ async def resolve_api_key_cloud_owner(
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 
 
 
 
-# Keys for storing cloud credentials in settings
-CLOUD_TOKEN_KEY = "bambu_cloud_token"
-CLOUD_EMAIL_KEY = "bambu_cloud_email"
-CLOUD_REGION_KEY = "bambu_cloud_region"
-# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
-# an ISO timestamp; absent/empty means "not known to be dead".
-CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
-
-
-def _normalise_region(region: str | None) -> str:
-    """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
-    return region if region in ("global", "china") else "global"
-
-
-async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
-    """Whether the stored Bambu token is known to have been rejected.
-
-    Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
-    cleared on a fresh login/logout. This is the only durable record we have:
-    Bambu's access token is opaque (no readable expiry) and Bambuddy does not
-    persist the refresh token, so without this flag a dead credential looks
-    exactly like a live one.
-    """
-    if user is not None:
-        return user.cloud_token_invalid_at is not None
-    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
-    row = result.scalar_one_or_none()
-    return bool(row and row.value)
-
-
-async def mark_cloud_token_invalid(user_id: int | None) -> None:
-    """Record that Bambu rejected the stored token.
-
-    Opens its own session on purpose. This runs from
-    ``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
-    is about to fail — writing through that route's session would tie the flag
-    to a transaction the route may still roll back, and the fact that the
-    credential is dead is true regardless of how the request ends.
-
-    Best-effort: a bookkeeping failure must never replace the 401 the caller
-    actually needs to see.
-    """
-    now = datetime.now(timezone.utc)
-    try:
-        async with async_session() as db:
-            if user_id is not None:
-                await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
-            else:
-                result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
-                row = result.scalar_one_or_none()
-                if row:
-                    row.value = now.isoformat()
-                else:
-                    db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
-            await db.commit()
-        logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
-    except Exception:
-        logger.exception("Could not record the Bambu Cloud token as invalid")
-
-
-async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
-    """Clear the rejected-token flag — called on every fresh login and logout."""
-    if user is not None:
-        await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
-        return
-    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
-    row = result.scalar_one_or_none()
-    if row:
-        await db.delete(row)
-
-
-async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
-    """Get stored cloud token, email, and region.
-
-    When a user is provided (auth enabled), returns that user's per-user credentials.
-    When user is None (auth disabled), falls back to global Settings table.
-    Region defaults to ``"global"`` when unset (including for rows that predate
-    the ``cloud_region`` column).
-    """
-    if user is not None:
-        return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
-
-    # Fallback: global storage (auth disabled)
-    result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
-    )
-    settings = {s.key: s.value for s in result.scalars().all()}
-    return (
-        settings.get(CLOUD_TOKEN_KEY),
-        settings.get(CLOUD_EMAIL_KEY),
-        _normalise_region(settings.get(CLOUD_REGION_KEY)),
-    )
-
-
 async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
 async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
     """Store cloud token, email, and region.
     """Store cloud token, email, and region.
 
 

+ 248 - 230
backend/app/api/routes/makerworld.py

@@ -1,13 +1,20 @@
 """MakerWorld integration routes.
 """MakerWorld integration routes.
 
 
-User pastes a MakerWorld URL → Bambuddy resolves it → shows plate list →
-one-click import/print. The URL-paste flow covers the actual discovery
-pattern (Reddit/YouTube/shared links) without needing to replicate
-MakerWorld's whole search UI.
+User pastes a model URL (MakerWorld or other supported host) → Bambuddy resolves
+it → shows plate list → one-click import/print. The URL-paste flow covers the
+actual discovery pattern (Reddit/YouTube/shared links) without needing to
+replicate the host's whole search UI.
 
 
 Search/browse endpoints are intentionally NOT exposed: the public-facing
 Search/browse endpoints are intentionally NOT exposed: the public-facing
 ``design/search`` endpoint returns empty results from server-originated
 ``design/search`` endpoint returns empty results from server-originated
 requests (see memory/makerworld-integration.md for the investigation).
 requests (see memory/makerworld-integration.md for the investigation).
+
+These are still the *MakerWorld* routes: they consult the shared seams where
+one exists — URL routing via :class:`ModelProviderRegistry`, permissions and
+folder naming from the provider descriptor, already-imported matching via
+:meth:`ModelProvider.source_url_filter` — but request/response shapes remain
+MakerWorld-specific. The fully shared import API that makes new hosts work
+with zero route changes arrives with #2793.
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
@@ -16,19 +23,20 @@ import logging
 import os
 import os
 from urllib.parse import unquote
 from urllib.parse import unquote
 
 
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, Header, HTTPException, Query
 from fastapi.responses import Response
 from fastapi.responses import Response
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.api.routes.cloud import (
-    get_stored_token,
-    is_cloud_token_invalid,
-    mark_cloud_token_invalid,
-    resolve_api_key_cloud_owner,
-)
+from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library import save_3mf_bytes_to_library
 from backend.app.api.routes.library import save_3mf_bytes_to_library
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    require_auth_if_enabled,
+    require_permission_if_auth_enabled,
+    security,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
@@ -41,74 +49,119 @@ from backend.app.schemas.makerworld import (
     MakerWorldResolveRequest,
     MakerWorldResolveRequest,
     MakerWorldStatus,
     MakerWorldStatus,
 )
 )
-from backend.app.services.makerworld import (
-    MakerWorldAuthError,
-    MakerWorldError,
-    MakerWorldForbiddenError,
-    MakerWorldNotFoundError,
-    MakerWorldService,
-    MakerWorldUnavailableError,
-    MakerWorldUrlError,
+from backend.app.services.model_providers import makerworld_provider, registry
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderAuthError,
+    ProviderError,
+    ProviderForbiddenError,
+    ProviderNotFoundError,
+    ProviderResourceRef,
+    ProviderService,
+    ProviderUnavailableError,
+    ProviderUrlError,
 )
 )
+from backend.app.services.model_providers.makerworld.service import MakerWorldService
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/makerworld", tags=["makerworld"])
 router = APIRouter(prefix="/makerworld", tags=["makerworld"])
 
 
-_SOURCE_TYPE = "makerworld"
+
+def _provider_for_url(url: str) -> ModelProvider:
+    """Return the registered model provider that claims *url*.
+
+    A pasted link for an unsupported host is a clean 400 — the registry is
+    the routing seam, and "nobody supports this URL" is a client-input
+    problem, not a server error.
+    """
+    provider = registry.find_for_url(url)
+    if provider is None:
+        msg = f"No registered model provider supports {url!r}"
+        raise HTTPException(status_code=400, detail=msg)
+    return provider
 
 
 
 
-async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldService:
-    """Construct a per-request MakerWorldService seeded with the caller's
-    stored Bambu Cloud bearer token when available.
+def _provider_for_source(source_type: str) -> ModelProvider:
+    """Return the registered model provider with this ``source_type``.
 
 
-    Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
-    optional; anonymous calls (metadata, URL resolution) still work — and,
-    like it, records a rejected token so the whole app agrees the sign-in is
-    dead rather than each feature failing on its own.
+    Import identifies a resource by numeric id, not by URL, so there is
+    nothing to route on except the source type the caller names. The detail
+    is built here rather than via ``str(KeyError)`` — KeyError's ``__str__``
+    is the *repr* of its argument and would ship the quotes to the client.
     """
     """
-    token, _email, _region = await get_stored_token(db, user)
-    user_id = user.id if user is not None else None
-    return MakerWorldService(
-        auth_token=token,
-        on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
-    )
+    try:
+        return registry.get(source_type)
+    except KeyError as exc:
+        msg = f"No model provider registered for source_type {source_type!r}"
+        raise HTTPException(status_code=400, detail=msg) from exc
+
+
+async def _authorize_for_provider(
+    provider: ModelProvider,
+    permission: Permission | None,
+    credentials: HTTPAuthorizationCredentials | None,
+    x_api_key: str | None,
+) -> User | None:
+    """Apply *provider*'s own permission to a request that named it.
+
+    This cannot live in the route signature. FastAPI resolves dependencies
+    before the body exists, so a dependency can only ever bake in one
+    provider's permission — MakerWorld's — while the provider actually being
+    used comes from the request (``source_type`` on import, the pasted URL on
+    resolve). Importing from a second provider would then be gated on
+    ``makerworld:import``, which is nobody's intent.
+
+    The check runs through the same ``require_permission_if_auth_enabled``
+    the decorator would have built, so JWT users, API keys (scope gate plus
+    the owner-outranks-key rule) and auth-disabled installs behave exactly as
+    before. The routes keep a permission-free ``require_auth_if_enabled``
+    dependency so an anonymous caller is still refused before the body is
+    read.
+
+    A provider that declares no permission is refused rather than waved
+    through: the descriptor's permission fields are optional, and "unset"
+    must not read as "unrestricted".
+    """
+    if permission is None:
+        raise HTTPException(
+            status_code=500,
+            detail=f"Model provider {provider.source_type!r} declares no permission for this operation",
+        )
+    checker = require_permission_if_auth_enabled(permission)
+    return await checker(credentials=credentials, x_api_key=x_api_key)
 
 
 
 
-def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
-    """Build a stable source_url we use for dedupe.
+async def _build_service(
+    db: AsyncSession,
+    provider: ModelProvider,
+    current_user: User | None,
+    api_key_cloud_owner: User | None = None,
+) -> ProviderService:
+    """Construct a per-request service via *provider*.
 
 
-    Dedupe is keyed per *plate* (profile) rather than per model, since the
-    ``/iot-service/.../profile/{profileId}`` download returns a specific
-    plate — not the full multi-plate zip — so two different plates of the
-    same design should become two separate library entries. Canonical
-    shape uses the locale-free path with the ``#profileId-`` fragment so
-    all URL variants of the same plate still collapse (e.g. ``/en/models/
-    123-slug?from=search#profileId-456`` and ``/de/models/123#profileId-
-    456`` both map to ``https://makerworld.com/models/123#profileId-
-    456``). Plate-less imports (legacy or whole-design) keep the old
-    model-only shape for backwards compatibility with existing rows.
+    Identity resolution (JWT user vs API-key owner vs anonymous) and
+    credential seeding live inside ``provider.build_service`` — the single
+    place every provider resolves them, so the routes never re-implement it.
     """
     """
-    if profile_id:
-        return f"https://makerworld.com/models/{model_id}#profileId-{profile_id}"
-    return f"https://makerworld.com/models/{model_id}"
+    return await provider.build_service(db=db, user=current_user, api_key_owner=api_key_cloud_owner)
 
 
 
 
-def _map_service_error(exc: MakerWorldError) -> HTTPException:
-    """Translate service exceptions into HTTP responses."""
-    if isinstance(exc, MakerWorldUrlError):
+def _map_service_error(exc: ProviderError) -> HTTPException:
+    """Translate provider service exceptions into HTTP responses."""
+    if isinstance(exc, ProviderUrlError):
         return HTTPException(status_code=400, detail=str(exc))
         return HTTPException(status_code=400, detail=str(exc))
-    if isinstance(exc, MakerWorldAuthError):
+    if isinstance(exc, ProviderAuthError):
         return HTTPException(status_code=401, detail=str(exc))
         return HTTPException(status_code=401, detail=str(exc))
-    if isinstance(exc, MakerWorldForbiddenError):
-        # 403 forwards MakerWorld's own refusal message (content-gated,
+    if isinstance(exc, ProviderForbiddenError):
+        # 403 forwards the provider's own refusal message (content-gated,
         # region-locked, requires points, etc.) — UI surfaces it verbatim.
         # region-locked, requires points, etc.) — UI surfaces it verbatim.
         return HTTPException(status_code=403, detail=str(exc))
         return HTTPException(status_code=403, detail=str(exc))
-    if isinstance(exc, MakerWorldNotFoundError):
+    if isinstance(exc, ProviderNotFoundError):
         return HTTPException(status_code=404, detail=str(exc))
         return HTTPException(status_code=404, detail=str(exc))
-    if isinstance(exc, MakerWorldUnavailableError):
+    if isinstance(exc, ProviderUnavailableError):
         return HTTPException(status_code=502, detail=str(exc))
         return HTTPException(status_code=502, detail=str(exc))
-    return HTTPException(status_code=500, detail=f"MakerWorld error: {exc}")
+    return HTTPException(status_code=500, detail=f"Model provider error: {exc}")
 
 
 
 
 @router.get("/thumbnail")
 @router.get("/thumbnail")
@@ -133,10 +186,10 @@ async def proxy_thumbnail(
     URLs are content-addressable (filename contains a hash), so the
     URLs are content-addressable (filename contains a hash), so the
     aggressive ``immutable`` cache-control is safe.
     aggressive ``immutable`` cache-control is safe.
     """
     """
-    service = MakerWorldService()
+    service = MakerWorldService(thumbnail_hosts=makerworld_provider.thumbnail_hosts())
     try:
     try:
         payload, content_type = await service.fetch_thumbnail(url)
         payload, content_type = await service.fetch_thumbnail(url)
-    except MakerWorldError as exc:
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
     finally:
     finally:
         await service.close()
         await service.close()
@@ -153,7 +206,7 @@ async def proxy_thumbnail(
 @router.get("/status", response_model=MakerWorldStatus)
 @router.get("/status", response_model=MakerWorldStatus)
 async def get_status(
 async def get_status(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
 ):
     """Report whether the caller can import 3MFs (needs a Bambu Cloud token).
     """Report whether the caller can import 3MFs (needs a Bambu Cloud token).
@@ -164,26 +217,37 @@ async def get_status(
     stored token rather than always reporting ``False`` (#1777, same shape
     stored token rather than always reporting ``False`` (#1777, same shape
     as the cloud-presets fix in #1182).
     as the cloud-presets fix in #1182).
     """
     """
-    cloud_token_user = current_user or api_key_cloud_owner
-    token, _email, _region = await get_stored_token(db, cloud_token_user)
-    has_token = bool(token)
-    # A token Bambu has already rejected downloads nothing. ``can_download``
-    # used to be a bare alias for ``has_cloud_token``, so the import button
-    # stayed enabled against a dead credential and the user found out via a
-    # 401 toast (#2562 follow-up).
-    expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
+    service = await _build_service(db, makerworld_provider, current_user, api_key_cloud_owner)
+    try:
+        status = await service.get_status(db)
+    finally:
+        await service.close()
     return MakerWorldStatus(
     return MakerWorldStatus(
-        has_cloud_token=has_token,
-        can_download=has_token and not expired,
-        sign_in_expired=expired,
+        has_cloud_token=status.authenticated,
+        can_download=status.can_download,
+        # ``credential_rejected`` is the machine-readable "your sign-in
+        # expired" state the provider set exactly when a stored token exists
+        # *and* was rejected — no token means there is no sign-in to have
+        # expired. It is read instead of ``auth_error is not None`` because
+        # the latter is a human-readable reason that providers may also set
+        # for non-credential failures (network, rate limit).
+        sign_in_expired=status.credential_rejected,
     )
     )
 
 
 
 
-@router.post("/resolve", response_model=MakerWorldResolvedModel)
+@router.post(
+    "/resolve",
+    response_model=MakerWorldResolvedModel,
+    # Authentication only — the permission belongs to whichever provider the
+    # pasted URL routes to, which is not known until the body is parsed (see
+    # ``_authorize_for_provider``).
+    dependencies=[Depends(require_auth_if_enabled)],
+)
 async def resolve_url(
 async def resolve_url(
     body: MakerWorldResolveRequest,
     body: MakerWorldResolveRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
 ):
     """Resolve a MakerWorld URL to full model metadata + plate list.
     """Resolve a MakerWorld URL to full model metadata + plate list.
@@ -192,68 +256,34 @@ async def resolve_url(
     exist for the same model URL, so the UI can show an "Already imported"
     exist for the same model URL, so the UI can show an "Already imported"
     badge and skip a redundant download.
     badge and skip a redundant download.
     """
     """
+    # Strategy pattern: select provider based on URL instead of hardcoding.
+    # Routing runs before the permission check because the permission *is* the
+    # provider's; all an unpermitted caller learns from the ordering is which
+    # hosts Bambuddy supports, which the UI states anyway.
+    provider = _provider_for_url(body.url)
+    current_user = await _authorize_for_provider(provider, provider.view_permission, credentials, x_api_key)
     try:
     try:
-        model_id, profile_id = MakerWorldService.parse_url(body.url)
-    except MakerWorldError as exc:
+        ref = provider.parse_url(body.url)
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
+    model_id = int(ref.external_id)
+    profile_id = int(ref.sub_id) if ref.sub_id else None
 
 
-    # API-keyed callers carry identity on the key, not in current_user — see
-    # the /status handler comment and #1777 / #1182.
-    cloud_token_user = current_user or api_key_cloud_owner
-    service = await _build_service(db, cloud_token_user)
+    service = await _build_service(db, provider, current_user, api_key_cloud_owner)
     try:
     try:
-        design = await service.get_design(model_id)
-        instances_envelope = await service.get_design_instances(model_id)
-    except MakerWorldError as exc:
+        resolved = await service.resolve(ref)
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
     finally:
     finally:
         await service.close()
         await service.close()
 
 
-    # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``; callers
-    # only care about the hits, and we normalise the null case to an empty list
-    # so the frontend doesn't have to handle null vs [] both ways.
-    instances = instances_envelope.get("hits") or []
-    if not isinstance(instances, list):
-        instances = []
-
-    # /instances/hits omits the per-instance printer compatibility info that
-    # /design.instances[].extention.modelInfo carries (compatibility +
-    # otherCompatibility). Merge it in so the frontend can show "this
-    # instance was sliced for A1" + "also marked compatible with: H2D, P1S,
-    # …" before the user picks one — without that, every instance row looks
-    # identical in the UI and users blindly pick the first one regardless of
-    # whether it matches their printer.
-    design_instances = design.get("instances") or []
-    if isinstance(design_instances, list):
-        compat_by_id = {}
-        for di in design_instances:
-            if not isinstance(di, dict):
-                continue
-            iid = di.get("id")
-            if iid is None:
-                continue
-            ext = (di.get("extention") or {}).get("modelInfo") or {}
-            compat_by_id[iid] = {
-                "compatibility": ext.get("compatibility"),
-                "otherCompatibility": ext.get("otherCompatibility"),
-            }
-        for inst in instances:
-            if not isinstance(inst, dict):
-                continue
-            iid = inst.get("id")
-            extra = compat_by_id.get(iid)
-            if extra:
-                inst["compatibility"] = extra["compatibility"]
-                inst["otherCompatibility"] = extra["otherCompatibility"]
-
-    # Find every library row whose source_url is either the model-level
-    # canonical URL (legacy whole-model imports) or any plate-level URL
-    # (``...#profileId-{n}``) under this model. The frontend surfaces this
+    # Find every library row whose source_url belongs to this resource —
+    # the provider's :meth:`source_url_filter` owns what "belongs" means
+    # (whole-model key, per-plate keys, ...). The frontend surfaces the ids
     # to mark imported plates in the instance picker.
     # to mark imported plates in the instance picker.
-    model_prefix = _canonical_url(model_id)
     existing_q = await db.execute(
     existing_q = await db.execute(
         select(LibraryFile.id).where(
         select(LibraryFile.id).where(
-            (LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%")),
+            provider.source_url_filter(LibraryFile.source_url, str(model_id)),
             LibraryFile.deleted_at.is_(None),
             LibraryFile.deleted_at.is_(None),
         )
         )
     )
     )
@@ -262,17 +292,24 @@ async def resolve_url(
     return MakerWorldResolvedModel(
     return MakerWorldResolvedModel(
         model_id=model_id,
         model_id=model_id,
         profile_id=profile_id,
         profile_id=profile_id,
-        design=design,
-        instances=instances,
+        design=resolved.design,
+        instances=resolved.instances,
         already_imported_library_ids=already_imported,
         already_imported_library_ids=already_imported,
     )
     )
 
 
 
 
-@router.post("/import", response_model=MakerWorldImportResponse)
+@router.post(
+    "/import",
+    response_model=MakerWorldImportResponse,
+    # Authentication only — the permission belongs to the provider named by
+    # ``source_type`` (see ``_authorize_for_provider``).
+    dependencies=[Depends(require_auth_if_enabled)],
+)
 async def import_instance(
 async def import_instance(
     body: MakerWorldImportRequest,
     body: MakerWorldImportRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
 ):
     """Download a specific MakerWorld instance (plate configuration) and save
     """Download a specific MakerWorld instance (plate configuration) and save
@@ -282,6 +319,15 @@ async def import_instance(
     was imported before (any plate), that existing LibraryFile is returned and
     was imported before (any plate), that existing LibraryFile is returned and
     no new download happens.
     no new download happens.
     """
     """
+    # Resolve the provider first: an unknown ``source_type`` must 400 before
+    # the default-destination folder gets auto-created as a side effect — and
+    # the permission that applies is the resolved provider's, not MakerWorld's,
+    # so it cannot be checked any earlier. All that costs is telling an
+    # authenticated-but-unpermitted caller which source types are registered,
+    # which the UI lists anyway; anonymous callers never get this far.
+    provider = _provider_for_source(body.source_type)
+    current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key)
+
     if body.folder_id is not None:
     if body.folder_id is not None:
         folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
         folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
         target_folder = folder_q.scalar_one_or_none()
         target_folder = folder_q.scalar_one_or_none()
@@ -294,90 +340,78 @@ async def import_instance(
             )
             )
         effective_folder_id: int | None = body.folder_id
         effective_folder_id: int | None = body.folder_id
     else:
     else:
-        # Default destination: a dedicated top-level "MakerWorld" folder. Keeps
-        # imports out of the library root so power users can still organise
-        # manually in subfolders, and auto-creates the folder on the first
-        # import so users don't have to set it up themselves.
-        mw_folder_q = await db.execute(
-            select(LibraryFolder).where(
-                LibraryFolder.name == "MakerWorld",
-                LibraryFolder.parent_id.is_(None),
-                LibraryFolder.is_external.is_(False),
+        # Default destination: the resolved provider's dedicated top-level
+        # folder (``default_folder_name`` — read off *provider*, not the
+        # MakerWorld singleton, so the second provider lands in its own
+        # folder). Keeps imports out of the library root so power users can
+        # still organise manually in subfolders, and auto-creates the folder
+        # on the first import so users don't have to set it up themselves. A
+        # provider that leaves it unset imports into the library root rather
+        # than minting a NULL-named folder.
+        default_folder_name = provider.default_folder_name
+        if default_folder_name is None:
+            effective_folder_id = None
+        else:
+            default_folder_q = await db.execute(
+                select(LibraryFolder).where(
+                    LibraryFolder.name == default_folder_name,
+                    LibraryFolder.parent_id.is_(None),
+                    LibraryFolder.is_external.is_(False),
+                )
             )
             )
-        )
-        mw_folder = mw_folder_q.scalar_one_or_none()
-        if mw_folder is None:
-            mw_folder = LibraryFolder(name="MakerWorld", parent_id=None)
-            db.add(mw_folder)
-            await db.flush()
-        effective_folder_id = mw_folder.id
-
-    # API-keyed callers carry identity on the key, not in current_user — see
-    # the /status handler comment and #1777 / #1182. The same resolved user
-    # is reused for owner_id on save_3mf_bytes_to_library below so the
-    # library row is attributed to the key's owner rather than NULL.
-    cloud_token_user = current_user or api_key_cloud_owner
-    service = await _build_service(db, cloud_token_user)
+            default_folder = default_folder_q.scalar_one_or_none()
+            if default_folder is None:
+                default_folder = LibraryFolder(name=default_folder_name, parent_id=None)
+                db.add(default_folder)
+                await db.flush()
+            effective_folder_id = default_folder.id
+
+    service = await _build_service(db, provider, current_user, api_key_cloud_owner)
 
 
     # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
     # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
-    # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
-    # Fetch design metadata to resolve it, and — in the same call — pick a
-    # default profileId from the response if the frontend didn't specify one.
+    # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N} —
+    # resolving that, plus picking a default profile when the frontend didn't
+    # specify one, lives inside ``get_download``. The route only orchestrates
+    # dedupe + persistence so every provider shares those concerns here.
+    ref = ProviderResourceRef(
+        source_type=provider.source_type,
+        external_id=str(body.model_id),
+        sub_id=str(body.profile_id) if body.profile_id else None,
+    )
+
     try:
     try:
-        design = await service.get_design(body.model_id)
-    except MakerWorldError as exc:
-        await service.close()
-        raise _map_service_error(exc) from exc
+        info = await service.get_download(ref)
+        # The provider enriches ``sub_id`` with the actually-resolved profile
+        # when the caller omitted one.
+        resolved_profile_id = int(info.ref.sub_id) if info.ref.sub_id else None
 
 
-    alphanumeric_model_id = design.get("modelId")
-    if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
-        await service.close()
-        raise HTTPException(
-            status_code=502,
-            detail="MakerWorld design metadata missing the modelId field",
-        )
+        # Canonical URL includes profile_id so each plate gets its own library
+        # entry (see ``ModelProvider.canonical_url``).
+        source_url = provider.canonical_url(info.ref)
 
 
-    profile_id = body.profile_id
-    if profile_id is None:
-        for instance in design.get("instances") or []:
-            pid = instance.get("profileId")
-            if isinstance(pid, int) and pid > 0:
-                profile_id = pid
-                break
-        if profile_id is None:
-            try:
-                envelope = await service.get_design_instances(body.model_id)
-            except MakerWorldError as exc:
-                await service.close()
-                raise _map_service_error(exc) from exc
-            for hit in envelope.get("hits") or []:
-                pid = hit.get("profileId")
-                if isinstance(pid, int) and pid > 0:
-                    profile_id = pid
-                    break
-        if profile_id is None:
-            await service.close()
-            raise HTTPException(
-                status_code=502,
-                detail="MakerWorld returned no instances for this model",
+        # Dedupe check upfront so we don't burn bandwidth re-downloading.
+        existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
+        existing_row = existing_q.scalar_one_or_none()
+        if existing_row is not None:
+            return MakerWorldImportResponse(
+                library_file_id=existing_row.id,
+                filename=existing_row.filename,
+                folder_id=existing_row.folder_id,
+                profile_id=resolved_profile_id,
+                was_existing=True,
             )
             )
 
 
-    # Canonical URL includes profile_id so each plate gets its own library
-    # entry (see ``_canonical_url`` docstring).
-    source_url = _canonical_url(body.model_id, profile_id)
-
-    try:
-        manifest = await service.get_profile_download(profile_id, alphanumeric_model_id)
-    except MakerWorldError as exc:
-        await service.close()
+        download = await service.download(info)
+    except ProviderError as exc:
         raise _map_service_error(exc) from exc
         raise _map_service_error(exc) from exc
+    finally:
+        await service.close()
 
 
-    signed_url = manifest.get("url")
     # Basename-strip any path components from the upstream filename so a
     # Basename-strip any path components from the upstream filename so a
     # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
     # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
     # string into the library row or the UI. On-disk storage uses a UUID
     # string into the library row or the UI. On-disk storage uses a UUID
     # filename regardless (see library.py), so this is defence-in-depth.
     # filename regardless (see library.py), so this is defence-in-depth.
-    raw_name = manifest.get("name")
+    raw_name = info.suggested_filename
     if isinstance(raw_name, str) and raw_name.strip():
     if isinstance(raw_name, str) and raw_name.strip():
         # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
         # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
         # because the same string round-trips through HTTP URLs in the
         # because the same string round-trips through HTTP URLs in the
@@ -387,44 +421,24 @@ async def import_instance(
         suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
         suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
     else:
     else:
         suggested_name = f"makerworld-{body.model_id}.3mf"
         suggested_name = f"makerworld-{body.model_id}.3mf"
-    if not signed_url or not isinstance(signed_url, str):
-        await service.close()
-        raise HTTPException(status_code=502, detail="MakerWorld did not return a download URL")
-
-    # Dedupe check upfront so we don't burn bandwidth re-downloading.
-    if source_url:
-        existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
-        existing_row = existing_q.scalar_one_or_none()
-        if existing_row is not None:
-            await service.close()
-            return MakerWorldImportResponse(
-                library_file_id=existing_row.id,
-                filename=existing_row.filename,
-                folder_id=existing_row.folder_id,
-                profile_id=profile_id,
-                was_existing=True,
-            )
-
-    try:
-        file_bytes, download_filename = await service.download_3mf(signed_url)
-    except MakerWorldError as exc:
-        await service.close()
-        raise _map_service_error(exc) from exc
-    finally:
-        await service.close()
 
 
     # Prefer the server-provided human-readable filename; the signed URL's
     # Prefer the server-provided human-readable filename; the signed URL's
     # path ends in a UUID that's not meaningful to users. Decode the
     # path ends in a UUID that's not meaningful to users. Decode the
     # fallback path-tail too — same percent-encoding round-trip applies
     # fallback path-tail too — same percent-encoding round-trip applies
     # there as on the manifest-supplied name.
     # there as on the manifest-supplied name.
-    filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download_filename)
+    filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
 
 
+    # API-keyed callers carry identity on the key, not in current_user (#1777);
+    # this collapse stays route-side solely so the library row is attributed
+    # to the key's owner rather than NULL. Credential identity is resolved
+    # inside the provider.
+    cloud_token_user = current_user or api_key_cloud_owner
     library_file, was_existing = await save_3mf_bytes_to_library(
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,
         db,
-        file_bytes=file_bytes,
+        file_bytes=download.file_bytes,
         filename=filename,
         filename=filename,
         folder_id=effective_folder_id,
         folder_id=effective_folder_id,
-        source_type=_SOURCE_TYPE,
+        source_type=provider.source_type,
         source_url=source_url,
         source_url=source_url,
         owner_id=cloud_token_user.id if cloud_token_user else None,
         owner_id=cloud_token_user.id if cloud_token_user else None,
     )
     )
@@ -433,7 +447,7 @@ async def import_instance(
         library_file_id=library_file.id,
         library_file_id=library_file.id,
         filename=library_file.filename,
         filename=library_file.filename,
         folder_id=library_file.folder_id,
         folder_id=library_file.folder_id,
-        profile_id=profile_id,
+        profile_id=resolved_profile_id,
         was_existing=was_existing,
         was_existing=was_existing,
     )
     )
 
 
@@ -442,23 +456,27 @@ async def import_instance(
 async def recent_imports(
 async def recent_imports(
     limit: int = 10,
     limit: int = 10,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
 ):
 ):
     """Last N MakerWorld imports, newest first.
     """Last N MakerWorld imports, newest first.
 
 
     Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
     Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
     page can show a 'Recent imports' sidebar that persists across resolves.
     page can show a 'Recent imports' sidebar that persists across resolves.
+    Widening this to all registered providers is a behaviour change that
+    belongs with the provider that needs it.
     ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
     ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
     """
     """
     _ = current_user  # permission gate only
     _ = current_user  # permission gate only
     capped = max(1, min(50, int(limit)))
     capped = max(1, min(50, int(limit)))
+
     result = await db.execute(
     result = await db.execute(
         LibraryFile.active()
         LibraryFile.active()
-        .where(LibraryFile.source_type == _SOURCE_TYPE)
+        .where(LibraryFile.source_type == makerworld_provider.source_type)
         .order_by(LibraryFile.created_at.desc())
         .order_by(LibraryFile.created_at.desc())
         .limit(capped)
         .limit(capped)
     )
     )
     rows = result.scalars().all()
     rows = result.scalars().all()
+
     return [
     return [
         MakerWorldRecentImport(
         MakerWorldRecentImport(
             library_file_id=row.id,
             library_file_id=row.id,

+ 2 - 1
backend/app/api/routes/slicer_presets.py

@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
+from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.orca_cloud import (
 from backend.app.api.routes.orca_cloud import (
     _ORCA_TYPE_TO_BAMBU,
     _ORCA_TYPE_TO_BAMBU,
     _build_authenticated_service as _build_orca_service,
     _build_authenticated_service as _build_orca_service,
@@ -43,6 +43,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     BambuCloudService,
 )
 )
+from backend.app.services.bambu_cloud_credentials import get_stored_token
 from backend.app.services.orca_cloud import (
 from backend.app.services.orca_cloud import (
     OrcaCloudAuthError,
     OrcaCloudAuthError,
     OrcaCloudError,
     OrcaCloudError,

+ 1 - 1
backend/app/main.py

@@ -8925,7 +8925,7 @@ async def lifespan(app: FastAPI):
     import httpx as _httpx
     import httpx as _httpx
 
 
     from backend.app.services.bambu_cloud import set_shared_http_client
     from backend.app.services.bambu_cloud import set_shared_http_client
-    from backend.app.services.makerworld import (
+    from backend.app.services.model_providers.makerworld.service import (
         set_shared_http_client as set_shared_makerworld_http_client,
         set_shared_http_client as set_shared_makerworld_http_client,
     )
     )
     from backend.app.services.orca_cloud import (
     from backend.app.services.orca_cloud import (

+ 10 - 0
backend/app/schemas/makerworld.py

@@ -42,6 +42,16 @@ class MakerWorldImportRequest(BaseModel):
         ...,
         ...,
         description="The MakerWorld design ID (the number in /models/{id}).",
         description="The MakerWorld design ID (the number in /models/{id}).",
     )
     )
+    source_type: str = Field(
+        default="makerworld",
+        description=(
+            "Which registered model provider owns the resource. Import "
+            "identifies a model by numeric id rather than URL, so there is no "
+            "URL for the provider registry to route on — the caller names the "
+            "provider instead. Defaults to 'makerworld' so existing callers "
+            "stay unchanged."
+        ),
+    )
     profile_id: int | None = Field(
     profile_id: int | None = Field(
         default=None,
         default=None,
         description=(
         description=(

+ 118 - 0
backend/app/services/bambu_cloud_credentials.py

@@ -0,0 +1,118 @@
+"""Bambu Cloud credential storage.
+
+Single seam for reading and bookkeeping the stored Bambu Cloud bearer token:
+per-user columns when auth is enabled, global ``Settings`` rows otherwise
+(auth-disabled single-user installs). Lives in the services layer so feature
+packages (e.g. ``model_providers``) can consume credentials without importing
+the route layer — routes are just one consumer among several here.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import async_session
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+# Keys for storing cloud credentials in settings
+CLOUD_TOKEN_KEY = "bambu_cloud_token"
+CLOUD_EMAIL_KEY = "bambu_cloud_email"
+CLOUD_REGION_KEY = "bambu_cloud_region"
+# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
+# an ISO timestamp; absent/empty means "not known to be dead".
+CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
+
+
+def _normalise_region(region: str | None) -> str:
+    """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
+    return region if region in ("global", "china") else "global"
+
+
+async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
+    """Whether the stored Bambu token is known to have been rejected.
+
+    Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
+    cleared on a fresh login/logout. This is the only durable record we have:
+    Bambu's access token is opaque (no readable expiry) and Bambuddy does not
+    persist the refresh token, so without this flag a dead credential looks
+    exactly like a live one.
+    """
+    if user is not None:
+        return user.cloud_token_invalid_at is not None
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    return bool(row and row.value)
+
+
+async def mark_cloud_token_invalid(user_id: int | None) -> None:
+    """Record that Bambu rejected the stored token.
+
+    Opens its own session on purpose. This runs from
+    ``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
+    is about to fail — writing through that route's session would tie the flag
+    to a transaction the route may still roll back, and the fact that the
+    credential is dead is true regardless of how the request ends.
+
+    Best-effort: a bookkeeping failure must never replace the 401 the caller
+    actually needs to see. ``user_id=None`` (auth-disabled single-user setup)
+    records the global flag — those installs *do* hold a token
+    (:func:`get_stored_token` reads it from ``Settings``), so the rejection
+    must land somewhere the status endpoints can see it.
+    """
+    now = datetime.now(timezone.utc)
+    try:
+        async with async_session() as db:
+            if user_id is not None:
+                await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
+            else:
+                result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+                row = result.scalar_one_or_none()
+                if row:
+                    row.value = now.isoformat()
+                else:
+                    db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
+            await db.commit()
+        logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
+    except Exception:
+        logger.exception("Could not record the Bambu Cloud token as invalid")
+
+
+async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
+    """Clear the rejected-token flag — called on every fresh login and logout."""
+    if user is not None:
+        await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
+        return
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    if row:
+        await db.delete(row)
+
+
+async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
+    """Get stored cloud token, email, and region.
+
+    When a user is provided (auth enabled), returns that user's per-user credentials.
+    When user is None (auth disabled), falls back to global Settings table.
+    Region defaults to ``"global"`` when unset (including for rows that predate the
+    ``cloud_region`` column).
+    """
+    if user is not None:
+        return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
+
+    # Fallback: global storage (auth disabled)
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
+    settings = {s.key: s.value for s in result.scalars().all()}
+    return (
+        settings.get(CLOUD_TOKEN_KEY),
+        settings.get(CLOUD_EMAIL_KEY),
+        _normalise_region(settings.get(CLOUD_REGION_KEY)),
+    )

+ 1 - 1
backend/app/services/github_backup.py

@@ -535,8 +535,8 @@ class GitHubBackupService:
         Both stores are read regardless: a ``Settings`` row survives enabling
         Both stores are read regardless: a ``Settings`` row survives enabling
         auth later, and dropping it silently would lose that account's presets.
         auth later, and dropping it silently would lose that account's presets.
         """
         """
-        from backend.app.api.routes.cloud import get_stored_token
         from backend.app.api.routes.orca_cloud import _load_credentials
         from backend.app.api.routes.orca_cloud import _load_credentials
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         bambu: list = []
         bambu: list = []
         orca: list = []
         orca: list = []

+ 51 - 0
backend/app/services/model_providers/__init__.py

@@ -0,0 +1,51 @@
+"""Model-provider interface + registry.
+
+The shared seam for "import a model from a 3D model website". Providers
+implement the interface (a ``ModelProvider`` descriptor + a per-request
+``ProviderService`` transport) and register an instance here; the registry
+routes pasted URLs to the owning provider via ``find_for_url``. MakerWorld is
+the first (and currently only) registered provider.
+"""
+
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderAuthConfig,
+    ProviderAuthError,
+    ProviderAuthType,
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderError,
+    ProviderForbiddenError,
+    ProviderNotFoundError,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+    ProviderService,
+    ProviderStatus,
+    ProviderUnavailableError,
+    ProviderUrlError,
+)
+from backend.app.services.model_providers.makerworld import makerworld_provider
+from backend.app.services.model_providers.registry import ModelProviderRegistry, registry
+
+registry.register(makerworld_provider)
+
+__all__ = [
+    "ModelProvider",
+    "ModelProviderRegistry",
+    "ProviderAuthConfig",
+    "ProviderAuthError",
+    "ProviderAuthType",
+    "ProviderDownload",
+    "ProviderDownloadInfo",
+    "ProviderError",
+    "ProviderForbiddenError",
+    "ProviderNotFoundError",
+    "ProviderResolvedModel",
+    "ProviderResourceRef",
+    "ProviderService",
+    "ProviderStatus",
+    "ProviderUnavailableError",
+    "ProviderUrlError",
+    "makerworld_provider",
+    "registry",
+]

+ 327 - 0
backend/app/services/model_providers/base.py

@@ -0,0 +1,327 @@
+"""Model-provider interface.
+
+A *model provider* is a website that hosts 3D printer models (MakerWorld,
+Thingiverse, Printables, ...) whose files Bambuddy can resolve and import
+into the library. This module defines the contract every provider must
+fulfil — the split being:
+
+  * :class:`ModelProvider` — the static, provider-wide descriptor: identity
+    (``source_type``, ``display_name``), URL routing (``host_patterns``),
+    the auth it needs (or explicitly doesn't), and a factory that builds a
+    per-request :class:`ProviderService` seeded with the caller's stored
+    credentials.
+  * :class:`ProviderService` — one HTTP client per request, mirroring the
+    ``BambuCloudService`` construction pattern: resolve a model URL to
+    metadata + importable files, resolve + fetch a concrete download, and
+    proxy thumbnail images. Providers are *thin transports*: shared concerns
+    (library dedupe, folder auto-creation, ``save_3mf_bytes_to_library``)
+    stay in the route layer so every provider benefits from them.
+
+The interface deliberately covers everything the MakerWorld integration
+needs today (see ``model_providers/makerworld/``) so that adding a new site
+is: implement ``ModelProvider`` + ``ProviderService``, register it, and the
+shared import API routes pasted URLs to it via ``registry.find_for_url``.
+
+Only interoperability — not affiliated with or endorsed by MakerWorld or any
+other provider, and not intended to circumvent any access control.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlparse
+
+import httpx
+
+from backend.app.core.compat import StrEnum
+
+if TYPE_CHECKING:
+    from sqlalchemy.ext.asyncio import AsyncSession
+
+    from backend.app.core.permissions import Permission
+    from backend.app.models.user import User
+
+
+class ProviderAuthType(StrEnum):
+    """The kind of credentials a model provider may (optionally) require."""
+
+    NONE = "none"
+    ACCESS_TOKEN = "access_token"
+    USERNAME_PASSWORD = "username_password"
+    BAMBU_CLOUD_BEARER = "bambu_cloud_bearer"  # MakerWorld today: shared Bambu Cloud token
+    COOKIE = "cookie"  # reserved for sites without a first-party API
+
+
+@dataclass(frozen=True)
+class ProviderAuthConfig:
+    """Declarative description of a provider's authentication requirement.
+
+    Describes *what* the provider needs so the UI can prompt for it; the
+    actual storage/retrieval of credentials stays provider-specific for now
+    (MakerWorld reads the Bambu Cloud token the user already configured).
+    ``credential_fields`` names the inputs a future generic credential vault
+    would collect (e.g. ``("access_token",)`` or ``("username", "password")``).
+    """
+
+    auth_type: ProviderAuthType
+    display_label: str
+    description: str = ""
+    credential_fields: tuple[str, ...] = ()
+    setup_hint: str = ""
+
+
+@dataclass(frozen=True)
+class ProviderResourceRef:
+    """Provider-agnostic handle for one model resource.
+
+    ``external_id`` is the provider-native model identifier (MakerWorld's
+    integer design id as a string); ``sub_id`` is an optional secondary key
+    such as MakerWorld's ``profileId`` for a specific plate.
+
+    Both ids must be **numeric strings** today: the shared route layer casts
+    them with ``int()`` when shaping API responses. Providers whose native
+    ids are not numeric need route-layer changes first — keep this contract
+    in mind when implementing one.
+    """
+
+    source_type: str
+    external_id: str
+    sub_id: str | None = None
+    original_url: str | None = None
+
+
+@dataclass
+class ProviderStatus:
+    """Whether the caller can use this provider right now.
+
+    ``auth_error`` carries a human-readable reason when the caller is signed
+    in but the stored credential has been rejected (e.g. expired); ``None``
+    when there is no error to report. ``credential_rejected`` is the
+    machine-readable counterpart — set exactly when the stored credential
+    exists *and* was refused by the provider — so callers (e.g. a route
+    reporting "sign-in expired") never have to infer it from ``auth_error``,
+    which may legitimately be set for other failures (network, rate limit).
+    """
+
+    authenticated: bool
+    can_download: bool
+    auth_error: str | None = None
+    credential_rejected: bool = False
+
+
+@dataclass
+class ProviderResolvedModel:
+    """Result of resolving a model URL.
+
+    ``design`` and ``instances`` are provider-specific dicts passed through
+    verbatim — the frontend reads fields a provider may add over time, so we
+    don't re-shape them here. Which library rows already hold this resource
+    is the route layer's concern (it owns the library query) and stays out of
+    the resolved payload.
+    """
+
+    ref: ProviderResourceRef
+    design: dict[str, Any]
+    instances: list[dict[str, Any]] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class ProviderDownloadInfo:
+    """A concrete, short-lived download for one file/plate.
+
+    ``ref`` may be enriched by the provider with the ``sub_id`` it resolved
+    (e.g. the actual MakerWorld profile selected when the caller omitted
+    one) so the route can build the canonical dedupe URL.
+    """
+
+    ref: ProviderResourceRef
+    url: str
+    suggested_filename: str
+
+
+@dataclass
+class ProviderDownload:
+    """Downloaded file bytes plus the final suggested filename."""
+
+    file_bytes: bytes
+    filename: str
+
+
+class ProviderError(Exception):
+    """Base exception for model-provider API errors."""
+
+
+class ProviderAuthError(ProviderError):
+    """Raised when a provider requires credentials and we have none (or the
+    stored one was rejected). True auth failure."""
+
+
+class ProviderForbiddenError(ProviderError):
+    """Raised when a provider refuses access despite valid authentication —
+    content-gated (purchase/points required, region restricted, ...)."""
+
+
+class ProviderNotFoundError(ProviderError):
+    """Raised when a model / file / profile doesn't exist."""
+
+
+class ProviderUnavailableError(ProviderError):
+    """Raised on 5xx, network errors, or malformed payloads."""
+
+
+class ProviderUrlError(ProviderError):
+    """Raised when a URL isn't a model page of this provider."""
+
+
+class ModelProvider(ABC):
+    """Static descriptor + factory for one model-hosting site.
+
+    Instances are shared (one per provider); all mutable state lives in the
+    per-request :class:`ProviderService` built by :meth:`build_service`.
+    """
+
+    source_type: str
+    display_name: str
+    host_patterns: tuple[str, ...] = ()
+    auth: ProviderAuthConfig | None = None
+    #: Top-level library folder imports land in when the caller names no
+    #: folder. ``None`` imports into the library root — the route will not
+    #: mint a folder without a name.
+    default_folder_name: str | None = None
+    #: The permissions the routes enforce for this provider's read and import
+    #: operations. Optional only so the base class has a default: a provider
+    #: that leaves them unset is refused at the gate rather than treated as
+    #: unrestricted (see ``makerworld._authorize_for_provider``).
+    view_permission: Permission | None = None
+    import_permission: Permission | None = None
+
+    @abstractmethod
+    async def build_service(
+        self,
+        *,
+        db: AsyncSession,
+        user: User | None,
+        api_key_owner: User | None = None,
+        client: httpx.AsyncClient | None = None,
+    ) -> ProviderService:
+        """Build a per-request service seeded with the caller's credentials.
+
+        ``api_key_owner`` is the API key's owning user for API-keyed calls
+        (see ``resolve_api_key_cloud_owner``); providers use it as the
+        fallback identity when ``user`` is None.
+        """
+
+    @abstractmethod
+    def parse_url(self, url: str) -> ProviderResourceRef:
+        """Extract a :class:`ProviderResourceRef` from a model URL.
+
+        Raises :class:`ProviderUrlError` when the URL isn't a model page of
+        this provider.
+        """
+
+    @abstractmethod
+    def canonical_url(self, ref: ProviderResourceRef) -> str:
+        """Stable dedupe key for a resource (library ``source_url``).
+
+        All URL variants of the same resource must collapse to this string;
+        different resources (e.g. different plates of one model) must differ.
+        """
+
+    def source_url_filter(self, column: Any, external_id: str) -> Any:
+        """SQL predicate over ``LibraryFile.source_url`` selecting every row
+        that belongs to this resource — the whole-model canonical URL plus,
+        when the provider keys dedupe per sub-resource (plate/profile), every
+        such variant. Drives the resolve flow's already-imported detection.
+
+        The default matches the model-level canonical URL only; providers with
+        recognisable per-plate URL shapes override this (see MakerWorld).
+        """
+        prefix = self.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
+        return column == prefix
+
+    def supports_url(self, url: str) -> bool:
+        """Whether ``url`` points at this provider (host-suffix match).
+
+        Accepts scheme-less input (``makerworld.com/models/1``) the same way
+        :meth:`parse_url` does, so ``find_for_url`` routes exactly the URLs
+        the provider will then accept.
+        """
+        if not url or not isinstance(url, str):
+            return False
+        candidate = url.strip()
+        if "://" not in candidate:
+            candidate = "https://" + candidate
+        try:
+            host = (urlparse(candidate).hostname or "").lower()
+        except ValueError:
+            return False
+        return any(host == pattern or host.endswith("." + pattern) for pattern in self.host_patterns)
+
+    def thumbnail_hosts(self) -> tuple[str, ...]:
+        """Hosts whose image URLs may be proxied by ``fetch_thumbnail``.
+
+        Serves as the SSRF allowlist for the provider's image proxy; empty
+        means the provider has no server-side thumbnail proxy.
+        """
+        return ()
+
+    def download_hosts(self) -> tuple[str, ...]:
+        """Hosts whose file URLs may be fetched by the download path.
+
+        Serves as the SSRF allowlist for :meth:`ProviderService.download`,
+        symmetric to :meth:`thumbnail_hosts`; empty means the provider has no
+        server-side file fetch (so no allowlist constraint applies). Providers
+        whose service fetches files must override this — a new provider gets
+        the same structural hint the thumbnail proxy gives its counterpart.
+        """
+        return ()
+
+
+class ProviderService(ABC):
+    """Per-request client for a single provider.
+
+    Built by :meth:`ModelProvider.build_service`, never constructed directly.
+    Providers must be closed after use (:meth:`close`); the shared connection
+    pool is only closed by the owner.
+    """
+
+    @abstractmethod
+    async def close(self) -> None:
+        """Close the client if this service instance owns it."""
+
+    @abstractmethod
+    async def get_status(self, db: AsyncSession) -> ProviderStatus:
+        """Report whether the caller can use this provider (credential state)."""
+
+    @abstractmethod
+    async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
+        """Fetch metadata + the importable file/plate list for a resource."""
+
+    @abstractmethod
+    async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
+        """Resolve the concrete download for a resource/file.
+
+        May need provider-specific lookups (e.g. MakerWorld's alphanumeric
+        ``modelId``) and must enrich ``ref.sub_id`` with the actually-resolved
+        file/plate so the route can build the canonical dedupe key.
+        Raises ``ProviderAuthError`` when the provider requires credentials
+        and the caller has none.
+        """
+
+    @abstractmethod
+    async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
+        """Fetch the file bytes for a :class:`ProviderDownloadInfo`.
+
+        Must restrict the upstream URL host to :meth:`ModelProvider.download_hosts`
+        (SSRF guard — the symmetric counterpart to ``fetch_thumbnail``).
+        """
+
+    @abstractmethod
+    async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
+        """Proxy a provider CDN image, returning ``(bytes, content_type)``.
+
+        Must restrict the upstream host to :meth:`ModelProvider.thumbnail_hosts`
+        (SSRF guard).
+        """

+ 12 - 0
backend/app/services/model_providers/makerworld/__init__.py

@@ -0,0 +1,12 @@
+"""MakerWorld model provider package.
+
+Exports the provider instance the registry consumes; the implementation lives
+in the sibling modules (``service``, ``http``, ``url``, ``errors``, ``auth``).
+"""
+
+from backend.app.services.model_providers.makerworld.provider import (
+    MakerWorldProvider,
+    makerworld_provider,
+)
+
+__all__ = ["MakerWorldProvider", "makerworld_provider"]

+ 18 - 0
backend/app/services/model_providers/makerworld/auth.py

@@ -0,0 +1,18 @@
+"""MakerWorld credential handling.
+
+MakerWorld downloads run on the same Bambu Cloud bearer token as the rest of
+the Bambu cloud integration — there is no separate MakerWorld OAuth flow. This
+module is the single seam where the MakerWorld provider reads the caller's
+stored token, reports a rejected/expired credential, and records a 401 so the
+whole app agrees the sign-in is dead (see ``cloud.mark_cloud_token_invalid``).
+"""
+
+from __future__ import annotations
+
+from backend.app.services.bambu_cloud_credentials import (
+    get_stored_token,
+    is_cloud_token_invalid,
+    mark_cloud_token_invalid,
+)
+
+__all__ = ["get_stored_token", "is_cloud_token_invalid", "mark_cloud_token_invalid"]

+ 44 - 0
backend/app/services/model_providers/makerworld/errors.py

@@ -0,0 +1,44 @@
+"""MakerWorld error types.
+
+Subclasses of the generic provider hierarchy so route layers can map errors
+with the provider-agnostic classes (:class:`ProviderError` and friends) while
+callers that know they're talking to MakerWorld get the specific types.
+"""
+
+from __future__ import annotations
+
+from backend.app.services.model_providers.base import (
+    ProviderAuthError,
+    ProviderError,
+    ProviderForbiddenError,
+    ProviderNotFoundError,
+    ProviderUnavailableError,
+    ProviderUrlError,
+)
+
+
+class MakerWorldError(ProviderError):
+    """Base exception for MakerWorld API errors."""
+
+
+class MakerWorldAuthError(ProviderAuthError, MakerWorldError):
+    """Raised when MakerWorld requires a Bambu Cloud token and we don't have
+    one (or the one we sent was rejected). True auth failure."""
+
+
+class MakerWorldForbiddenError(ProviderForbiddenError, MakerWorldError):
+    """Raised when MakerWorld refuses access despite valid authentication —
+    content-gated (points required, purchase required, region restricted,
+    early-access, etc.)."""
+
+
+class MakerWorldNotFoundError(ProviderNotFoundError, MakerWorldError):
+    """Raised when a design / profile / instance doesn't exist."""
+
+
+class MakerWorldUnavailableError(ProviderUnavailableError, MakerWorldError):
+    """Raised on 5xx, network errors, or malformed payloads."""
+
+
+class MakerWorldUrlError(ProviderUrlError, MakerWorldError):
+    """Raised when a URL isn't a makerworld.com model page."""

+ 166 - 0
backend/app/services/model_providers/makerworld/http.py

@@ -0,0 +1,166 @@
+"""MakerWorld HTTP layer.
+
+Constants and the low-level transport helpers for the MakerWorld / Bambu Lab
+APIs: the S3 presigned-download path that must reach the transport
+byte-for-byte, upstream error extraction, and the CDN SSRF guard helpers used
+by :class:`MakerWorldService`.
+
+The app-scoped shared ``httpx`` client lives with its consumer instead (see
+``service.set_shared_http_client``) — same-module so the service reads the
+live value, matching ``bambu_cloud`` / ``orca_cloud`` / ``slicer_api``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import ssl
+
+import certifi
+import httpx
+
+from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError
+
+# API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
+# backend that the MakerWorld web UI talks to, but not behind Cloudflare
+# (the website ``makerworld.com`` is, and plain httpx requests there get
+# fingerprinted as bot traffic and served "Please log in").
+MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
+
+# Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3 presigned
+# URLs (e.g. ``s3.us-west-2.amazonaws.com``) from the iot-service download
+# endpoint. The suffix check matches any regional S3 endpoint.
+#
+# Deliberately NOT part of the ``download_hosts()`` seam: that seam is an
+# exact-host allowlist a provider declares, and this is a suffix family
+# belonging to Bambu's signed-URL infrastructure specifically. It stays a
+# constant of *this* provider's transport: ``download_3mf`` accepts the
+# injected hosts or an S3 endpoint, while a second provider brings its own
+# service and declares its own ``download_hosts()``.
+_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
+
+# The shared default SSRF allowlist for MakerWorld CDN traffic. The thumbnail
+# proxy and the 3MF download path are both driven by the provider descriptor
+# instead — ``build_service`` feeds the runner's ``ModelProvider.thumbnail_hosts()``
+# and ``download_hosts()`` into ``MakerWorldService`` — and this tuple is what
+# those methods return by default. Lives here with the other transport guards
+# so the allowlist is in one place.
+MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
+
+# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
+# as Bambuddy with a source URL so Bambu can distinguish our traffic from
+# impersonators — the opposite of what the OrcaSlicer fork was called out for
+# in the May 2026 Bambu Lab blog post on cloud access. The Referer is kept
+# because MakerWorld's CSRF / origin-check middleware uses it on some
+# endpoints — that's distinct from client impersonation.
+_CLIENT_HEADERS = {
+    "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
+    "Accept": "text/html,application/json,*/*",
+    "Accept-Language": "en-US,en;q=0.9",
+    "Referer": "https://makerworld.com/",
+}
+
+_MAX_3MF_BYTES = 200 * 1024 * 1024  # 200 MB hard cap
+_MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024  # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
+
+_IMAGE_EXT_TO_MIME = {
+    ".png": "image/png",
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".gif": "image/gif",
+    ".webp": "image/webp",
+    ".bmp": "image/bmp",
+}
+# Content types we refuse even if the URL extension looks image-y — prevents
+# forwarding an upstream error page or JSON blob with image framing.
+_REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
+
+
+def _s3_ssl_context() -> ssl.SSLContext:
+    """Build the TLS context used for the S3 presigned download (#2562).
+
+    ``urllib.request`` verifies against the *OS* trust store, while httpx —
+    every other network call in Bambuddy — verifies against the bundled
+    ``certifi`` CA bundle. On Windows those two disagree: Python's
+    ``ssl.load_default_certs()`` only enumerates the roots already cached in
+    the Windows ROOT store, and Windows populates that store lazily via
+    CryptoAPI's auto-update, which Python never triggers. If the Amazon root
+    signing the S3 chain isn't cached on that machine yet, verification fails
+    with ``unable to get local issuer certificate`` — even though the
+    api.bambulab.com calls that preceded it (httpx) succeeded.
+
+    Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
+    the app already trusts. Built per call rather than at import so a certifi
+    refresh doesn't require a restart; construction is cheap relative to the
+    download that follows.
+    """
+    return ssl.create_default_context(cafile=certifi.where())
+
+
+async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
+    """Fetch an AWS S3 presigned URL without touching the query string.
+
+    ``urllib.request`` passes the URL to the transport verbatim — which is
+    essential for S3 presigned URLs where the signature is computed over
+    the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
+    libcurl layer both normalise encodings and produce
+    ``SignatureDoesNotMatch`` 400s from S3.
+
+    Runs the blocking urllib call in a thread executor so we don't stall
+    the event loop.
+    """
+    from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
+
+    # Don't follow redirects: the host allowlist is only enforced on
+    # the initial URL. A 302 from S3 to any other host would otherwise
+    # transparently bypass the allowlist — so insist S3 resolve directly.
+    class _NoRedirect(HTTPRedirectHandler):
+        def redirect_request(self, *args, **kwargs):  # type: ignore[override]
+            return None
+
+    # HTTPSHandler swaps only the TLS context — the URL still reaches the
+    # transport verbatim, which is what the S3 signature depends on.
+    opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
+
+    def _blocking_fetch() -> bytes:
+        req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
+        with opener.open(req, timeout=60.0) as resp:
+            if resp.status != 200:
+                raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
+            data = b""
+            while True:
+                chunk = resp.read(65536)
+                if not chunk:
+                    break
+                data += chunk
+                if len(data) > _MAX_3MF_BYTES:
+                    raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
+            return data
+
+    try:
+        data = await asyncio.to_thread(_blocking_fetch)
+    except MakerWorldUnavailableError:
+        raise
+    except Exception as exc:  # noqa: BLE001 — urllib throws a zoo of exceptions
+        raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
+    return data, filename_fallback
+
+
+def _extract_upstream_error(response: httpx.Response) -> str | None:
+    """Pull MakerWorld's own error text out of a 4xx/5xx response body.
+
+    MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
+    and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
+    the body isn't JSON or doesn't have a recognised error field — callers
+    should fall back to a generic message in that case.
+    """
+    try:
+        data = response.json()
+    except ValueError:
+        return None
+    if not isinstance(data, dict):
+        return None
+    for key in ("error", "message", "detail"):
+        value = data.get(key)
+        if isinstance(value, str) and value.strip():
+            return value.strip()
+    return None

+ 117 - 0
backend/app/services/model_providers/makerworld/provider.py

@@ -0,0 +1,117 @@
+"""MakerWorld model provider.
+
+Static descriptor + per-request service factory for makerworld.com. The
+``MakerWorldProvider`` instance is what gets registered in the shared
+:class:`ModelProviderRegistry`; the actual API work lives in ``service.py``
+(the per-request :class:`ProviderService`) and ``url.py`` (URL parsing and
+canonicalisation). Credential handling is centralised here so route layers
+never touch MakerWorld specifics.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import httpx
+
+from backend.app.core.permissions import Permission
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderAuthConfig,
+    ProviderAuthType,
+    ProviderResourceRef,
+    ProviderService,
+)
+from backend.app.services.model_providers.makerworld import url as mw_url
+from backend.app.services.model_providers.makerworld.auth import (
+    get_stored_token,
+    mark_cloud_token_invalid,
+)
+from backend.app.services.model_providers.makerworld.http import MAKERWORLD_CDN_HOSTS
+from backend.app.services.model_providers.makerworld.service import MakerWorldService
+
+if TYPE_CHECKING:
+    from sqlalchemy.ext.asyncio import AsyncSession
+
+    from backend.app.models.user import User
+
+
+class MakerWorldProvider(ModelProvider):
+    """MakerWorld descriptor: identity, URL routing, auth requirements, and the
+    factory that builds a per-request :class:`MakerWorldService` seeded with the
+    caller's stored Bambu Cloud bearer token.
+    """
+
+    source_type = "makerworld"
+    display_name = "MakerWorld"
+    host_patterns = ("makerworld.com",)
+    auth = ProviderAuthConfig(
+        auth_type=ProviderAuthType.BAMBU_CLOUD_BEARER,
+        display_label="Bambu Cloud sign-in",
+        description=(
+            "MakerWorld downloads reuse the Bambu Cloud account already stored in Bambuddy — "
+            "there is no separate MakerWorld sign-in."
+        ),
+        setup_hint="Open the Profiles page and sign in to Bambu Cloud.",
+    )
+    default_folder_name = "MakerWorld"
+    view_permission = Permission.MAKERWORLD_VIEW
+    import_permission = Permission.MAKERWORLD_IMPORT
+
+    async def build_service(
+        self,
+        *,
+        db: AsyncSession,
+        user: User | None,
+        api_key_owner: User | None = None,
+        client: httpx.AsyncClient | None = None,
+    ) -> ProviderService:
+        """Build a per-request service seeded with the caller's stored Bambu
+        Cloud bearer, mirroring ``cloud.build_authenticated_cloud``.
+
+        ``api_key_owner`` is the API key's owning user for API-keyed calls
+        (see ``resolve_api_key_cloud_owner``); MakerWorld uses it as the
+        fallback identity when ``user`` is None. Like the cloud integration, a
+        rejected token is recorded so the whole app agrees the sign-in is dead
+        rather than each feature failing on its own — including auth-disabled
+        single-user installs, where ``user_id=None`` records the *global*
+        flag those installs read back on the status endpoints.
+        """
+        identity = user if user is not None else api_key_owner
+        token, _email, _region = await get_stored_token(db, identity)
+        user_id = identity.id if identity is not None else None
+        return MakerWorldService(
+            client=client,
+            auth_token=token,
+            user=identity,
+            on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
+            # The SSRF allowlists are the provider's declared seams — the
+            # service must not hardcode its own copies (symmetric pair,
+            # ``fetch_thumbnail`` / ``download``).
+            thumbnail_hosts=self.thumbnail_hosts(),
+            download_hosts=self.download_hosts(),
+        )
+
+    def parse_url(self, url: str) -> ProviderResourceRef:
+        return mw_url.parse_url(url)
+
+    def canonical_url(self, ref: ProviderResourceRef) -> str:
+        return mw_url.canonical_url(ref)
+
+    def source_url_filter(self, column, external_id: str):
+        """Whole-model key plus every per-plate key — MakerWorld's canonical
+        shape appends ``#profileId-{n}`` for plate-level dedupe (see
+        ``url.canonical_url``), so the already-imported detection must match
+        both. The ``#profileId-`` fragment lives here with the descriptor
+        because it is part of this provider's URL contract."""
+        prefix = mw_url.canonical_url(ProviderResourceRef(source_type=self.source_type, external_id=external_id))
+        return (column == prefix) | (column.like(f"{prefix}#profileId-%"))
+
+    def thumbnail_hosts(self) -> tuple[str, ...]:
+        return MAKERWORLD_CDN_HOSTS
+
+    def download_hosts(self) -> tuple[str, ...]:
+        return MAKERWORLD_CDN_HOSTS
+
+
+makerworld_provider = MakerWorldProvider()

+ 187 - 230
backend/app/services/makerworld.py → backend/app/services/model_providers/makerworld/service.py

@@ -9,7 +9,11 @@ The endpoints and header set were reverse-engineered from the
 `kloshi-io/makerworld-api-reverse` TypeScript project (Apache-2.0) and
 `kloshi-io/makerworld-api-reverse` TypeScript project (Apache-2.0) and
 cross-validated against live MakerWorld traffic. Authenticated calls reuse
 cross-validated against live MakerWorld traffic. Authenticated calls reuse
 Bambuddy's existing Bambu Cloud bearer token (same SSO backend — no separate
 Bambuddy's existing Bambu Cloud bearer token (same SSO backend — no separate
-OAuth flow needed).
+OAuth flow needed; see ``model_providers/makerworld/auth.py``).
+
+Implements the :class:`ProviderService` interface — the route layer drives it
+through ``resolve`` / ``get_download`` / ``download`` so the same flow can be
+reused for future providers.
 
 
 Only interoperability — not affiliated with or endorsed by MakerWorld or
 Only interoperability — not affiliated with or endorsed by MakerWorld or
 Bambu Lab, and not intended to circumvent any access control.
 Bambu Lab, and not intended to circumvent any access control.
@@ -19,211 +23,74 @@ from __future__ import annotations
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-import re
-import ssl
 from collections.abc import Awaitable, Callable
 from collections.abc import Awaitable, Callable
+from dataclasses import replace
 from typing import Any
 from typing import Any
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
-import certifi
 import httpx
 import httpx
 
 
 from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
 from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
-
-logger = logging.getLogger(__name__)
-
-
-# API base: ``api.bambulab.com/v1/design-service`` — the same Bambu Cloud
-# backend that the MakerWorld web UI talks to, but not behind Cloudflare
-# (the website ``makerworld.com`` is, and plain httpx requests there get
-# fingerprinted as bot traffic and served "Please log in"). Confirmed by
-# Pr0zak/YASTL#51 and verified with direct curl.
-MAKERWORLD_API_BASE = "https://api.bambulab.com/v1/design-service"
-MAKERWORLD_HOST = "makerworld.com"  # Used only for URL parsing (input validation)
-MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
-
-# Hosts that the iot-service download endpoint may return presigned URLs
-# for. Besides MakerWorld's own CDN, Bambu Cloud also issues AWS S3
-# presigned URLs (e.g. ``s3.us-west-2.amazonaws.com``) — confirmed by
-# Pr0zak/YASTL#52. The suffix check matches any regional S3 endpoint.
-_ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
-
-# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
-# as Bambuddy with a source URL so Bambu can distinguish our traffic from
-# impersonators — the opposite of what the OrcaSlicer fork was called out for
-# in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via
-# curl that MakerWorld treats this UA identically to a Firefox UA at the
-# Cloudflare edge (same response shape on /api/v1/design-service/* paths).
-# The Referer is kept because MakerWorld's CSRF / origin-check middleware uses
-# it on some endpoints — that's distinct from client impersonation.
-_CLIENT_HEADERS = {
-    "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
-    "Accept": "text/html,application/json,*/*",
-    "Accept-Language": "en-US,en;q=0.9",
-    "Referer": "https://makerworld.com/",
-}
-
-# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
-# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
-# string verbatim, which surfaced as a "Please login." toast on a UI that was
-# simultaneously reporting the user as connected — maximally confusing, and it
-# named no page to go to. Say what happened and where to fix it. Bambu Cloud
-# sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
-# there is no Settings → Bambu Cloud page, which is what the old fallback text
-# told people to look for.
-_SIGN_IN_EXPIRED_MESSAGE = (
-    "Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
+from backend.app.services.model_providers.base import (
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+    ProviderService,
+    ProviderStatus,
+)
+from backend.app.services.model_providers.makerworld.auth import is_cloud_token_invalid
+from backend.app.services.model_providers.makerworld.errors import (
+    MakerWorldAuthError,
+    MakerWorldForbiddenError,
+    MakerWorldNotFoundError,
+    MakerWorldUnavailableError,
+    MakerWorldUrlError,
+)
+from backend.app.services.model_providers.makerworld.http import (
+    _ALLOWED_DOWNLOAD_SUFFIXES,
+    _CLIENT_HEADERS,
+    _IMAGE_EXT_TO_MIME,
+    _MAX_3MF_BYTES,
+    _MAX_THUMBNAIL_BYTES,
+    _REFUSED_THUMBNAIL_MIMES,
+    MAKERWORLD_API_BASE,
+    MAKERWORLD_CDN_HOSTS,
+    _download_s3_urllib,
+    _extract_upstream_error,
 )
 )
 
 
-_MODEL_ID_RE = re.compile(r"/models/(\d+)")
-_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
-_MAX_3MF_BYTES = 200 * 1024 * 1024  # 200 MB hard cap
-_MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024  # 10 MB hard cap — MakerWorld's "thumbnails" can be 2–3 MB source images
-_IMAGE_EXT_TO_MIME = {
-    ".png": "image/png",
-    ".jpg": "image/jpeg",
-    ".jpeg": "image/jpeg",
-    ".gif": "image/gif",
-    ".webp": "image/webp",
-    ".bmp": "image/bmp",
-}
-# Content types we refuse even if the URL extension looks image-y — prevents
-# forwarding an upstream error page or JSON blob with image framing.
-_REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
+logger = logging.getLogger(__name__)
 
 
 _shared_http_client: httpx.AsyncClient | None = None
 _shared_http_client: httpx.AsyncClient | None = None
 
 
 
 
-def _s3_ssl_context() -> ssl.SSLContext:
-    """Build the TLS context used for the S3 presigned download (#2562).
-
-    ``urllib.request`` verifies against the *OS* trust store, while httpx —
-    every other network call in Bambuddy — verifies against the bundled
-    ``certifi`` CA bundle. On Windows those two disagree: Python's
-    ``ssl.load_default_certs()`` only enumerates the roots already cached in
-    the Windows ROOT store, and Windows populates that store lazily via
-    CryptoAPI's auto-update, which Python never triggers. If the Amazon root
-    signing the S3 chain isn't cached on that machine yet, verification fails
-    with ``unable to get local issuer certificate`` — even though the
-    api.bambulab.com calls that preceded it (httpx) succeeded.
-
-    Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
-    the app already trusts. Built per call rather than at import so a certifi
-    refresh doesn't require a restart; construction is cheap relative to the
-    download that follows.
-    """
-    return ssl.create_default_context(cafile=certifi.where())
-
-
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
     """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
 
 
     Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
     Same pattern as ``bambu_cloud.set_shared_http_client`` — lets the FastAPI
     lifespan share one connection pool across per-request service instances.
     lifespan share one connection pool across per-request service instances.
+    Must live in the same module as the service class so ``__init__`` reads
+    the live value rather than an import-time snapshot.
     """
     """
     global _shared_http_client
     global _shared_http_client
     _shared_http_client = client
     _shared_http_client = client
 
 
 
 
-class MakerWorldError(Exception):
-    """Base exception for MakerWorld API errors."""
-
-
-class MakerWorldAuthError(MakerWorldError):
-    """Raised when the endpoint requires a Bambu Cloud token and we don't have
-    one (or the one we sent was rejected). True auth failure."""
-
-
-class MakerWorldForbiddenError(MakerWorldError):
-    """Raised when MakerWorld refuses access despite valid authentication —
-    content-gated (points required, purchase required, region restricted,
-    early-access, etc.). The message includes MakerWorld's own reason text
-    when provided."""
-
-
-class MakerWorldNotFoundError(MakerWorldError):
-    """Raised when a design / profile / instance doesn't exist."""
-
-
-class MakerWorldUnavailableError(MakerWorldError):
-    """Raised on 5xx, network errors, or malformed payloads."""
-
-
-class MakerWorldUrlError(MakerWorldError):
-    """Raised when a URL isn't a makerworld.com model page."""
-
-
-async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes, str]:
-    """Fetch an AWS S3 presigned URL without touching the query string.
+# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
+# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
+# string verbatim, which surfaced as a "Please login." toast on a UI that was
+# simultaneously reporting the user as connected — maximally confusing, and it
+# named no page to go to. Say what happened and where to fix it. Bambu Cloud
+# sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
+# there is no Settings → Bambu Cloud page, which is what the old fallback text
+# told people to look for.
+_SIGN_IN_EXPIRED_MESSAGE = (
+    "Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
+)
 
 
-    ``urllib.request`` passes the URL to the transport verbatim — which is
-    essential for S3 presigned URLs where the signature is computed over
-    the exact query-string bytes. httpx's ``URL`` class and curl_cffi's
-    libcurl layer both normalise encodings and produce
-    ``SignatureDoesNotMatch`` 400s from S3.
 
 
-    Runs the blocking urllib call in a thread executor so we don't stall
-    the event loop.
-    """
-    from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
-
-    # Don't follow redirects: the host allowlist above is only enforced on
-    # the initial URL. A 302 from S3 to any other host would otherwise
-    # transparently bypass the allowlist — so insist S3 resolve directly.
-    class _NoRedirect(HTTPRedirectHandler):
-        def redirect_request(self, *args, **kwargs):  # type: ignore[override]
-            return None
-
-    # HTTPSHandler swaps only the TLS context — the URL still reaches the
-    # transport verbatim, which is what the S3 signature depends on.
-    opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
-
-    def _blocking_fetch() -> bytes:
-        req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
-        with opener.open(req, timeout=60.0) as resp:
-            if resp.status != 200:
-                raise MakerWorldUnavailableError(f"3MF download returned HTTP {resp.status}")
-            data = b""
-            while True:
-                chunk = resp.read(65536)
-                if not chunk:
-                    break
-                data += chunk
-                if len(data) > _MAX_3MF_BYTES:
-                    raise MakerWorldUnavailableError(f"3MF exceeds {_MAX_3MF_BYTES // (1024 * 1024)} MB cap")
-            return data
-
-    try:
-        data = await asyncio.to_thread(_blocking_fetch)
-    except MakerWorldUnavailableError:
-        raise
-    except Exception as exc:  # noqa: BLE001 — urllib throws a zoo of exceptions
-        raise MakerWorldUnavailableError(f"S3 download failed: {exc}") from exc
-    return data, filename_fallback
-
-
-def _extract_upstream_error(response: httpx.Response) -> str | None:
-    """Pull MakerWorld's own error text out of a 4xx/5xx response body.
-
-    MakerWorld returns ``{"code": N, "error": "text"}`` on auth/perm failures
-    and sometimes ``{"message": "..."}`` on other errors. Returns ``None`` if
-    the body isn't JSON or doesn't have a recognised error field — callers
-    should fall back to a generic message in that case.
-    """
-    try:
-        data = response.json()
-    except ValueError:
-        return None
-    if not isinstance(data, dict):
-        return None
-    for key in ("error", "message", "detail"):
-        value = data.get(key)
-        if isinstance(value, str) and value.strip():
-            return value.strip()
-    return None
-
-
-class MakerWorldService:
+class MakerWorldService(ProviderService):
     """Per-request MakerWorld API client.
     """Per-request MakerWorld API client.
 
 
     Mirrors ``BambuCloudService``'s construction pattern so callers can
     Mirrors ``BambuCloudService``'s construction pattern so callers can
@@ -233,15 +100,26 @@ class MakerWorldService:
 
 
     def __init__(
     def __init__(
         self,
         self,
+        *,
         client: httpx.AsyncClient | None = None,
         client: httpx.AsyncClient | None = None,
         auth_token: str | None = None,
         auth_token: str | None = None,
+        user: Any | None = None,
         on_auth_failure: Callable[[], Awaitable[None]] | None = None,
         on_auth_failure: Callable[[], Awaitable[None]] | None = None,
+        thumbnail_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
+        download_hosts: tuple[str, ...] = MAKERWORLD_CDN_HOSTS,
     ):
     ):
         # Fired when Bambu rejects the stored token (401). MakerWorld runs on the
         # Fired when Bambu rejects the stored token (401). MakerWorld runs on the
         # same Bambu Cloud bearer as everything else, so a rejection here means
         # same Bambu Cloud bearer as everything else, so a rejection here means
         # the credential is dead app-wide — see ``build_authenticated_cloud``.
         # the credential is dead app-wide — see ``build_authenticated_cloud``.
         self._on_auth_failure = on_auth_failure
         self._on_auth_failure = on_auth_failure
         self._auth_failure_reported = False
         self._auth_failure_reported = False
+        # SSRF allowlists for the thumbnail proxy and the 3MF download guard.
+        # Default to MakerWorld's CDN hosts; ``MakerWorldProvider.build_service``
+        # passes ``ModelProvider.thumbnail_hosts()`` / ``download_hosts()`` so
+        # the guards are driven by the provider descriptor rather than enforced
+        # by coincidence (interface contract on ``ProviderService``).
+        self._thumbnail_hosts = tuple(thumbnail_hosts)
+        self._download_hosts = tuple(download_hosts)
         if client is not None:
         if client is not None:
             self._client = client
             self._client = client
             self._owns_client = False
             self._owns_client = False
@@ -252,6 +130,7 @@ class MakerWorldService:
             self._client = httpx.AsyncClient(timeout=30.0)
             self._client = httpx.AsyncClient(timeout=30.0)
             self._owns_client = True
             self._owns_client = True
         self._auth_token = auth_token
         self._auth_token = auth_token
+        self._user = user
 
 
     async def close(self) -> None:
     async def close(self) -> None:
         if self._owns_client:
         if self._owns_client:
@@ -283,6 +162,121 @@ class MakerWorldService:
             headers["Authorization"] = f"Bearer {self._auth_token}"
             headers["Authorization"] = f"Bearer {self._auth_token}"
         return headers
         return headers
 
 
+    # ------------------------------------------------------------- interface
+
+    async def get_status(self, db: Any) -> ProviderStatus:
+        """Whether the caller can download: needs a stored, non-rejected Bambu
+        Cloud token. ``credential_rejected`` is the machine-readable expired
+        state; ``auth_error`` names it for humans so the UI can say "your
+        sign-in expired" rather than a bare "sign in"."""
+        has_token = bool(self._auth_token)
+        expired = has_token and await is_cloud_token_invalid(db, self._user)
+        return ProviderStatus(
+            authenticated=has_token,
+            can_download=has_token and not expired,
+            auth_error=_SIGN_IN_EXPIRED_MESSAGE if expired else None,
+            credential_rejected=expired,
+        )
+
+    async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
+        """Fetch full model metadata + the plate list, merging per-instance
+        printer compatibility so the frontend can show "sliced for A1 / also
+        compatible with H2D, P1S" before the user picks a plate."""
+        model_id = int(ref.external_id)
+        design = await self.get_design(model_id)
+        instances_envelope = await self.get_design_instances(model_id)
+
+        # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``;
+        # normalise the null case to an empty list so the frontend doesn't
+        # have to handle null vs [] both ways.
+        instances = instances_envelope.get("hits") or []
+        if not isinstance(instances, list):
+            instances = []
+
+        # /instances/hits omits the per-instance printer compatibility info
+        # that /design.instances[].extention.modelInfo carries. Merge it in.
+        design_instances = design.get("instances") or []
+        if isinstance(design_instances, list):
+            compat_by_id = {}
+            for di in design_instances:
+                if not isinstance(di, dict):
+                    continue
+                iid = di.get("id")
+                if iid is None:
+                    continue
+                ext = (di.get("extention") or {}).get("modelInfo") or {}
+                compat_by_id[iid] = {
+                    "compatibility": ext.get("compatibility"),
+                    "otherCompatibility": ext.get("otherCompatibility"),
+                }
+            for inst in instances:
+                if not isinstance(inst, dict):
+                    continue
+                iid = inst.get("id")
+                extra = compat_by_id.get(iid)
+                if extra:
+                    inst["compatibility"] = extra["compatibility"]
+                    inst["otherCompatibility"] = extra["otherCompatibility"]
+
+        return ProviderResolvedModel(ref=ref, design=design, instances=instances)
+
+    async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
+        """Resolve the signed 3MF download for a specific MakerWorld profile.
+
+        Handles the provider-specific dance: the iot-service endpoint needs
+        the *alphanumeric* ``modelId`` (e.g. ``"US2bb73b106683e5"``) from the
+        design, not the integer design id, and picks a default profile when
+        the caller didn't specify one. Enriches ``ref.sub_id`` with the actual
+        profile used so the route can build the per-plate dedupe key.
+        """
+        model_id = int(ref.external_id)
+        design = await self.get_design(model_id)
+
+        alphanumeric_model_id = design.get("modelId")
+        if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
+            raise MakerWorldUnavailableError("MakerWorld design metadata missing the modelId field")
+
+        profile_id = int(ref.sub_id) if ref.sub_id else None
+        if profile_id is None:
+            for instance in design.get("instances") or []:
+                pid = instance.get("profileId")
+                if isinstance(pid, int) and pid > 0:
+                    profile_id = pid
+                    break
+            if profile_id is None:
+                envelope = await self.get_design_instances(model_id)
+                for hit in envelope.get("hits") or []:
+                    pid = hit.get("profileId")
+                    if isinstance(pid, int) and pid > 0:
+                        profile_id = pid
+                        break
+            if profile_id is None:
+                raise MakerWorldUnavailableError("MakerWorld returned no instances for this model")
+
+        manifest = await self.get_profile_download(profile_id, alphanumeric_model_id)
+
+        signed_url = manifest.get("url")
+        if not signed_url or not isinstance(signed_url, str):
+            raise MakerWorldUnavailableError("MakerWorld did not return a download URL")
+
+        # Raw upstream name — the route layer basenames / percent-decodes it
+        # as defence-in-depth before persisting.
+        raw_name = manifest.get("name")
+        suggested_filename = raw_name if isinstance(raw_name, str) and raw_name.strip() else ""
+
+        return ProviderDownloadInfo(
+            ref=replace(ref, sub_id=str(profile_id)),
+            url=signed_url,
+            suggested_filename=suggested_filename,
+        )
+
+    async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
+        """Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
+        file_bytes, download_filename = await self.download_3mf(info.url)
+        return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
+
+    # ---------------------------------------------------------------- endpoints
+
     async def _get_json(self, path: str) -> dict[str, Any]:
     async def _get_json(self, path: str) -> dict[str, Any]:
         """GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
         """GET ``{MAKERWORLD_API_BASE}{path}`` returning the decoded JSON body.
 
 
@@ -376,49 +370,6 @@ class MakerWorldService:
             )
             )
         return data
         return data
 
 
-    # ------------------------------------------------------------------ URL parse
-
-    @staticmethod
-    def parse_url(url: str) -> tuple[int, int | None]:
-        """Extract ``(model_id, profile_id_or_None)`` from a MakerWorld URL.
-
-        Accepts any of:
-          - ``https://makerworld.com/en/models/1400373``
-          - ``https://makerworld.com/en/models/1400373-slug-with-dashes``
-          - ``https://makerworld.com/en/models/1400373#profileId-1452154``
-          - ``makerworld.com/models/1400373`` (scheme optional)
-
-        Rejects non-makerworld hosts.
-        """
-        if not url or not isinstance(url, str):
-            raise MakerWorldUrlError("URL is empty or not a string")
-        candidate = url.strip()
-        if "://" not in candidate:
-            candidate = "https://" + candidate
-        try:
-            parsed = urlparse(candidate)
-        except ValueError as exc:
-            raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
-
-        host = (parsed.hostname or "").lower()
-        if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
-            raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
-
-        model_match = _MODEL_ID_RE.search(parsed.path)
-        if not model_match:
-            raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
-        model_id = int(model_match.group(1))
-
-        profile_id: int | None = None
-        if parsed.fragment:
-            profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
-            if profile_match:
-                profile_id = int(profile_match.group(1))
-
-        return model_id, profile_id
-
-    # ---------------------------------------------------------------- endpoints
-
     async def get_design(self, model_id: int) -> dict[str, Any]:
     async def get_design(self, model_id: int) -> dict[str, Any]:
         """Fetch full model metadata. Works anonymously.
         """Fetch full model metadata. Works anonymously.
 
 
@@ -510,8 +461,13 @@ class MakerWorldService:
     async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
     async def download_3mf(self, signed_url: str) -> tuple[bytes, str]:
         """Fetch the 3MF bytes from a signed MakerWorld CDN URL.
         """Fetch the 3MF bytes from a signed MakerWorld CDN URL.
 
 
-        Validates that the URL's host is one of the known MakerWorld CDN hosts
-        (SSRF guard — pattern matches ``_spoolman_helpers.assert_safe_spoolman_url``).
+        Validates that the URL's host is one of the declared download hosts
+        (SSRF guard — driven by ``ModelProvider.download_hosts()`` via
+        ``build_service``, the symmetric counterpart to the thumbnail
+        allowlist) *or* matches ``_ALLOWED_DOWNLOAD_SUFFIXES``, Bambu's S3
+        regional endpoints, which are this provider's own signed-URL family
+        rather than part of the injectable seam; pattern matches
+        ``_spoolman_helpers.assert_safe_spoolman_url``.
         Enforces a 200 MB cap so a single bad response can't exhaust disk.
         Enforces a 200 MB cap so a single bad response can't exhaust disk.
 
 
         Returns ``(file_bytes, suggested_filename)``.
         Returns ``(file_bytes, suggested_filename)``.
@@ -522,7 +478,7 @@ class MakerWorldService:
             raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
             raise MakerWorldUrlError(f"Invalid download URL: {exc}") from exc
 
 
         host = (parsed.hostname or "").lower()
         host = (parsed.hostname or "").lower()
-        is_allowed = host in MAKERWORLD_CDN_HOSTS or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
+        is_allowed = host in self._download_hosts or any(host.endswith(suffix) for suffix in _ALLOWED_DOWNLOAD_SUFFIXES)
         if not is_allowed:
         if not is_allowed:
             raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
             raise MakerWorldUrlError(f"Refusing to download from non-MakerWorld host: {host!r}")
 
 
@@ -570,8 +526,9 @@ class MakerWorldService:
         SPA's ``img-src`` CSP and keeps users' IP addresses out of
         SPA's ``img-src`` CSP and keeps users' IP addresses out of
         MakerWorld's access logs.
         MakerWorld's access logs.
 
 
-        Validates that the URL's host is one of the known MakerWorld CDN
-        hosts (SSRF guard — same allowlist as :meth:`download_3mf`). Caps
+        Validates that the URL's host is one of the declared thumbnail hosts
+        (SSRF guard — symmetric to :meth:`download_3mf`; both allowlists are
+        fed from the provider descriptor by ``build_service``). Caps
         payload at 5 MB. Returns ``(bytes, content_type)``; content type
         payload at 5 MB. Returns ``(bytes, content_type)``; content type
         defaults to ``image/jpeg`` if the upstream didn't set one.
         defaults to ``image/jpeg`` if the upstream didn't set one.
         """
         """
@@ -581,7 +538,7 @@ class MakerWorldService:
             raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
             raise MakerWorldUrlError(f"Invalid thumbnail URL: {exc}") from exc
 
 
         host = (parsed.hostname or "").lower()
         host = (parsed.hostname or "").lower()
-        if host not in MAKERWORLD_CDN_HOSTS:
+        if host not in self._thumbnail_hosts:
             raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
             raise MakerWorldUrlError(f"Refusing to fetch thumbnail from non-MakerWorld host: {host!r}")
 
 
         # ``follow_redirects=False``: the host allowlist above is only
         # ``follow_redirects=False``: the host allowlist above is only

+ 82 - 0
backend/app/services/model_providers/makerworld/url.py

@@ -0,0 +1,82 @@
+"""MakerWorld URL parsing and canonicalisation.
+
+Extracts ``(model_id, profile_id_or_None)`` from model URLs and builds the
+stable dedupe key used as the library ``source_url``. Rejects non-makerworld
+hosts — this is the input-validation surface, so it is deliberately strict.
+"""
+
+from __future__ import annotations
+
+import re
+from urllib.parse import urlparse
+
+from backend.app.services.model_providers.base import ProviderResourceRef
+from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
+
+MAKERWORLD_HOST = "makerworld.com"  # Used only for URL parsing (input validation)
+
+_MODEL_ID_RE = re.compile(r"/models/(\d+)")
+_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
+
+
+def parse_url(url: str) -> ProviderResourceRef:
+    """Extract a :class:`ProviderResourceRef` from a MakerWorld URL.
+
+    Accepts any of:
+      - ``https://makerworld.com/en/models/1400373``
+      - ``https://makerworld.com/en/models/1400373-slug-with-dashes``
+      - ``https://makerworld.com/en/models/1400373#profileId-1452154``
+      - ``makerworld.com/models/1400373`` (scheme optional)
+
+    Rejects non-makerworld hosts.
+    """
+    if not url or not isinstance(url, str):
+        raise MakerWorldUrlError("URL is empty or not a string")
+    candidate = url.strip()
+    if "://" not in candidate:
+        candidate = "https://" + candidate
+    try:
+        parsed = urlparse(candidate)
+    except ValueError as exc:
+        raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
+
+    host = (parsed.hostname or "").lower()
+    if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
+        raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
+
+    model_match = _MODEL_ID_RE.search(parsed.path)
+    if not model_match:
+        raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
+    model_id = int(model_match.group(1))
+
+    profile_id: int | None = None
+    if parsed.fragment:
+        profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
+        if profile_match:
+            profile_id = int(profile_match.group(1))
+
+    return ProviderResourceRef(
+        source_type="makerworld",
+        external_id=str(model_id),
+        sub_id=str(profile_id) if profile_id is not None else None,
+        original_url=url,
+    )
+
+
+def canonical_url(ref: ProviderResourceRef) -> str:
+    """Build a stable dedupe key for a MakerWorld resource.
+
+    Dedupe is keyed per *plate* (profile) rather than per model, since the
+    download returns a specific plate — not the full multi-plate zip — so two
+    different plates of the same design should become two separate library
+    entries. Canonical shape uses the locale-free path with the
+    ``#profileId-`` fragment so all URL variants of the same plate still
+    collapse (e.g. ``/en/models/123-slug?from=search#profileId-456`` and
+    ``/de/models/123#profileId-456`` both map to
+    ``https://makerworld.com/models/123#profileId-456``). Plate-less imports
+    (legacy or whole-design) keep the old model-only shape for backwards
+    compatibility with existing rows.
+    """
+    if ref.sub_id:
+        return f"https://makerworld.com/models/{ref.external_id}#profileId-{ref.sub_id}"
+    return f"https://makerworld.com/models/{ref.external_id}"

+ 60 - 0
backend/app/services/model_providers/registry.py

@@ -0,0 +1,60 @@
+"""Provider registry — maps ``source_type`` / URLs to model providers.
+
+The registry is the routing layer a future *shared* import API uses: a pasted
+URL goes through :meth:`ModelProviderRegistry.find_for_url`, which asks each
+registered provider ``supports_url`` and returns the one that owns it. Today
+the route layer still calls the MakerWorld provider directly (endpoints stay
+at ``/makerworld/*``), but registering providers here keeps the seam ready.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from backend.app.services.model_providers.base import ModelProvider
+
+
+class ModelProviderRegistry:
+    """Holds the registered :class:`ModelProvider` instances.
+
+    Registering is idempotent per provider instance; registering a *different*
+    provider under an already-taken ``source_type`` is an error.
+    """
+
+    def __init__(self) -> None:
+        self._providers: dict[str, ModelProvider] = {}
+
+    def register(self, provider: ModelProvider) -> None:
+        existing = self._providers.get(provider.source_type)
+        if existing is not None and existing is not provider:
+            raise ValueError(f"A model provider for source_type {provider.source_type!r} is already registered")
+        self._providers[provider.source_type] = provider
+
+    def get(self, source_type: str) -> ModelProvider:
+        try:
+            return self._providers[source_type]
+        except KeyError as exc:
+            raise KeyError(f"No model provider registered for source_type {source_type!r}") from exc
+
+    def all(self) -> tuple[ModelProvider, ...]:
+        return tuple(self._providers.values())
+
+    def find_for_url(self, url: str) -> ModelProvider | None:
+        """Return the provider that claims ``url``, or ``None`` if none do.
+
+        Iterates in registration (dict insertion) order; when more than one
+        provider ``supports_url`` the *first registered* one wins. Providers
+        overlap rarely (``host_patterns`` are usually disjoint), so this
+        tie-break is documented rather than policed — a "generic" provider
+        must register after the specific ones it might shadow.
+        """
+        for provider in self._providers.values():
+            if provider.supports_url(url):
+                return provider
+        return None
+
+
+# App-wide registry. Providers register themselves on package import (see
+# ``backend/app/services/model_providers/__init__.py``).
+registry = ModelProviderRegistry()

+ 1 - 1
backend/app/services/preset_resolver.py

@@ -29,7 +29,6 @@ import logging
 from fastapi import HTTPException
 from fastapi import HTTPException
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.api.routes.cloud import get_stored_token
 from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
 from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
@@ -40,6 +39,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     BambuCloudService,
 )
 )
+from backend.app.services.bambu_cloud_credentials import get_stored_token
 from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
 from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)

+ 17 - 9
backend/tests/integration/test_cloud_auth.py

@@ -265,7 +265,7 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
     async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
         """get_stored_token with user=None and no global token returns (None, None)."""
         """get_stored_token with user=None and no global token returns (None, None)."""
-        from backend.app.api.routes.cloud import get_stored_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         token, email, region = await get_stored_token(db_session, user=None)
         token, email, region = await get_stored_token(db_session, user=None)
         assert token is None
         assert token is None
@@ -275,7 +275,8 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_store_and_get_global_token(self, db_session):
     async def test_store_and_get_global_token(self, db_session):
         """store_token with user=None stores in global Settings table."""
         """store_token with user=None stores in global Settings table."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
         await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
         token, email, region = await get_stored_token(db_session, user=None)
         token, email, region = await get_stored_token(db_session, user=None)
@@ -286,9 +287,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_store_and_get_per_user_token(self, db_session):
     async def test_store_and_get_per_user_token(self, db_session):
         """store_token with user stores on the user record."""
         """store_token with user stores on the user record."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
         user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
         db_session.add(user)
         db_session.add(user)
@@ -309,9 +311,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_per_user_token_does_not_affect_global(self, db_session):
     async def test_per_user_token_does_not_affect_global(self, db_session):
         """Storing per-user token should not affect global Settings."""
         """Storing per-user token should not affect global Settings."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
         user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
         db_session.add(user)
         db_session.add(user)
@@ -352,7 +355,8 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_clear_global_token(self, db_session):
     async def test_clear_global_token(self, db_session):
         """clear_token with user=None clears from global Settings."""
         """clear_token with user=None clears from global Settings."""
-        from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
+        from backend.app.api.routes.cloud import clear_token, store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "global-token", "global@test.com", "global", user=None)
         await store_token(db_session, "global-token", "global@test.com", "global", user=None)
         await clear_token(db_session, user=None)
         await clear_token(db_session, user=None)
@@ -365,9 +369,10 @@ class TestCloudTokenStorage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_two_users_independent_tokens(self, db_session):
     async def test_two_users_independent_tokens(self, db_session):
         """Two users should have completely independent cloud tokens and regions."""
         """Two users should have completely independent cloud tokens and regions."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
         user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
         user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
         user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
@@ -406,9 +411,10 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_region_survives_roundtrip_per_user(self, db_session):
     async def test_region_survives_roundtrip_per_user(self, db_session):
         """Stored China region is returned on subsequent get_stored_token calls."""
         """Stored China region is returned on subsequent get_stored_token calls."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
         from backend.app.models.user import User
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
         user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
         db_session.add(user)
         db_session.add(user)
@@ -429,7 +435,8 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_region_survives_roundtrip_global_fallback(self, db_session):
     async def test_region_survives_roundtrip_global_fallback(self, db_session):
         """Stored China region in auth-disabled Settings fallback survives too."""
         """Stored China region in auth-disabled Settings fallback survives too."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "cn-token", "token-auth", "china", user=None)
         await store_token(db_session, "cn-token", "token-auth", "china", user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)
@@ -438,7 +445,8 @@ class TestCloudRegionPersistence:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_invalid_region_is_normalised_to_global(self, db_session):
     async def test_invalid_region_is_normalised_to_global(self, db_session):
         """Unknown region values fall back to 'global' rather than mis-route."""
         """Unknown region values fall back to 'global' rather than mis-route."""
-        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.api.routes.cloud import store_token
+        from backend.app.services.bambu_cloud_credentials import get_stored_token
 
 
         await store_token(db_session, "t", "x@test.com", "mars", user=None)
         await store_token(db_session, "t", "x@test.com", "mars", user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)
         _token, _email, region = await get_stored_token(db_session, user=None)

+ 5 - 5
backend/tests/integration/test_cloud_token_auth_migration.py

@@ -20,16 +20,16 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes import cloud as cloud_routes
 from backend.app.api.routes import cloud as cloud_routes
-from backend.app.api.routes.cloud import (
+from backend.app.core.auth import get_password_hash
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.bambu_cloud import BambuCloudError
+from backend.app.services.bambu_cloud_credentials import (
     CLOUD_EMAIL_KEY,
     CLOUD_EMAIL_KEY,
     CLOUD_REGION_KEY,
     CLOUD_REGION_KEY,
     CLOUD_TOKEN_KEY,
     CLOUD_TOKEN_KEY,
     get_stored_token,
     get_stored_token,
 )
 )
-from backend.app.core.auth import get_password_hash
-from backend.app.models.settings import Settings
-from backend.app.models.user import User
-from backend.app.services.bambu_cloud import BambuCloudError
 
 
 
 
 async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
 async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:

+ 60 - 38
backend/tests/integration/test_makerworld_apikey_auth.py

@@ -29,6 +29,12 @@ from backend.app.core.auth import generate_api_key
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.services.model_providers.base import (
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+)
 
 
 
 
 async def _setup_auth_with_admin(client: AsyncClient) -> str:
 async def _setup_auth_with_admin(client: AsyncClient) -> str:
@@ -104,6 +110,20 @@ def _fake_service(**stubs):
     return svc
     return svc
 
 
 
 
+def _download_info(
+    model_id: int = 1400373,
+    profile_id: int = 298919107,
+    name: str = "cube.3mf",
+) -> ProviderDownloadInfo:
+    """What ``service.get_download`` hands the route — signed URL + raw
+    upstream name + enriched ref (sub_id carries the resolved profile)."""
+    return ProviderDownloadInfo(
+        ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
+        url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
+        suggested_filename=name,
+    )
+
+
 class TestStatusEndpoint:
 class TestStatusEndpoint:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -161,9 +181,13 @@ class TestResolveEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         key = await _make_key(db_session, owner=admin, name="resolve-cloud")
         key = await _make_key(db_session, owner=admin, name="resolve-cloud")
 
 
-        design = {"id": 1400373, "modelId": "US2bb73b106683e5", "title": "Cube", "instances": []}
-        instances = {"total": 0, "hits": []}
-        svc = _fake_service(get_design=design, get_design_instances=instances)
+        svc = _fake_service(
+            resolve=ProviderResolvedModel(
+                ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
+                design={"id": 1400373, "title": "Cube"},
+                instances=[],
+            )
+        )
         build = AsyncMock(return_value=svc)
         build = AsyncMock(return_value=svc)
 
 
         with patch("backend.app.api.routes.makerworld._build_service", build):
         with patch("backend.app.api.routes.makerworld._build_service", build):
@@ -173,14 +197,22 @@ class TestResolveEndpoint:
                 headers={"X-API-Key": key},
                 headers={"X-API-Key": key},
             )
             )
         assert resp.status_code == 200, resp.text
         assert resp.status_code == 200, resp.text
-        # _build_service receives (db, user); the user arg must be the owning admin.
-        # Without the fix it'd be None (the API-key dep value).
+        # _build_service receives (db, provider, current_user, api_key_cloud_owner).
+        # Identity resolution lives in the provider now: for an API-keyed
+        # call current_user is None by design and the key's owner must arrive
+        # via api_key_cloud_owner — without the fix it'd be dropped entirely.
         assert build.await_count == 1
         assert build.await_count == 1
-        passed_user = (
-            build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
+        jwt_user = (
+            build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
         )
         )
-        assert passed_user is not None, "resolve_url must pass the API-key owner, not None"
-        assert passed_user.id == admin.id
+        key_owner = (
+            build.await_args.args[3]
+            if len(build.await_args.args) > 3
+            else build.await_args.kwargs.get("api_key_cloud_owner")
+        )
+        assert jwt_user is None, "API-keyed callers present no JWT user"
+        assert key_owner is not None, "resolve_url must forward the API-key owner to the provider"
+        assert key_owner.id == admin.id
 
 
 
 
 class TestImportEndpoint:
 class TestImportEndpoint:
@@ -195,23 +227,12 @@ class TestImportEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         key = await _make_key(db_session, owner=admin, name="import-cloud")
         key = await _make_key(db_session, owner=admin, name="import-cloud")
 
 
-        design = {
-            "id": 1400373,
-            "modelId": "US2bb73b106683e5",
-            "title": "Cube",
-            "instances": [{"profileId": 298919107, "title": "default"}],
-        }
-        manifest = {
-            "name": "cube.3mf",
-            "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
-        }
-        # 3MF download returns (bytes, filename). The bytes don't have to be a
-        # valid zip — save_3mf_bytes_to_library stores them as-is and the
-        # downstream thumbnail extractor swallows errors.
+        # The 3MF bytes don't have to be a valid zip —
+        # save_3mf_bytes_to_library stores them as-is and the downstream
+        # thumbnail extractor swallows errors.
         svc = _fake_service(
         svc = _fake_service(
-            get_design=design,
-            get_profile_download=manifest,
-            download_3mf=(b"PK\x03\x04fake-3mf-bytes", "cube.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -249,16 +270,9 @@ class TestImportEndpoint:
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
         key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
         key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
 
 
-        design = {
-            "id": 1400373,
-            "modelId": "US2bb73b106683e5",
-            "instances": [{"profileId": 298919107}],
-        }
-        manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
         svc = _fake_service(
         svc = _fake_service(
-            get_design=design,
-            get_profile_download=manifest,
-            download_3mf=(b"PK\x03\x04fake", "cube.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=b"PK\x03\x04fake", filename="cube.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
@@ -270,11 +284,19 @@ class TestImportEndpoint:
         assert resp.status_code == 200, resp.text
         assert resp.status_code == 200, resp.text
         body = resp.json()
         body = resp.json()
 
 
-        # _build_service got None — same as before the PR for non-cloud keys.
-        passed_user = (
-            build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
+        # Both identity slots are None — same as before the PR for non-cloud
+        # keys: no JWT user, and the cloud-scope fence keeps the key's owner
+        # back, so the provider builds an anonymous service.
+        jwt_user = (
+            build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
+        )
+        key_owner = (
+            build.await_args.args[3]
+            if len(build.await_args.args) > 3
+            else build.await_args.kwargs.get("api_key_cloud_owner")
         )
         )
-        assert passed_user is None
+        assert jwt_user is None
+        assert key_owner is None
 
 
         # And owner_id is NULL because the cloud-scope fence said no.
         # And owner_id is NULL because the cloud-scope fence said no.
         result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
         result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))

+ 180 - 0
backend/tests/integration/test_makerworld_permission_gate.py

@@ -0,0 +1,180 @@
+"""The /makerworld/* permission gate with auth enabled.
+
+The gate moved out of the route signature and into the handler: the provider
+that a request actually uses comes from the body (``source_type`` on import,
+the pasted URL on resolve), and FastAPI resolves dependencies before the body
+exists, so a dependency could only ever name one provider's permission. What
+must not change is the enforcement itself, so these pin the outcomes rather
+than the wiring: anonymous callers are still refused before the body is read,
+and a signed-in user without the permission still gets a 403.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.model_providers.base import (
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+)
+
+
+async def _setup_auth_with_admin(client: AsyncClient) -> str:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "mwadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": "mwadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _make_user(client: AsyncClient, admin_jwt: str, *, username: str, permissions: list[str]) -> str:
+    """Create a user in a fresh group holding exactly *permissions*."""
+    group = await client.post(
+        "/api/v1/groups/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={"name": f"grp_{username}", "permissions": permissions},
+    )
+    assert group.status_code in (200, 201), group.text
+    created = await client.post(
+        "/api/v1/users/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+def _fake_service(**stubs):
+    svc = AsyncMock()
+    svc.close = AsyncMock()
+    for name, value in stubs.items():
+        setattr(svc, name, AsyncMock(return_value=value))
+    return svc
+
+
+def _import_service():
+    return _fake_service(
+        get_download=ProviderDownloadInfo(
+            ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107"),
+            url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
+            suggested_filename="cube.3mf",
+        ),
+        download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
+    )
+
+
+class TestAnonymousIsRefusedFirst:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_import_is_401(self, async_client: AsyncClient):
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post("/api/v1/makerworld/import", json={"model_id": 1400373})
+        assert resp.status_code == 401, resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_resolve_is_401(self, async_client: AsyncClient):
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/makerworld/resolve",
+            json={"url": "https://makerworld.com/en/models/1400373"},
+        )
+        assert resp.status_code == 401, resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_with_a_malformed_body_is_still_401_not_422(self, async_client: AsyncClient):
+        """The permission moved into the handler, but authentication stayed a
+        route dependency precisely so an unauthenticated caller cannot probe
+        the request schema through validation errors."""
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post("/api/v1/makerworld/import", json={"nonsense": True})
+        assert resp.status_code == 401, resp.text
+
+
+class TestPermissionStillBites:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_view_only_user_cannot_import(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(async_client, admin, username="mwviewer", permissions=["makerworld:view"])
+
+        with patch(
+            "backend.app.api.routes.makerworld._build_service",
+            AsyncMock(return_value=_import_service()),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373},
+                headers={"Authorization": f"Bearer {jwt}"},
+            )
+        assert resp.status_code == 403, resp.text
+        assert "makerworld:import" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_user_without_view_cannot_resolve(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(async_client, admin, username="mwnoview", permissions=["printers:read"])
+
+        resp = await async_client.post(
+            "/api/v1/makerworld/resolve",
+            json={"url": "https://makerworld.com/en/models/1400373"},
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+        assert resp.status_code == 403, resp.text
+        assert "makerworld:view" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_user_holding_the_permission_gets_through(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(
+            async_client,
+            admin,
+            username="mwimporter",
+            permissions=["makerworld:view", "makerworld:import"],
+        )
+
+        with patch(
+            "backend.app.api.routes.makerworld._build_service",
+            AsyncMock(return_value=_import_service()),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373},
+                headers={"Authorization": f"Bearer {jwt}"},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["was_existing"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resolve_passes_for_a_viewer(self, async_client: AsyncClient):
+        admin = await _setup_auth_with_admin(async_client)
+        jwt = await _make_user(async_client, admin, username="mwviewer2", permissions=["makerworld:view"])
+
+        svc = _fake_service(
+            resolve=ProviderResolvedModel(
+                ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
+                design={"id": 1400373},
+                instances=[],
+            )
+        )
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/resolve",
+                json={"url": "https://makerworld.com/en/models/1400373"},
+                headers={"Authorization": f"Bearer {jwt}"},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["model_id"] == 1400373

+ 106 - 0
backend/tests/unit/services/test_bambu_cloud_credentials.py

@@ -0,0 +1,106 @@
+"""Tests for ``services/bambu_cloud_credentials`` — the credential seam.
+
+The read paths are covered indirectly by the cloud-token expiry and
+migration suites; these pin the write path that review blocker 5 hinged on:
+``mark_cloud_token_invalid`` must record a rejection for *both* identity
+shapes, because auth-disabled single-user installs (the default) hold their
+token in global ``Settings`` — ``user_id=None`` is a real, expected input,
+not a degenerate one.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.core.auth import get_password_hash
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services import bambu_cloud_credentials as creds
+from backend.app.services.bambu_cloud_credentials import (
+    CLOUD_TOKEN_INVALID_KEY,
+    mark_cloud_token_invalid,
+)
+
+pytestmark = pytest.mark.asyncio
+
+
+class _SharedSessionCtx:
+    """Route ``mark`` through the fixture's in-memory session: the function
+    normally opens its own session against the configured database, which in
+    tests is a different SQLite than ``db_session``'s in-memory one."""
+
+    def __init__(self, session):
+        self._session = session
+
+    async def __aenter__(self):
+        return self._session
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+@pytest.fixture(autouse=True)
+def shared_session(db_session, monkeypatch):
+    monkeypatch.setattr(creds, "async_session", lambda: _SharedSessionCtx(db_session))
+
+
+async def _make_user(db, username: str = "cred-user") -> User:
+    user = User(
+        username=username,
+        password_hash=get_password_hash("AdminPass1!"),
+        role="admin",
+        is_active=True,
+    )
+    db.add(user)
+    await db.commit()
+    await db.refresh(user)
+    return user
+
+
+async def test_mark_sets_the_per_user_flag(db_session):
+    """user_id set → the rejection lands on that user's column."""
+    user = await _make_user(db_session)
+
+    await mark_cloud_token_invalid(user.id)
+    await db_session.refresh(user)
+
+    assert user.cloud_token_invalid_at is not None
+
+
+async def test_mark_none_writes_the_global_settings_flag(db_session):
+    """user_id=None (auth-disabled install) → the global ``Settings`` row.
+    A second call updates the existing row rather than adding another."""
+    await mark_cloud_token_invalid(None)
+
+    result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    rows = result.scalars().all()
+    assert len(rows) == 1
+    # Stored value parses as ISO — the status endpoints compare it as a date.
+    datetime.fromisoformat(rows[0].value)
+
+    first_value = rows[0].value
+    await mark_cloud_token_invalid(None)
+    result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    rows = result.scalars().all()
+    assert len(rows) == 1
+    assert rows[0].value >= first_value
+
+
+async def test_mark_is_best_effort(db_session, monkeypatch):
+    """A bookkeeping failure must never replace the 401 the caller needs to
+    see — the function swallows everything."""
+
+    class _Boom:
+        async def __aenter__(self):
+            raise RuntimeError("db gone")
+
+        async def __aexit__(self, *exc):
+            return False
+
+    monkeypatch.setattr(creds, "async_session", lambda: _Boom())
+
+    # Must not raise.
+    await mark_cloud_token_invalid(None)

+ 287 - 27
backend/tests/unit/services/test_makerworld.py

@@ -8,55 +8,55 @@ from urllib.error import HTTPError, URLError
 import httpx
 import httpx
 import pytest
 import pytest
 
 
-from backend.app.services.makerworld import (
-    _MAX_3MF_BYTES,
-    MAKERWORLD_API_BASE,
+from backend.app.services.model_providers.base import ProviderResourceRef
+from backend.app.services.model_providers.makerworld.errors import (
     MakerWorldAuthError,
     MakerWorldAuthError,
     MakerWorldForbiddenError,
     MakerWorldForbiddenError,
     MakerWorldNotFoundError,
     MakerWorldNotFoundError,
-    MakerWorldService,
     MakerWorldUnavailableError,
     MakerWorldUnavailableError,
     MakerWorldUrlError,
     MakerWorldUrlError,
 )
 )
+from backend.app.services.model_providers.makerworld.http import _MAX_3MF_BYTES, MAKERWORLD_API_BASE
+from backend.app.services.model_providers.makerworld.service import MakerWorldService, set_shared_http_client
+from backend.app.services.model_providers.makerworld.url import parse_url
 
 
 
 
 class TestParseUrl:
 class TestParseUrl:
-    """MakerWorld URL extraction."""
+    """MakerWorld URL extraction — tests parse_url directly."""
 
 
     def test_strips_locale_prefix_and_slug(self):
     def test_strips_locale_prefix_and_slug(self):
-        model, profile = MakerWorldService.parse_url(
-            "https://makerworld.com/en/models/1400373-self-watering-seed-starter"
-        )
-        assert model == 1400373
-        assert profile is None
+        ref = parse_url("https://makerworld.com/en/models/1400373-self-watering-seed-starter")
+        assert ref.external_id == "1400373"
+        assert ref.sub_id is None
 
 
     def test_extracts_profile_id_from_fragment(self):
     def test_extracts_profile_id_from_fragment(self):
-        model, profile = MakerWorldService.parse_url("https://makerworld.com/en/models/1400373-slug#profileId-1452154")
-        assert model == 1400373
-        assert profile == 1452154
+        ref = parse_url("https://makerworld.com/en/models/1400373-slug#profileId-1452154")
+        assert ref.external_id == "1400373"
+        assert ref.sub_id == "1452154"
 
 
     def test_accepts_scheme_omitted(self):
     def test_accepts_scheme_omitted(self):
-        model, profile = MakerWorldService.parse_url("makerworld.com/models/999")
-        assert model == 999
-        assert profile is None
+        ref = parse_url("makerworld.com/models/999")
+        assert ref.external_id == "999"
+        assert ref.sub_id is None
 
 
     def test_accepts_subdomain(self):
     def test_accepts_subdomain(self):
         # Defensive: if MakerWorld ever stands up a regional subdomain, still accept it
         # Defensive: if MakerWorld ever stands up a regional subdomain, still accept it
-        model, _ = MakerWorldService.parse_url("https://www.makerworld.com/en/models/42")
-        assert model == 42
+        ref = parse_url("https://www.makerworld.com/en/models/42")
+        assert ref.external_id == "42"
+        assert ref.sub_id is None
 
 
     def test_rejects_non_makerworld_host(self):
     def test_rejects_non_makerworld_host(self):
         with pytest.raises(MakerWorldUrlError):
         with pytest.raises(MakerWorldUrlError):
-            MakerWorldService.parse_url("https://thingiverse.com/things/123")
+            parse_url("https://thingiverse.com/things/123")
 
 
     def test_rejects_malformed_url(self):
     def test_rejects_malformed_url(self):
         # No /models/ segment anywhere in path
         # No /models/ segment anywhere in path
         with pytest.raises(MakerWorldUrlError):
         with pytest.raises(MakerWorldUrlError):
-            MakerWorldService.parse_url("https://makerworld.com/en/creators/foo")
+            parse_url("https://makerworld.com/en/creators/foo")
 
 
     def test_rejects_empty(self):
     def test_rejects_empty(self):
         with pytest.raises(MakerWorldUrlError):
         with pytest.raises(MakerWorldUrlError):
-            MakerWorldService.parse_url("")
+            parse_url("")
 
 
 
 
 class TestApiBase:
 class TestApiBase:
@@ -269,6 +269,195 @@ class TestGetDesign:
             await service.get_design(1)
             await service.get_design(1)
 
 
 
 
+class TestResolve:
+    """``resolve`` — the interface-level "URL → metadata + plate list" flow the
+    /makerworld/resolve route drives. The per-instance printer-compatibility
+    merge lives here (not in the route) so every future provider gets it from
+    its own ``resolve`` implementation."""
+
+    @pytest.fixture
+    def service(self):
+        return MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+
+    @pytest.mark.asyncio
+    async def test_merges_compatibility_from_design_into_instances(self, service):
+        """Per-instance printer compatibility info lives on
+        ``design.instances[].extention.modelInfo`` but not on
+        ``/instances/hits``. Resolve enriches each hit with both
+        ``compatibility`` (primary printer the instance was sliced for) and
+        ``otherCompatibility`` (extra printers the uploader marked it
+        compatible with) so the frontend can show "sliced for A1 / also
+        marked compatible with: H2D, P1S".
+        """
+        design_payload = {
+            "id": 1400373,
+            "title": "Seed Starter",
+            "instances": [
+                {
+                    "id": 1452154,
+                    "extention": {
+                        "modelInfo": {
+                            "compatibility": ["A1"],
+                            "otherCompatibility": ["H2D", "P1S"],
+                        }
+                    },
+                },
+                {
+                    "id": 1452158,
+                    "extention": {
+                        "modelInfo": {
+                            "compatibility": ["X1 Carbon"],
+                            "otherCompatibility": [],
+                        }
+                    },
+                },
+            ],
+        }
+        instances_payload = {
+            "total": 2,
+            "hits": [
+                {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
+                {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
+            ],
+        }
+        service.get_design = AsyncMock(return_value=design_payload)
+        service.get_design_instances = AsyncMock(return_value=instances_payload)
+
+        resolved = await service.resolve(ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+        by_id = {i["id"]: i for i in resolved.instances}
+        assert by_id[1452154]["compatibility"] == ["A1"]
+        assert by_id[1452154]["otherCompatibility"] == ["H2D", "P1S"]
+        assert by_id[1452158]["compatibility"] == ["X1 Carbon"]
+        assert by_id[1452158]["otherCompatibility"] == []
+        assert resolved.design == design_payload
+
+    @pytest.mark.asyncio
+    async def test_handles_missing_compatibility_gracefully(self, service):
+        """Older designs (or hits without a matching design.instances entry)
+        must not crash resolve — they just don't get the compat fields."""
+        design_payload = {"id": 1400373, "instances": [{"id": 1452154}]}  # no extention
+        instances_payload = {
+            "total": 2,
+            "hits": [
+                {"id": 1452154, "profileId": 298919107},
+                {"id": 9999999, "profileId": 298919999},  # no design.instances match
+            ],
+        }
+        service.get_design = AsyncMock(return_value=design_payload)
+        service.get_design_instances = AsyncMock(return_value=instances_payload)
+
+        resolved = await service.resolve(ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+        # First instance: design entry exists but no extention → fields absent or None.
+        first = next(i for i in resolved.instances if i["id"] == 1452154)
+        assert first.get("compatibility") is None
+        assert first.get("otherCompatibility") is None
+        # Second instance: no design entry at all → no enrichment, no crash.
+        second = next(i for i in resolved.instances if i["id"] == 9999999)
+        assert "compatibility" not in second or second["compatibility"] is None
+
+    @pytest.mark.asyncio
+    async def test_normalises_null_and_non_list_hits_to_empty(self, service):
+        service.get_design = AsyncMock(return_value={"id": 1400373})
+        service.get_design_instances = AsyncMock(return_value={"total": 0, "hits": None})
+
+        resolved = await service.resolve(ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+        assert resolved.instances == []
+
+
+class TestGetDownload:
+    """``get_download`` — the interface-level "resource → signed 3MF URL"
+    flow the /makerworld/import route drives. The provider-specific dance
+    lives here: the iot-service endpoint needs the *alphanumeric* modelId
+    (not the integer design id), the profile falls back in two tiers, and
+    three malformed-upstream shapes must map to UnavailableError (502)."""
+
+    @pytest.fixture
+    def service(self):
+        return MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+
+    def _design(self, **overrides):
+        design = {
+            "id": 1400373,
+            "modelId": "US2bb73b106683e5",
+            "instances": [{"profileId": 298919107, "title": "9 cells"}],
+        }
+        design.update(overrides)
+        return design
+
+    def _manifest(self, url="https://makerworld.bblmw.com/x.3mf?exp=1", name="benchy.3mf"):
+        return {"url": url, "name": name}
+
+    async def _run(self, service, ref):
+        return await service.get_download(ref)
+
+    @pytest.mark.asyncio
+    async def test_resolves_alphanumeric_model_id_and_explicit_profile(self, service):
+        """Explicit profile_id flows through; get_profile_download receives
+        the alphanumeric modelId from the design, not the integer id."""
+        service.get_design = AsyncMock(return_value=self._design())
+        manifest = self._manifest()
+        service.get_profile_download = AsyncMock(return_value=manifest)
+
+        info = await self._run(
+            service, ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107")
+        )
+
+        service.get_profile_download.assert_awaited_once_with(298919107, "US2bb73b106683e5")
+        assert info.url == manifest["url"]
+        assert info.suggested_filename == "benchy.3mf"
+        # The enriched ref carries the resolved profile for the dedupe key.
+        assert info.ref.sub_id == "298919107"
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_first_design_instance_profile(self, service):
+        """No profile given → first ``design.instances[].profileId`` wins."""
+        service.get_design = AsyncMock(return_value=self._design())
+        service.get_profile_download = AsyncMock(return_value=self._manifest())
+
+        info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+
+        service.get_profile_download.assert_awaited_once_with(298919107, "US2bb73b106683e5")
+        assert info.ref.sub_id == "298919107"
+
+    @pytest.mark.asyncio
+    async def test_second_tier_falls_back_to_instances_envelope(self, service):
+        """Design carries no usable profileId → the ``/design/{id}/instances``
+        envelope is consulted before giving up."""
+        service.get_design = AsyncMock(return_value=self._design(instances=[{"title": "no profileId here"}]))
+        service.get_design_instances = AsyncMock(return_value={"total": 1, "hits": [{"profileId": 298919564}]})
+        service.get_profile_download = AsyncMock(return_value=self._manifest())
+
+        info = await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+
+        service.get_design_instances.assert_awaited_once_with(1400373)
+        service.get_profile_download.assert_awaited_once_with(298919564, "US2bb73b106683e5")
+        assert info.ref.sub_id == "298919564"
+
+    @pytest.mark.asyncio
+    async def test_missing_alphanumeric_model_id_is_unavailable(self, service):
+        """A design without the ``modelId`` field can't reach iot-service."""
+        service.get_design = AsyncMock(return_value={"id": 1400373})
+
+        with pytest.raises(MakerWorldUnavailableError, match="modelId"):
+            await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+
+    @pytest.mark.asyncio
+    async def test_no_profiles_anywhere_is_unavailable(self, service):
+        service.get_design = AsyncMock(return_value=self._design(instances=[]))
+        service.get_design_instances = AsyncMock(return_value={"total": 0, "hits": []})
+
+        with pytest.raises(MakerWorldUnavailableError, match="no instances"):
+            await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+
+    @pytest.mark.asyncio
+    async def test_manifest_without_url_is_unavailable(self, service):
+        service.get_design = AsyncMock(return_value=self._design())
+        service.get_profile_download = AsyncMock(return_value={"name": "benchy.3mf"})
+
+        with pytest.raises(MakerWorldUnavailableError, match="download URL"):
+            await self._run(service, ProviderResourceRef(source_type="makerworld", external_id="1400373"))
+
+
 class TestGetProfileDownload:
 class TestGetProfileDownload:
     """The new auth-gated 3MF manifest endpoint on the Bambu iot-service.
     """The new auth-gated 3MF manifest endpoint on the Bambu iot-service.
 
 
@@ -397,11 +586,52 @@ class TestDownload3MF:
         with pytest.raises(MakerWorldUrlError):
         with pytest.raises(MakerWorldUrlError):
             await svc.download_3mf(url)
             await svc.download_3mf(url)
 
 
+    @pytest.mark.asyncio
+    async def test_download_allowlist_is_driven_by_injected_hosts(self):
+        """``download_hosts`` is the SSRF seam, not a hardcoded constant (review
+        round 3 note 2). ``build_service`` passes ``ModelProvider.download_hosts()``
+        into ``MakerWorldService`` — prove the injection is live by accepting a
+        host inside a custom allowlist and refusing a MakerWorld CDN host that
+        isn't in it."""
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), download_hosts=("cdn.example.com",))
+
+        resp = MagicMock()
+        resp.status_code = 200
+
+        async def _chunks():
+            yield b"PK\x03\x04"
+
+        resp.aiter_bytes = lambda: _chunks()
+        svc._client.stream = MagicMock(return_value=self._stream_ctx(resp))
+
+        payload, _ = await svc.download_3mf("https://cdn.example.com/m/foo.3mf?exp=1&key=k")
+        assert payload == b"PK\x03\x04"
+
+        with pytest.raises(MakerWorldUrlError):
+            await svc.download_3mf("https://makerworld.bblmw.com/makerworld/model/X/Y/foo.3mf?exp=1&key=k")
+
+    @pytest.mark.asyncio
+    async def test_s3_suffix_family_is_allowed_regardless_of_injected_hosts(self):
+        """``_ALLOWED_DOWNLOAD_SUFFIXES`` is deliberately outside the
+        ``download_hosts()`` seam: Bambu's presigned S3 endpoints are this
+        provider's own signed-URL family, not an exact-host allowlist a
+        provider declares. Pinned so narrowing the injected hosts can never
+        silently take the S3 download path with it."""
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), download_hosts=("cdn.example.com",))
+        with patch(
+            "backend.app.services.model_providers.makerworld.service._download_s3_urllib",
+            AsyncMock(return_value=(b"PK\x03\x04", "plate.3mf")),
+        ) as s3:
+            payload, name = await svc.download_3mf("https://s3.us-west-2.amazonaws.com/bucket/plate.3mf?sig=1")
+        assert payload == b"PK\x03\x04"
+        assert name == "plate.3mf"
+        assert s3.await_count == 1
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_s3_host_delegates_to_urllib_path(self):
     async def test_s3_host_delegates_to_urllib_path(self):
         svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
         svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
         with patch(
         with patch(
-            "backend.app.services.makerworld._download_s3_urllib",
+            "backend.app.services.model_providers.makerworld.service._download_s3_urllib",
             new=AsyncMock(return_value=(b"payload", "file.3mf")),
             new=AsyncMock(return_value=(b"payload", "file.3mf")),
         ) as mocked:
         ) as mocked:
             payload, filename = await svc.download_3mf(
             payload, filename = await svc.download_3mf(
@@ -499,7 +729,7 @@ class TestS3UrllibDownload:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_returns_bytes_and_filename(self):
     async def test_returns_bytes_and_filename(self):
-        from backend.app.services.makerworld import _download_s3_urllib
+        from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
 
 
         fake_resp = MagicMock()
         fake_resp = MagicMock()
         fake_resp.status = 200
         fake_resp.status = 200
@@ -524,7 +754,7 @@ class TestS3UrllibDownload:
         """The ``_NoRedirect`` handler returns ``None`` from ``redirect_request``,
         """The ``_NoRedirect`` handler returns ``None`` from ``redirect_request``,
         which makes ``urllib`` raise ``HTTPError`` instead of following. The
         which makes ``urllib`` raise ``HTTPError`` instead of following. The
         wrapper must surface that as ``MakerWorldUnavailableError``."""
         wrapper must surface that as ``MakerWorldUnavailableError``."""
-        from backend.app.services.makerworld import _download_s3_urllib
+        from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
 
 
         fake_opener = MagicMock()
         fake_opener = MagicMock()
         fake_opener.open = MagicMock(
         fake_opener.open = MagicMock(
@@ -548,7 +778,7 @@ class TestS3UrllibDownload:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_non_200_raises_unavailable(self):
     async def test_non_200_raises_unavailable(self):
-        from backend.app.services.makerworld import _download_s3_urllib
+        from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
 
 
         fake_resp = MagicMock()
         fake_resp = MagicMock()
         fake_resp.status = 403
         fake_resp.status = 403
@@ -570,7 +800,7 @@ class TestS3UrllibDownload:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_size_cap_enforced(self):
     async def test_size_cap_enforced(self):
-        from backend.app.services.makerworld import _download_s3_urllib
+        from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
 
 
         fake_resp = MagicMock()
         fake_resp = MagicMock()
         fake_resp.status = 200
         fake_resp.status = 200
@@ -593,7 +823,7 @@ class TestS3UrllibDownload:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_network_error_mapped_to_unavailable(self):
     async def test_network_error_mapped_to_unavailable(self):
-        from backend.app.services.makerworld import _download_s3_urllib
+        from backend.app.services.model_providers.makerworld.http import _download_s3_urllib
 
 
         fake_opener = MagicMock()
         fake_opener = MagicMock()
         fake_opener.open = MagicMock(side_effect=URLError("dns fail"))
         fake_opener.open = MagicMock(side_effect=URLError("dns fail"))
@@ -699,3 +929,33 @@ class TestFetchThumbnail:
 
 
         with pytest.raises(MakerWorldUnavailableError):
         with pytest.raises(MakerWorldUnavailableError):
             await service.fetch_thumbnail("https://makerworld.bblmw.com/makerworld/model/X/blob")
             await service.fetch_thumbnail("https://makerworld.bblmw.com/makerworld/model/X/blob")
+
+
+class TestSharedHttpClient:
+    """The app-scoped httpx client registered via ``set_shared_http_client``
+    must be reused by per-request services (one shared connection pool, same
+    pattern as ``bambu_cloud``). The setter has to live in the same module as
+    the service class, or the import-time snapshot never sees the lifespan's
+    late registration and every request spins up its own client."""
+
+    @pytest.mark.asyncio
+    async def test_reuses_registered_client(self):
+        client = MagicMock(spec=httpx.AsyncClient)
+        set_shared_http_client(client)
+        try:
+            svc = MakerWorldService()
+            assert svc._client is client
+            assert svc._owns_client is False
+            # close() must NOT close a client it doesn't own
+            await svc.close()
+            client.aclose.assert_not_called()
+        finally:
+            set_shared_http_client(None)
+
+    @pytest.mark.asyncio
+    async def test_creates_and_owns_own_client_when_none_registered(self):
+        set_shared_http_client(None)
+        svc = MakerWorldService()
+        assert svc._owns_client is True
+        await svc.close()
+        assert svc._client.is_closed

+ 221 - 0
backend/tests/unit/services/test_model_provider_interface.py

@@ -0,0 +1,221 @@
+"""Tests for the MakerWorld provider descriptor (``provider.py``).
+
+The descriptor is the interface between the route layer and the per-request
+service: identity, URL routing, auth requirements, and the factory that seeds
+a service with the caller's stored Bambu Cloud bearer token.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.core.permissions import Permission
+from backend.app.services.model_providers import makerworld_provider
+from backend.app.services.model_providers.base import ModelProvider
+from backend.app.services.model_providers.makerworld.service import MakerWorldService
+
+
+class TestMakerWorldProviderDescriptor:
+    def test_identity_fields(self):
+        assert makerworld_provider.source_type == "makerworld"
+        assert makerworld_provider.display_name == "MakerWorld"
+        assert makerworld_provider.host_patterns == ("makerworld.com",)
+        assert makerworld_provider.default_folder_name == "MakerWorld"
+
+    def test_permissions(self):
+        assert makerworld_provider.view_permission == Permission.MAKERWORLD_VIEW
+        assert makerworld_provider.import_permission == Permission.MAKERWORLD_IMPORT
+
+    def test_auth_descriptor(self):
+        assert makerworld_provider.auth is not None
+        assert makerworld_provider.auth.auth_type == "bambu_cloud_bearer"
+        assert makerworld_provider.auth.credential_fields == ()
+
+    def test_supports_url_host_suffix_match(self):
+        assert makerworld_provider.supports_url("https://makerworld.com/en/models/1400373")
+        assert makerworld_provider.supports_url("https://www.makerworld.com/models/1400373")
+        assert makerworld_provider.supports_url("makerworld.com/models/1")
+
+    def test_rejects_foreign_hosts_and_garbage(self):
+        assert not makerworld_provider.supports_url("https://thingiverse.com/thing/123")
+        assert not makerworld_provider.supports_url("https://makerworld.com.evil.example/x")
+        assert not makerworld_provider.supports_url("")
+        assert not makerworld_provider.supports_url(None)  # type: ignore[arg-type]
+        assert not makerworld_provider.supports_url(123)  # type: ignore[arg-type]
+
+    def test_thumbnail_hosts_is_the_cdn_allowlist(self):
+        assert "makerworld.bblmw.com" in makerworld_provider.thumbnail_hosts()
+        assert "public-cdn.bblmw.com" in makerworld_provider.thumbnail_hosts()
+
+    def test_download_hosts_is_the_cdn_allowlist(self):
+        """The download-guard SSRF seam mirrors the thumbnail one — a provider
+        that fetches files server-side declares the hosts its service may
+        fetch from (review round 3, note 2)."""
+        assert "makerworld.bblmw.com" in makerworld_provider.download_hosts()
+        assert "public-cdn.bblmw.com" in makerworld_provider.download_hosts()
+
+    def test_parse_and_canonical_roundtrip(self):
+        ref = makerworld_provider.parse_url("https://makerworld.com/en/models/1400373-slug#profileId-1452154")
+        assert ref.external_id == "1400373"
+        assert ref.sub_id == "1452154"
+        assert ref.source_type == "makerworld"
+        assert makerworld_provider.canonical_url(ref) == "https://makerworld.com/models/1400373#profileId-1452154"
+
+    def test_canonical_plate_less_shape(self):
+        ref = makerworld_provider.parse_url("https://makerworld.com/models/999")
+        assert makerworld_provider.canonical_url(ref) == "https://makerworld.com/models/999"
+
+    def test_source_url_filter_matches_model_and_any_plate(self):
+        """The already-imported predicate must cover the whole-model key and
+        every per-plate key — MakerWorld's canonical shape appends
+        ``#profileId-{n}`` (review round 3: shape knowledge belongs to the
+        provider, not the route)."""
+        from sqlalchemy import String, column as sa_column
+
+        expr = makerworld_provider.source_url_filter(sa_column("source_url", String), "1400373")
+        sql = str(expr.compile(compile_kwargs={"literal_binds": True}))
+        assert "source_url = 'https://makerworld.com/models/1400373'" in sql
+        assert "LIKE 'https://makerworld.com/models/1400373#profileId-%'" in sql
+
+    def test_default_source_url_filter_is_exact_match_only(self):
+        """A provider without plate-shaped keys inherits the default: exact
+        match on its whole-model canonical URL, nothing else."""
+        from sqlalchemy import String, column as sa_column
+
+        expr = _WholeModelProvider().source_url_filter(sa_column("source_url", String), "42")
+        sql = str(expr.compile(compile_kwargs={"literal_binds": True}))
+        assert sql == "source_url = 'https://example.com/models/42'"
+        assert "LIKE" not in sql
+
+
+class _WholeModelProvider(ModelProvider):
+    """Minimal concrete provider that keys dedupe at whole-model granularity
+    only — exercises the inherited default ``source_url_filter``."""
+
+    source_type = "wholemodel"
+    display_name = "WholeModel"
+
+    async def build_service(self, *, db, user, api_key_owner=None, client=None):
+        raise NotImplementedError
+
+    def parse_url(self, url):
+        raise NotImplementedError
+
+    def canonical_url(self, ref):
+        return f"https://example.com/models/{ref.external_id}"
+
+
+class TestBuildService:
+    """build_service must reproduce exactly what the old route helper did:
+    read the caller's stored Bambu Cloud token and wire the rejected-token
+    callback so a 401 invalidates the shared credential app-wide."""
+
+    @pytest.mark.asyncio
+    async def test_seeds_token_and_auth_failure_callback(self):
+        db = AsyncMock()
+        user = AsyncMock()
+        user.id = 7
+
+        with (
+            patch(
+                "backend.app.services.model_providers.makerworld.provider.get_stored_token",
+                AsyncMock(return_value=("tok-abc", "e@x.com", "global")),
+            ),
+            patch(
+                "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
+                AsyncMock(),
+            ) as mark_invalid,
+        ):
+            svc = await makerworld_provider.build_service(db=db, user=user)
+            assert isinstance(svc, MakerWorldService)
+            assert svc._auth_token == "tok-abc"
+            assert svc._user is user
+            await svc._on_auth_failure()
+            mark_invalid.assert_awaited_once_with(7)
+            await svc.close()
+
+    @pytest.mark.asyncio
+    async def test_anonymous_user_still_gets_auth_callback(self):
+        """No user ≠ nothing to invalidate. Auth-disabled single-user installs
+        hold their token in global Settings (``get_stored_token(db, None)``
+        reads it), so a rejection must still be recorded — ``user_id=None``
+        writes the global flag (review blocker 5). The callback stays wired;
+        only a *stray* non-expiry 401 keeps it a no-op."""
+        db = AsyncMock()
+        with (
+            patch(
+                "backend.app.services.model_providers.makerworld.provider.get_stored_token",
+                AsyncMock(return_value=(None, None, "global")),
+            ),
+            patch(
+                "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
+                AsyncMock(),
+            ) as mark_invalid,
+        ):
+            svc = await makerworld_provider.build_service(db=db, user=None)
+
+            assert svc._auth_token is None
+            assert svc._on_auth_failure is not None
+            # Firing it records the *global* flag (user_id=None), not a per-user row.
+            await svc._on_auth_failure()
+            mark_invalid.assert_awaited_once_with(None)
+        await svc.close()
+
+    @pytest.mark.asyncio
+    async def test_build_service_passes_declared_thumbnail_hosts(self):
+        """The SSRF seam contract: ``fetch_thumbnail``'s allowlist must come
+        from ``ModelProvider.thumbnail_hosts()`` via build_service — not from
+        a hardcoded copy inside the service (review round 2, item 1)."""
+        db = AsyncMock()
+        with patch(
+            "backend.app.services.model_providers.makerworld.provider.get_stored_token",
+            AsyncMock(return_value=(None, None, "global")),
+        ):
+            svc = await makerworld_provider.build_service(db=db, user=None)
+
+        assert svc._thumbnail_hosts == makerworld_provider.thumbnail_hosts()
+        assert len(svc._thumbnail_hosts) > 0
+        await svc.close()
+
+    @pytest.mark.asyncio
+    async def test_build_service_passes_declared_download_hosts(self):
+        """Symmetric SSRF seam contract: ``download``'s allowlist must come
+        from ``ModelProvider.download_hosts()`` via build_service — not from
+        a hardcoded copy inside the service (review round 3, note 2)."""
+        db = AsyncMock()
+        with patch(
+            "backend.app.services.model_providers.makerworld.provider.get_stored_token",
+            AsyncMock(return_value=(None, None, "global")),
+        ):
+            svc = await makerworld_provider.build_service(db=db, user=None)
+
+        assert svc._download_hosts == makerworld_provider.download_hosts()
+        assert len(svc._download_hosts) > 0
+        await svc.close()
+
+    @pytest.mark.asyncio
+    async def test_api_key_owner_is_the_fallback_identity(self):
+        """API-keyed callers carry identity on the key (#1777) — build_service
+        must use the key's owner when ``user`` is None."""
+        db = AsyncMock()
+        owner = AsyncMock()
+        owner.id = 11
+
+        with (
+            patch(
+                "backend.app.services.model_providers.makerworld.provider.get_stored_token",
+                AsyncMock(return_value=("owner-tok", "owner@x.com", "global")),
+            ),
+            patch(
+                "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
+                AsyncMock(),
+            ) as mark_invalid,
+        ):
+            svc = await makerworld_provider.build_service(db=db, user=None, api_key_owner=owner)
+            assert svc._auth_token == "owner-tok"
+            assert svc._user is owner
+            await svc._on_auth_failure()
+            mark_invalid.assert_awaited_once_with(11)
+            await svc.close()

+ 2 - 1
backend/tests/unit/test_cloud_captcha_2790.py

@@ -205,7 +205,8 @@ class TestMakerWorldSharesTheDetector:
     async def test_a_challenge_worded_differently_is_still_named(self):
     async def test_a_challenge_worded_differently_is_still_named(self):
         """MakerWorld used to require the literal word "robot" in the error text
         """MakerWorld used to require the literal word "robot" in the error text
         and reported anything else as an unexplained block."""
         and reported anything else as an unexplained block."""
-        from backend.app.services.makerworld import MakerWorldService, MakerWorldUnavailableError
+        from backend.app.services.model_providers.makerworld.errors import MakerWorldUnavailableError
+        from backend.app.services.model_providers.makerworld.service import MakerWorldService
 
 
         svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok")
         svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok")
         svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"}))
         svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"}))

+ 5 - 6
backend/tests/unit/test_cloud_token_expiry.py

@@ -23,18 +23,17 @@ from unittest.mock import AsyncMock, MagicMock
 import httpx
 import httpx
 import pytest
 import pytest
 
 
-from backend.app.api.routes.cloud import (
+from backend.app.api.routes.cloud import clear_token, store_token
+from backend.app.models.settings import Settings
+from backend.app.services import bambu_cloud as bc
+from backend.app.services.bambu_cloud import BambuCloudService
+from backend.app.services.bambu_cloud_credentials import (
     CLOUD_EMAIL_KEY,
     CLOUD_EMAIL_KEY,
     CLOUD_REGION_KEY,
     CLOUD_REGION_KEY,
     CLOUD_TOKEN_INVALID_KEY,
     CLOUD_TOKEN_INVALID_KEY,
     CLOUD_TOKEN_KEY,
     CLOUD_TOKEN_KEY,
-    clear_token,
     is_cloud_token_invalid,
     is_cloud_token_invalid,
-    store_token,
 )
 )
-from backend.app.models.settings import Settings
-from backend.app.services import bambu_cloud as bc
-from backend.app.services.bambu_cloud import BambuCloudService
 
 
 
 
 @pytest.fixture(autouse=True)
 @pytest.fixture(autouse=True)

+ 6 - 6
backend/tests/unit/test_github_backup_cloud_profiles.py

@@ -83,7 +83,7 @@ class TestCloudAccountEnumeration:
         Settings table and the account is keyed ``global``."""
         Settings table and the account is keyed ``global``."""
         with (
         with (
             patch(
             patch(
-                "backend.app.api.routes.cloud.get_stored_token",
+                "backend.app.services.bambu_cloud_credentials.get_stored_token",
                 new_callable=AsyncMock,
                 new_callable=AsyncMock,
                 return_value=("bambu-token", "a@b.c", "global"),
                 return_value=("bambu-token", "a@b.c", "global"),
             ),
             ),
@@ -113,7 +113,7 @@ class TestCloudAccountEnumeration:
 
 
         with (
         with (
             patch(
             patch(
-                "backend.app.api.routes.cloud.get_stored_token",
+                "backend.app.services.bambu_cloud_credentials.get_stored_token",
                 new_callable=AsyncMock,
                 new_callable=AsyncMock,
                 return_value=(None, None, "global"),
                 return_value=(None, None, "global"),
             ),
             ),
@@ -137,7 +137,7 @@ class TestCloudAccountEnumeration:
 
 
         with (
         with (
             patch(
             patch(
-                "backend.app.api.routes.cloud.get_stored_token",
+                "backend.app.services.bambu_cloud_credentials.get_stored_token",
                 new_callable=AsyncMock,
                 new_callable=AsyncMock,
                 return_value=("legacy-global", None, "global"),
                 return_value=("legacy-global", None, "global"),
             ),
             ),
@@ -405,7 +405,7 @@ class TestCollectorAndMetadata:
         files: dict = {}
         files: dict = {}
         with (
         with (
             patch(
             patch(
-                "backend.app.api.routes.cloud.get_stored_token",
+                "backend.app.services.bambu_cloud_credentials.get_stored_token",
                 new_callable=AsyncMock,
                 new_callable=AsyncMock,
                 return_value=(None, None, "global"),
                 return_value=(None, None, "global"),
             ),
             ),
@@ -435,7 +435,7 @@ class TestCollectorAndMetadata:
         files: dict = {}
         files: dict = {}
         with (
         with (
             patch(
             patch(
-                "backend.app.api.routes.cloud.get_stored_token",
+                "backend.app.services.bambu_cloud_credentials.get_stored_token",
                 new_callable=AsyncMock,
                 new_callable=AsyncMock,
                 return_value=(None, None, "global"),
                 return_value=(None, None, "global"),
             ),
             ),
@@ -500,7 +500,7 @@ class TestSettingsFallbackIsStillHonoured:
         await db_session.commit()
         await db_session.commit()
 
 
         with patch(
         with patch(
-            "backend.app.api.routes.cloud.get_stored_token",
+            "backend.app.services.bambu_cloud_credentials.get_stored_token",
             new_callable=AsyncMock,
             new_callable=AsyncMock,
             return_value=(None, None, "global"),
             return_value=(None, None, "global"),
         ):
         ):

+ 408 - 161
backend/tests/unit/test_makerworld_routes.py

@@ -14,8 +14,33 @@ from unittest.mock import AsyncMock, patch
 
 
 import pytest
 import pytest
 
 
-from backend.app.api.routes.makerworld import _canonical_url
+from backend.app.api.routes import makerworld as makerworld_routes
+from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+    ProviderStatus,
+)
+from backend.app.services.model_providers.makerworld import makerworld_provider
+
+
+def _download_info(
+    model_id: int = 1400373,
+    profile_id: int = 298919107,
+    name: str = "benchy.3mf",
+    url: str = "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
+) -> ProviderDownloadInfo:
+    """What ``service.get_download`` hands the route: signed URL + raw upstream
+    name + the enriched resource ref (``sub_id`` carries the resolved profile)."""
+    return ProviderDownloadInfo(
+        ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
+        url=url,
+        suggested_filename=name,
+    )
 
 
 
 
 def _fake_service(**stubs):
 def _fake_service(**stubs):
@@ -30,37 +55,117 @@ def _fake_service(**stubs):
     return svc
     return svc
 
 
 
 
-def _default_design(alphanumeric: str = "US2bb73b106683e5", model_id: int = 1400373):
-    """Shape the backend needs from ``/design/{id}``: the alphanumeric
-    ``modelId`` field that iot-service requires, plus at least one instance
-    so the importer has a ``profile_id`` to fall back on."""
-    return {
-        "id": model_id,
-        "modelId": alphanumeric,
-        "title": "Seed Starter",
-        "instances": [{"profileId": 298919107, "title": "9 cells"}],
-    }
+class _DummyProvider(ModelProvider):
+    """Stand-in for a second registered model provider.
 
 
+    Lets the route tests exercise behaviour that differs from the MakerWorld
+    singleton — a provider-specific default folder name (or none at all), and
+    its own permissions — without registering anything in the app-wide
+    registry. The permissions deliberately are *not* the MakerWorld ones: the
+    routes must gate on whichever provider the request resolved to.
+    """
 
 
-def _default_manifest(name: str = "benchy.3mf"):
-    return {
-        "name": name,
-        "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
-    }
+    source_type = "dummy"
+    display_name = "Dummy"
 
 
+    def __init__(
+        self,
+        default_folder_name: str | None = "Dummy Imports",
+        view_permission: Permission | None = Permission.LIBRARY_READ,
+        import_permission: Permission | None = Permission.LIBRARY_UPLOAD,
+    ):
+        self.default_folder_name = default_folder_name
+        self.view_permission = view_permission
+        self.import_permission = import_permission
 
 
-class TestCanonicalUrl:
-    """Unit test the dedupe-key builder directly — regressions break dedupe
-    silently so it's worth pinning the exact shape."""
+    async def build_service(self, *, db, user, api_key_owner=None, client=None):
+        raise NotImplementedError
 
 
-    def test_without_profile_id(self):
-        assert _canonical_url(1400373) == "https://makerworld.com/models/1400373"
+    def parse_url(self, url):
+        return ProviderResourceRef(source_type=self.source_type, external_id="1400373", original_url=url)
 
 
-    def test_without_profile_id_when_none(self):
-        assert _canonical_url(1400373, None) == "https://makerworld.com/models/1400373"
+    def canonical_url(self, ref):
+        return f"https://dummy.example.com/models/{ref.external_id}"
 
 
-    def test_with_profile_id(self):
-        assert _canonical_url(1400373, 298919107) == ("https://makerworld.com/models/1400373#profileId-298919107")
+
+def _permission_spy():
+    """Record which permission the route hands the shared gate.
+
+    The gate itself still runs — the spy delegates to the real factory — so a
+    test using it proves the wiring without loosening the check.
+    """
+    seen: list = []
+    real = makerworld_routes.require_permission_if_auth_enabled
+
+    def factory(*permissions):
+        seen.extend(permissions)
+        return real(*permissions)
+
+    return seen, factory
+
+
+class TestThumbnail:
+    """GET /makerworld/thumbnail — the anonymous CDN image proxy."""
+
+    def _patch_service(self, svc):
+        return patch("backend.app.api.routes.makerworld.MakerWorldService", return_value=svc)
+
+    @pytest.mark.asyncio
+    async def test_proxies_image_with_immutable_cache(self, async_client):
+        from unittest.mock import MagicMock
+
+        svc = MagicMock()
+        svc.fetch_thumbnail = AsyncMock(return_value=(b"png-bytes", "image/png"))
+        svc.close = AsyncMock()
+
+        with self._patch_service(svc):
+            resp = await async_client.get(
+                "/api/v1/makerworld/thumbnail",
+                params={"url": "https://makerworld.bblmw.com/img/x.png"},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.content == b"png-bytes"
+        assert resp.headers["content-type"] == "image/png"
+        assert "immutable" in resp.headers["cache-control"]
+        # The SSRF allowlist is the provider's declared seam, not a local
+        # copy inside the route (review round 2).
+        assert svc.fetch_thumbnail.await_args.args[0] == "https://makerworld.bblmw.com/img/x.png"
+        svc.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_allowlist_comes_from_the_provider_descriptor(self, async_client):
+        from unittest.mock import MagicMock
+
+        svc = MagicMock()
+        svc.fetch_thumbnail = AsyncMock(return_value=(b"x", "image/png"))
+        svc.close = AsyncMock()
+
+        with self._patch_service(svc) as cls:
+            await async_client.get(
+                "/api/v1/makerworld/thumbnail",
+                params={"url": "https://makerworld.bblmw.com/img/x.png"},
+            )
+        assert cls.call_args.kwargs["thumbnail_hosts"] == makerworld_provider.thumbnail_hosts()
+
+    @pytest.mark.asyncio
+    async def test_non_cdn_host_is_a_clean_400(self, async_client):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
+
+        svc = MagicMock()
+        svc.fetch_thumbnail = AsyncMock(
+            side_effect=MakerWorldUrlError("Refusing to fetch thumbnail from non-MakerWorld host: 'evil.example'")
+        )
+        svc.close = AsyncMock()
+
+        with self._patch_service(svc):
+            resp = await async_client.get(
+                "/api/v1/makerworld/thumbnail",
+                params={"url": "https://evil.example/x.png"},
+            )
+        assert resp.status_code == 400
+        svc.close.assert_awaited_once()
 
 
 
 
 class TestStatus:
 class TestStatus:
@@ -79,8 +184,8 @@ class TestStatus:
         used to be a bare alias for ``has_cloud_token``, so the import button
         used to be a bare alias for ``has_cloud_token``, so the import button
         stayed live against a dead credential and the user only found out via a
         stayed live against a dead credential and the user only found out via a
         401 toast."""
         401 toast."""
-        from backend.app.api.routes.cloud import CLOUD_TOKEN_INVALID_KEY, CLOUD_TOKEN_KEY
         from backend.app.models.settings import Settings
         from backend.app.models.settings import Settings
+        from backend.app.services.bambu_cloud_credentials import CLOUD_TOKEN_INVALID_KEY, CLOUD_TOKEN_KEY
 
 
         db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="dead-token"))
         db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="dead-token"))
         db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
         db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
@@ -94,6 +199,33 @@ class TestStatus:
             "sign_in_expired": True,
             "sign_in_expired": True,
         }
         }
 
 
+    @pytest.mark.asyncio
+    async def test_sign_in_expired_reads_credential_rejected_not_auth_error(self, async_client):
+        """The route keys ``sign_in_expired`` off the machine-readable
+        ``credential_rejected`` flag, not ``auth_error`` (review round 3 note 1):
+        ``auth_error`` is the human-readable reason and may be set for non-
+        credential failures too. A service reporting an expired credential
+        *without* a message must still surface ``sign_in_expired=True``."""
+        svc = AsyncMock()
+        svc.close = AsyncMock()
+        svc.get_status = AsyncMock(
+            return_value=ProviderStatus(
+                authenticated=True,
+                can_download=False,
+                auth_error=None,
+                credential_rejected=True,
+            )
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.get("/api/v1/makerworld/status")
+        assert resp.status_code == 200
+        assert resp.json() == {
+            "has_cloud_token": True,
+            "can_download": False,
+            "sign_in_expired": True,
+        }
+
 
 
 class TestResolve:
 class TestResolve:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -102,20 +234,25 @@ class TestResolve:
             "/api/v1/makerworld/resolve",
             "/api/v1/makerworld/resolve",
             json={"url": "https://thingiverse.com/thing/1"},
             json={"url": "https://thingiverse.com/thing/1"},
         )
         )
+        # A pasted link for an unsupported host is a clean client-input 400,
+        # never a 500 — the registry guard runs before any provider call.
         assert resp.status_code == 400
         assert resp.status_code == 400
-        assert "makerworld" in resp.json()["detail"].lower()
+        assert "provider" in resp.json()["detail"].lower()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_happy_path_returns_design_and_instances(self, async_client):
     async def test_happy_path_returns_design_and_instances(self, async_client):
         design_payload = {"id": 1400373, "title": "Seed Starter"}
         design_payload = {"id": 1400373, "title": "Seed Starter"}
-        instances_payload = {
-            "total": 2,
-            "hits": [
-                {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
-                {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
-            ],
-        }
-        svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
+        instances_payload = [
+            {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
+            {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
+        ]
+        svc = _fake_service(
+            resolve=ProviderResolvedModel(
+                ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="1452154"),
+                design=design_payload,
+                instances=instances_payload,
+            )
+        )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
             resp = await async_client.post(
             resp = await async_client.post(
@@ -132,8 +269,10 @@ class TestResolve:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_flags_already_imported_library_ids(self, async_client, db_session):
     async def test_flags_already_imported_library_ids(self, async_client, db_session):
-        # Seed a matching LibraryFile so resolve() reports it back
-        existing = LibraryFile(
+        """Both dedupe shapes must be found through the provider's
+        ``source_url_filter``: the whole-model canonical URL *and* any
+        plate-level ``#profileId-`` row."""
+        model_row = LibraryFile(
             filename="prev.3mf",
             filename="prev.3mf",
             file_path="library/files/prev.3mf",
             file_path="library/files/prev.3mf",
             file_type="3mf",
             file_type="3mf",
@@ -141,13 +280,25 @@ class TestResolve:
             source_type="makerworld",
             source_type="makerworld",
             source_url="https://makerworld.com/models/1400373",
             source_url="https://makerworld.com/models/1400373",
         )
         )
-        db_session.add(existing)
+        plate_row = LibraryFile(
+            filename="prev-plate.3mf",
+            file_path="library/files/prev-plate.3mf",
+            file_type="3mf",
+            file_size=100,
+            source_type="makerworld",
+            source_url="https://makerworld.com/models/1400373#profileId-298919107",
+        )
+        db_session.add_all([model_row, plate_row])
         await db_session.commit()
         await db_session.commit()
-        await db_session.refresh(existing)
+        await db_session.refresh(model_row)
+        await db_session.refresh(plate_row)
 
 
         svc = _fake_service(
         svc = _fake_service(
-            get_design={"id": 1400373},
-            get_design_instances={"total": 0, "hits": []},
+            resolve=ProviderResolvedModel(
+                ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
+                design={"id": 1400373},
+                instances=[],
+            )
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -156,93 +307,34 @@ class TestResolve:
                 json={"url": "https://makerworld.com/en/models/1400373"},
                 json={"url": "https://makerworld.com/en/models/1400373"},
             )
             )
         assert resp.status_code == 200, resp.text
         assert resp.status_code == 200, resp.text
-        assert resp.json()["already_imported_library_ids"] == [existing.id]
+        assert sorted(resp.json()["already_imported_library_ids"]) == sorted([model_row.id, plate_row.id])
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_merges_compatibility_from_design_into_instances(self, async_client):
-        """Per-instance printer compatibility info lives on
-        ``design.instances[].extention.modelInfo`` but not on
-        ``/instances/hits``. Resolve enriches each hit with both
-        ``compatibility`` (primary printer the instance was sliced for) and
-        ``otherCompatibility`` (extra printers the uploader marked it
-        compatible with) so the frontend can show "sliced for A1 / also
-        marked compatible with: H2D, P1S".
-        """
-        design_payload = {
-            "id": 1400373,
-            "title": "Seed Starter",
-            "instances": [
-                {
-                    "id": 1452154,
-                    "extention": {
-                        "modelInfo": {
-                            "compatibility": ["A1"],
-                            "otherCompatibility": ["H2D", "P1S"],
-                        }
-                    },
-                },
-                {
-                    "id": 1452158,
-                    "extention": {
-                        "modelInfo": {
-                            "compatibility": ["X1 Carbon"],
-                            "otherCompatibility": [],
-                        }
-                    },
-                },
-            ],
-        }
-        instances_payload = {
-            "total": 2,
-            "hits": [
-                {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
-                {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
-            ],
-        }
-        svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
-
-        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
-            resp = await async_client.post(
-                "/api/v1/makerworld/resolve",
-                json={"url": "https://makerworld.com/en/models/1400373"},
+    async def test_gate_uses_the_permission_of_the_provider_the_url_routes_to(self, async_client):
+        """Same rule as import, keyed off the pasted URL instead of
+        ``source_type``: a link that routes to another provider is gated on
+        that provider's view permission, not ``makerworld:view``."""
+        seen, factory = _permission_spy()
+        dummy = _DummyProvider()
+        svc = _fake_service(
+            resolve=ProviderResolvedModel(
+                ref=ProviderResourceRef(source_type="dummy", external_id="1400373"),
+                design={"id": 1400373},
+                instances=[],
             )
             )
-        assert resp.status_code == 200, resp.text
-        instances = resp.json()["instances"]
-        by_id = {i["id"]: i for i in instances}
-        assert by_id[1452154]["compatibility"] == ["A1"]
-        assert by_id[1452154]["otherCompatibility"] == ["H2D", "P1S"]
-        assert by_id[1452158]["compatibility"] == ["X1 Carbon"]
-        assert by_id[1452158]["otherCompatibility"] == []
-
-    @pytest.mark.asyncio
-    async def test_resolve_handles_missing_compatibility_gracefully(self, async_client):
-        """Older designs (or hits without a matching design.instances entry)
-        must not crash the resolve response — they just don't get the
-        compat fields."""
-        design_payload = {"id": 1400373, "instances": [{"id": 1452154}]}  # no extention
-        instances_payload = {
-            "total": 2,
-            "hits": [
-                {"id": 1452154, "profileId": 298919107},
-                {"id": 9999999, "profileId": 298919999},  # no design.instances match
-            ],
-        }
-        svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
+        )
 
 
-        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+        with (
+            patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
+            patch("backend.app.api.routes.makerworld._provider_for_url", return_value=dummy),
+            patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
+        ):
             resp = await async_client.post(
             resp = await async_client.post(
                 "/api/v1/makerworld/resolve",
                 "/api/v1/makerworld/resolve",
-                json={"url": "https://makerworld.com/en/models/1400373"},
+                json={"url": "https://dummy.example.com/models/1400373"},
             )
             )
         assert resp.status_code == 200, resp.text
         assert resp.status_code == 200, resp.text
-        instances = resp.json()["instances"]
-        # First instance: design entry exists but no extention → fields absent or None.
-        first = next(i for i in instances if i["id"] == 1452154)
-        assert first.get("compatibility") is None
-        assert first.get("otherCompatibility") is None
-        # Second instance: no design entry at all → no enrichment, no crash.
-        second = next(i for i in instances if i["id"] == 9999999)
-        assert "compatibility" not in second or second["compatibility"] is None
+        assert seen == [Permission.LIBRARY_READ]
 
 
 
 
 class TestImport:
 class TestImport:
@@ -270,11 +362,8 @@ class TestImport:
         await db_session.commit()
         await db_session.commit()
         await db_session.refresh(existing)
         await db_session.refresh(existing)
 
 
-        svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest(),
-        )
-        svc.download_3mf = AsyncMock()  # must remain uncalled
+        svc = _fake_service(get_download=_download_info())
+        svc.download = AsyncMock()  # must remain uncalled
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
             resp = await async_client.post(
             resp = await async_client.post(
@@ -287,16 +376,52 @@ class TestImport:
         assert body["library_file_id"] == existing.id
         assert body["library_file_id"] == existing.id
         assert body["was_existing"] is True
         assert body["was_existing"] is True
         assert body["profile_id"] == 298919107
         assert body["profile_id"] == 298919107
-        svc.download_3mf.assert_not_called()
+        svc.download.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_unknown_source_type_is_a_clean_400(self, async_client, db_session):
+        """``source_type`` names the provider (there is no URL to route on);
+        an unregistered value is a client-input problem — 400 before any
+        service is built or bytes downloaded."""
+        svc = _fake_service(
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
+        )
+        svc.download = AsyncMock()
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "source_type": "thingiverse"},
+            )
+        assert resp.status_code == 400, resp.text
+        assert "thingiverse" in resp.json()["detail"].lower()
+        svc.download.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_unknown_source_type_creates_no_folder_side_effect(self, async_client, db_session):
+        """Provider resolution must precede destination handling — a rejected
+        request must not leave an auto-created default folder behind."""
+        from sqlalchemy import select
+
+        svc = _fake_service(get_download=_download_info())
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "source_type": "bogus"},
+            )
+
+        result = await db_session.execute(select(LibraryFolder))
+        assert result.scalars().all() == []
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_autocreates_makerworld_folder_when_folder_id_none(self, async_client, db_session):
     async def test_autocreates_makerworld_folder_when_folder_id_none(self, async_client, db_session):
         """Default destination — a top-level "MakerWorld" folder — is created
         """Default destination — a top-level "MakerWorld" folder — is created
         on first import so users don't have to set it up."""
         on first import so users don't have to set it up."""
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest(),
-            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -315,6 +440,141 @@ class TestImport:
         folder = result.scalar_one()
         folder = result.scalar_one()
         assert resp.json()["folder_id"] == folder.id
         assert resp.json()["folder_id"] == folder.id
 
 
+    @pytest.mark.asyncio
+    async def test_default_folder_comes_from_resolved_provider(self, async_client, db_session):
+        """``import_instance`` must read ``default_folder_name`` off the provider
+        it resolved — not the MakerWorld singleton (review round 3 fix). Latent
+        with one provider, but the difference is visible behind a stand-in: a
+        second provider's import lands in *its* folder, not "MakerWorld"."""
+        dummy = _DummyProvider(default_folder_name="Dummy Imports")
+        svc = _fake_service(
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
+        )
+
+        with (
+            patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
+            patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
+            )
+        assert resp.status_code == 200, resp.text
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(
+            select(LibraryFolder).where(LibraryFolder.name == "Dummy Imports", LibraryFolder.parent_id.is_(None))
+        )
+        assert result.scalar_one_or_none() is not None
+        # The MakerWorld singleton's folder must NOT be auto-created instead.
+        assert (
+            await db_session.execute(
+                select(LibraryFolder).where(LibraryFolder.name == "MakerWorld", LibraryFolder.parent_id.is_(None))
+            )
+        ).scalar_one_or_none() is None
+
+    @pytest.mark.asyncio
+    async def test_none_default_folder_name_imports_to_library_root(self, async_client, db_session):
+        """A provider that leaves ``default_folder_name`` unset imports into the
+        library root rather than minting a NULL-named folder (review round 3,
+        note 3)."""
+        dummy = _DummyProvider(default_folder_name=None)
+        svc = _fake_service(
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
+        )
+
+        with (
+            patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
+            patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy", "folder_id": None},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["folder_id"] is None
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(LibraryFolder))
+        assert result.scalars().all() == []
+
+    @pytest.mark.asyncio
+    async def test_gate_uses_the_makerworld_permission_for_makerworld(self, async_client):
+        """Control for the test below: the default ``source_type`` still gates
+        on ``makerworld:import``, exactly as the route decorator used to."""
+        seen, factory = _permission_spy()
+        svc = _fake_service(
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
+        )
+
+        with (
+            patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
+            patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107},
+            )
+        assert resp.status_code == 200, resp.text
+        assert seen == [Permission.MAKERWORLD_IMPORT]
+
+    @pytest.mark.asyncio
+    async def test_gate_uses_the_resolved_providers_permission(self, async_client):
+        """The permission is the resolved provider's, not the MakerWorld
+        singleton's. It cannot be a route dependency — FastAPI resolves those
+        before the body exists, so the decorator could only ever name one
+        provider, and importing from a second one would be gated on
+        ``makerworld:import``."""
+        seen, factory = _permission_spy()
+        dummy = _DummyProvider()
+        svc = _fake_service(
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
+        )
+
+        with (
+            patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
+            patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
+            patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
+            )
+        assert resp.status_code == 200, resp.text
+        assert seen == [Permission.LIBRARY_UPLOAD]
+        assert Permission.MAKERWORLD_IMPORT not in seen
+
+    @pytest.mark.asyncio
+    async def test_provider_without_a_permission_is_refused_not_waved_through(self, async_client, db_session):
+        """``import_permission`` is optional on the descriptor, so "unset" must
+        fail closed rather than read as "unrestricted"."""
+        dummy = _DummyProvider(import_permission=None)
+        svc = _fake_service(
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
+        )
+
+        with (
+            patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
+            patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
+        ):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
+            )
+        assert resp.status_code == 500
+        assert "declares no permission" in resp.json()["detail"]
+
+        from sqlalchemy import select
+
+        assert (await db_session.execute(select(LibraryFile))).scalars().all() == []
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_uses_existing_folder_when_folder_id_provided(self, async_client, db_session):
     async def test_uses_existing_folder_when_folder_id_provided(self, async_client, db_session):
         """Caller-supplied ``folder_id`` must be honoured even if the default
         """Caller-supplied ``folder_id`` must be honoured even if the default
@@ -325,9 +585,8 @@ class TestImport:
         await db_session.refresh(folder)
         await db_session.refresh(folder)
 
 
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest(),
-            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -343,9 +602,8 @@ class TestImport:
         """The saved row's ``source_url`` must include ``#profileId-`` so two
         """The saved row's ``source_url`` must include ``#profileId-`` so two
         plates of the same model become two library rows (dedupe is per-plate)."""
         plates of the same model become two library rows (dedupe is per-plate)."""
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest(),
-            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -369,12 +627,8 @@ class TestImport:
         library row. On-disk storage uses a UUID already, this is belt-and-
         library row. On-disk storage uses a UUID already, this is belt-and-
         braces protection for the human-readable field."""
         braces protection for the human-readable field."""
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download={
-                "name": "../../evil.3mf",
-                "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
-            },
-            download_3mf=(self._FAKE_3MF_BYTES, "fallback.3mf"),
+            get_download=_download_info(name="../../evil.3mf"),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="fallback.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -391,9 +645,8 @@ class TestImport:
         response field must always be populated, even when the caller provided
         response field must always be populated, even when the caller provided
         it explicitly (rather than the backend falling back to design defaults)."""
         it explicitly (rather than the backend falling back to design defaults)."""
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest(),
-            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -426,9 +679,8 @@ class TestImport:
         await db_session.refresh(folder)
         await db_session.refresh(folder)
 
 
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest("seed-starter.3mf"),
-            download_3mf=(self._FAKE_3MF_BYTES, "seed-starter.3mf"),
+            get_download=_download_info(name="seed-starter.3mf"),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="seed-starter.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -453,8 +705,8 @@ class TestImport:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_import_to_readonly_external_rejected_at_route(self, async_client, db_session, tmp_path):
     async def test_import_to_readonly_external_rejected_at_route(self, async_client, db_session, tmp_path):
-        """The route-layer gate at ``makerworld.py:256-260`` rejects read-only
-        externals with 403 before any download happens — so MakerWorld
+        """The route-layer gate in ``import_instance`` rejects read-only
+        external folders with 403 before any download happens — so MakerWorld
         credentials and the upstream download bandwidth aren't wasted."""
         credentials and the upstream download bandwidth aren't wasted."""
         ext_dir = tmp_path / "nas-readonly"
         ext_dir = tmp_path / "nas-readonly"
         ext_dir.mkdir()
         ext_dir.mkdir()
@@ -469,11 +721,8 @@ class TestImport:
         await db_session.commit()
         await db_session.commit()
         await db_session.refresh(folder)
         await db_session.refresh(folder)
 
 
-        svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest(),
-        )
-        svc.download_3mf = AsyncMock()
+        svc = _fake_service(get_download=_download_info())
+        svc.download = AsyncMock()
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
             resp = await async_client.post(
             resp = await async_client.post(
@@ -481,7 +730,7 @@ class TestImport:
                 json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
                 json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
             )
             )
         assert resp.status_code == 403, resp.text
         assert resp.status_code == 403, resp.text
-        svc.download_3mf.assert_not_called()
+        svc.download.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_import_to_external_with_missing_path_returns_400(self, async_client, db_session, tmp_path):
     async def test_import_to_external_with_missing_path_returns_400(self, async_client, db_session, tmp_path):
@@ -501,9 +750,8 @@ class TestImport:
         await db_session.refresh(folder)
         await db_session.refresh(folder)
 
 
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest(),
-            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+            get_download=_download_info(),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
@@ -535,9 +783,8 @@ class TestImport:
         await db_session.refresh(folder)
         await db_session.refresh(folder)
 
 
         svc = _fake_service(
         svc = _fake_service(
-            get_design=_default_design(),
-            get_profile_download=_default_manifest("benchy.3mf"),
-            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+            get_download=_download_info(name="benchy.3mf"),
+            download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
         )
         )
 
 
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
         with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):

+ 2 - 2
backend/tests/unit/test_makerworld_s3_tls.py

@@ -1,4 +1,4 @@
-"""Tests for the S3 presigned-download path in ``services/makerworld.py``.
+"""Tests for the S3 presigned-download path in ``model_providers/makerworld/http.py``.
 
 
 MakerWorld hands back an AWS presigned URL for the 3MF, and we fetch that one
 MakerWorld hands back an AWS presigned URL for the 3MF, and we fetch that one
 with ``urllib.request`` rather than httpx — httpx re-encodes the query string
 with ``urllib.request`` rather than httpx — httpx re-encodes the query string
@@ -25,7 +25,7 @@ from cryptography.hazmat.primitives import hashes, serialization
 from cryptography.hazmat.primitives.asymmetric import ec
 from cryptography.hazmat.primitives.asymmetric import ec
 from cryptography.x509.oid import NameOID
 from cryptography.x509.oid import NameOID
 
 
-from backend.app.services import makerworld as mw
+from backend.app.services.model_providers.makerworld import http as mw
 
 
 # A presigned URL in the shape Bambu Cloud actually mints: the signature is
 # A presigned URL in the shape Bambu Cloud actually mints: the signature is
 # computed over these exact query-string bytes, so any re-encoding breaks it.
 # computed over these exact query-string bytes, so any re-encoding breaks it.

+ 70 - 0
backend/tests/unit/test_model_provider_registry.py

@@ -0,0 +1,70 @@
+"""Tests for the model-provider registry.
+
+Pins the routing seam that a future *shared* import API will use: pasted URLs
+go through ``find_for_url`` and land on the provider that owns them.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.services.model_providers import makerworld_provider, registry
+from backend.app.services.model_providers.base import ModelProvider
+from backend.app.services.model_providers.registry import ModelProviderRegistry
+
+
+class _DummyProvider(ModelProvider):
+    source_type = "dummy"
+    display_name = "Dummy"
+
+    async def build_service(self, *, db, user, api_key_owner=None, client=None):
+        raise NotImplementedError
+
+    def parse_url(self, url):
+        raise NotImplementedError
+
+    def canonical_url(self, ref):
+        raise NotImplementedError
+
+
+class TestAppRegistry:
+    """The app-wide singleton auto-registers MakerWorld on import."""
+
+    def test_makerworld_is_registered(self):
+        assert registry.get("makerworld") is makerworld_provider
+        assert registry.get("makerworld").display_name == "MakerWorld"
+
+    def test_unknown_source_type_raises_keyerror(self):
+        with pytest.raises(KeyError):
+            registry.get("thingiverse")
+
+    def test_find_for_url_routes_makerworld_urls(self):
+        provider = registry.find_for_url("https://makerworld.com/en/models/1400373#profileId-1452154")
+        assert provider is makerworld_provider
+
+    def test_find_for_url_returns_none_for_foreign_hosts(self):
+        assert registry.find_for_url("https://thingiverse.com/thing/123") is None
+        assert registry.find_for_url("") is None
+        assert registry.find_for_url(None) is None  # type: ignore[arg-type]
+
+
+class TestModelProviderRegistry:
+    def test_register_is_idempotent_per_instance(self):
+        reg = ModelProviderRegistry()
+        provider = _DummyProvider()
+        reg.register(provider)
+        reg.register(provider)
+        assert reg.all() == (provider,)
+
+    def test_register_duplicate_source_type_rejected(self):
+        reg = ModelProviderRegistry()
+        reg.register(_DummyProvider())
+        with pytest.raises(ValueError):
+            reg.register(_DummyProvider())
+
+    def test_all_returns_registered_providers(self):
+        reg = ModelProviderRegistry()
+        provider = _DummyProvider()
+        reg.register(provider)
+        assert provider in reg.all()
+        assert len(reg.all()) == 1