Skip to content

Commit 7a785ca

Browse files
authored
Merge branch 'main' into feat/dockerize
2 parents 48970ec + 98e58fd commit 7a785ca

14 files changed

Lines changed: 477 additions & 75 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""add settings table
2+
3+
Revision ID: e18675e6eecb
4+
Revises: 9b73c6b77fa4
5+
Create Date: 2026-01-13 12:39:41.971893
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
import sqlmodel # noqa: F401 - Added for SQLModel support
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "e18675e6eecb"
17+
down_revision: str | None = "9b73c6b77fa4"
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
22+
def upgrade() -> None:
23+
op.create_table(
24+
"settings",
25+
sa.Column("id", sa.Integer(), nullable=False),
26+
sa.Column("public_url", sa.String(), nullable=True),
27+
sa.Column("updated_at", sa.DateTime(), nullable=False),
28+
sa.PrimaryKeyConstraint("id"),
29+
)
30+
31+
32+
def downgrade() -> None:
33+
op.drop_table("settings")

backend/syftai_space/components/auth/dependencies.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ def get_verified_user_email(
5353

5454
# Otherwise, verify token with SyftHub
5555
try:
56-
client = SyftHubClient(str(marketplace.url))
57-
client.login(marketplace.email, marketplace.password)
58-
result = client.verify_satellite_token(token)
56+
with SyftHubClient(str(marketplace.url)) as client:
57+
client.login(marketplace.email, marketplace.password)
58+
result = client.verify_satellite_token(token)
5959
except SyftHubError as e:
6060
raise e.to_http_exception() from e
6161

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Admin API key authentication middleware."""
22

3-
from typing import Callable
3+
from collections.abc import Callable
44

55
from starlette.middleware.base import BaseHTTPMiddleware
66
from starlette.requests import Request
77
from starlette.responses import JSONResponse, Response
88

9-
from syftai_space.components.auth.public import PUBLIC_ROUTE_MARKER
9+
from syftai_space.components.auth.public import is_public_route
1010

1111

1212
class AdminKeyMiddleware(BaseHTTPMiddleware):
@@ -18,31 +18,24 @@ class AdminKeyMiddleware(BaseHTTPMiddleware):
1818
All other routes require Authorization: Bearer <key> header.
1919
"""
2020

21-
# Static paths that don't have route handlers (can't use decorator)
22-
STATIC_PUBLIC_PATHS = [
23-
"/docs",
24-
"/redoc",
25-
"/openapi.json",
26-
"/syftai-server", # Frontend static files
27-
]
28-
21+
STATIC_PUBLIC_PATHS = ["/docs", "/redoc", "/openapi.json", "/syftai-server"]
2922
STATIC_PUBLIC_EXACT = ["/"]
3023

3124
async def dispatch(
3225
self, request: Request, call_next: Callable[[Request], Response]
3326
) -> Response:
3427
path = request.url.path
28+
method = request.method
3529

36-
# Check if route is marked as public via @public_route decorator
37-
endpoint = request.scope.get("endpoint")
38-
if endpoint and getattr(endpoint, PUBLIC_ROUTE_MARKER, False):
30+
# Allow static paths (docs, frontend)
31+
if self._is_static_public_path(path):
3932
return await call_next(request)
4033

41-
# Allow static public paths (docs, frontend, etc.)
42-
if self._is_static_public_path(path):
34+
# Allow public API routes (discovered from @public_route decorator)
35+
if is_public_route(path, method):
4336
return await call_next(request)
4437

45-
# All other routes require admin key
38+
# Require admin key for all other routes
4639
if not self._verify_admin_key(request):
4740
return JSONResponse(
4841
status_code=401,
@@ -55,20 +48,17 @@ def _is_static_public_path(self, path: str) -> bool:
5548
"""Check static paths that can't use decorators."""
5649
if path in self.STATIC_PUBLIC_EXACT:
5750
return True
58-
return any(path.startswith(prefix) for prefix in self.STATIC_PUBLIC_PATHS)
51+
return any(path.startswith(p) for p in self.STATIC_PUBLIC_PATHS)
5952

6053
def _verify_admin_key(self, request: Request) -> bool:
6154
"""Verify the admin API key from Authorization header."""
6255
from syftai_space.config import app_settings
6356

64-
# If no admin key configured, allow all (dev mode)
6557
if not app_settings.admin_api_key:
6658
return True
6759

68-
# Check Authorization: Bearer <token> header
6960
auth_header = request.headers.get("Authorization", "")
7061
if auth_header.startswith("Bearer "):
71-
provided_key = auth_header[7:] # Strip "Bearer " prefix
72-
return provided_key == app_settings.admin_api_key
62+
return auth_header[7:] == app_settings.admin_api_key
7363

7464
return False
Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,58 @@
1-
"""Public route decorator for marking routes that don't require authentication."""
1+
"""Public route decorator and discovery for routes that skip admin auth."""
22

3-
from typing import Callable, TypeVar
3+
import re
4+
from collections.abc import Callable
5+
from typing import TypeVar
46

5-
F = TypeVar("F", bound=Callable)
7+
from loguru import logger
8+
from starlette.routing import Mount, Route
69

10+
F = TypeVar("F", bound=Callable)
711
PUBLIC_ROUTE_MARKER = "public_route"
812

13+
# Module-level storage (populated by discover_public_routes)
14+
_public_patterns: list[tuple[str, re.Pattern]] = []
15+
916

1017
def public_route(func: F) -> F:
11-
"""Decorator to mark a route as public (no auth required).
18+
"""Decorator to mark a route as public (no admin auth required).
1219
1320
Usage:
1421
@public_route
1522
@router.post("/{slug}/query")
1623
async def query_endpoint(...):
1724
...
18-
19-
The middleware checks for this marker via:
20-
getattr(endpoint, PUBLIC_ROUTE_MARKER, False)
2125
"""
2226
setattr(func, PUBLIC_ROUTE_MARKER, True)
2327
return func
28+
29+
30+
def discover_public_routes(app) -> None:
31+
"""Scan app routes and register those marked with @public_route.
32+
33+
Call this after all routes are registered.
34+
"""
35+
_public_patterns.clear()
36+
_scan_routes(app.routes, "")
37+
logger.info(f"Discovered {len(_public_patterns)} public routes")
38+
39+
40+
def is_public_route(path: str, method: str) -> bool:
41+
"""Check if path+method is a public route."""
42+
return any(m == method and p.match(path) for m, p in _public_patterns)
43+
44+
45+
def _scan_routes(routes, prefix: str) -> None:
46+
"""Recursively scan routes for @public_route marker."""
47+
for route in routes:
48+
if isinstance(route, Mount):
49+
_scan_routes(route.routes, prefix + route.path)
50+
elif isinstance(route, Route):
51+
endpoint = getattr(route, "endpoint", None)
52+
if endpoint and getattr(endpoint, PUBLIC_ROUTE_MARKER, False):
53+
full_path = prefix + route.path
54+
pattern = re.compile(
55+
"^" + re.sub(r"\{[^}]+\}", r"[^/]+", full_path) + "$"
56+
)
57+
for method in route.methods or {"GET"}:
58+
_public_patterns.append((method, pattern))

backend/syftai_space/components/endpoints/handlers.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
EndpointCreateResponse,
1717
EndpointDetailResponse,
1818
EndpointListItem,
19+
MarketplaceAvailabilityResult,
1920
MessageResponse,
2021
ProviderInfo,
2122
PublishEndpointResponse,
2223
PublishResult,
2324
QueryEndpointResponse,
2425
ReferencesResponse,
26+
SlugAvailabilityResponse,
2527
SummaryResponse,
2628
TokenUsage,
2729
)
@@ -607,7 +609,7 @@ def _publish_to_marketplace(
607609
}
608610
],
609611
}
610-
client.publish_endpoint(payload)
612+
client.publish_endpoint(payload, overwrite=True)
611613

612614
except SyftHubError as e:
613615
return PublishResult(
@@ -636,3 +638,132 @@ def _publish_to_marketplace(
636638
success=False,
637639
error=str(e),
638640
)
641+
642+
def check_slug_availability(
643+
self,
644+
slug: str,
645+
marketplace_ids: list[UUID] | None,
646+
check_all_marketplaces: bool,
647+
tenant: Tenant,
648+
) -> SlugAvailabilityResponse:
649+
"""Check if a slug is available locally and optionally on marketplaces.
650+
651+
Args:
652+
slug: Slug to check
653+
marketplace_ids: Optional list of marketplace IDs to check
654+
check_all_marketplaces: If True, check all active marketplaces (takes precedence)
655+
tenant: Tenant context
656+
657+
Returns:
658+
Availability status for local and each requested marketplace
659+
"""
660+
# Check local availability
661+
existing_endpoint = self.endpoint_repository.get_by_slug(slug, tenant.id)
662+
local_available = existing_endpoint is None
663+
664+
# Determine if we need to check marketplaces
665+
should_check_marketplaces = check_all_marketplaces or marketplace_ids
666+
667+
# If no marketplace check needed, return local-only result
668+
if not should_check_marketplaces:
669+
return SlugAvailabilityResponse(
670+
slug=slug,
671+
local_available=local_available,
672+
marketplaces=None,
673+
)
674+
675+
# Check marketplace availability
676+
if not self.marketplace_repository:
677+
raise HTTPException(
678+
status_code=500,
679+
detail="Marketplace checking is not configured",
680+
)
681+
682+
# Get marketplaces to check
683+
if check_all_marketplaces:
684+
# Get all active marketplaces (flag takes precedence)
685+
marketplaces = self.marketplace_repository.get_active(tenant.id)
686+
missing_ids: set[UUID] = set()
687+
else:
688+
# Get specific marketplaces by IDs
689+
marketplaces = self.marketplace_repository.get_by_ids(
690+
marketplace_ids, tenant.id
691+
)
692+
found_ids = {m.id for m in marketplaces}
693+
missing_ids = set(marketplace_ids) - found_ids
694+
695+
# Build results for each requested marketplace
696+
marketplace_results: list[MarketplaceAvailabilityResult] = []
697+
698+
# Add results for missing marketplaces (only when using marketplace_ids)
699+
for missing_id in missing_ids:
700+
marketplace_results.append(
701+
MarketplaceAvailabilityResult(
702+
marketplace_id=missing_id,
703+
available=None,
704+
error="Marketplace not found",
705+
)
706+
)
707+
708+
# Check each found marketplace
709+
for marketplace in marketplaces:
710+
result = self._check_marketplace_availability(slug, marketplace)
711+
marketplace_results.append(result)
712+
713+
return SlugAvailabilityResponse(
714+
slug=slug,
715+
local_available=local_available,
716+
marketplaces=marketplace_results,
717+
)
718+
719+
def _check_marketplace_availability(
720+
self,
721+
slug: str,
722+
marketplace: Marketplace,
723+
) -> MarketplaceAvailabilityResult:
724+
"""Check if a slug is available on a specific marketplace.
725+
726+
Args:
727+
slug: Slug to check
728+
marketplace: Marketplace to check on
729+
730+
Returns:
731+
Availability result for the marketplace
732+
"""
733+
# Validate marketplace is active
734+
if not marketplace.is_active:
735+
return MarketplaceAvailabilityResult(
736+
marketplace_id=marketplace.id,
737+
available=None,
738+
error="Marketplace is not active",
739+
)
740+
741+
# Validate marketplace has credentials
742+
if not marketplace.email or not marketplace.password:
743+
return MarketplaceAvailabilityResult(
744+
marketplace_id=marketplace.id,
745+
available=None,
746+
error="Marketplace credentials not configured",
747+
)
748+
749+
try:
750+
with SyftHubClient(base_url=marketplace.url) as client:
751+
client.login(username=marketplace.email, password=marketplace.password)
752+
exists = client.endpoint_exists(slug)
753+
return MarketplaceAvailabilityResult(
754+
marketplace_id=marketplace.id,
755+
available=not exists,
756+
error=None,
757+
)
758+
except SyftHubError as e:
759+
return MarketplaceAvailabilityResult(
760+
marketplace_id=marketplace.id,
761+
available=None,
762+
error=e.message,
763+
)
764+
except Exception as e:
765+
return MarketplaceAvailabilityResult(
766+
marketplace_id=marketplace.id,
767+
available=None,
768+
error=str(e),
769+
)

backend/syftai_space/components/endpoints/routes.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
PublishEndpointResponse,
1616
QueryEndpointRequest,
1717
QueryEndpointResponse,
18+
SlugAvailabilityRequest,
19+
SlugAvailabilityResponse,
1820
)
1921
from syftai_space.components.tenants.dependency import get_tenant_dependency
2022
from syftai_space.components.tenants.entities import Tenant
@@ -91,6 +93,33 @@ async def list_endpoints(
9193
"""
9294
return handler.list_endpoints(tenant)
9395

96+
@router.post("/validate-slug", response_model=SlugAvailabilityResponse)
97+
async def validate_slug(
98+
request: SlugAvailabilityRequest,
99+
tenant: Tenant = Depends(get_tenant_dependency),
100+
handler: EndpointHandler = Depends(get_handler),
101+
) -> SlugAvailabilityResponse:
102+
"""Validate if a slug is available locally and optionally on marketplaces.
103+
104+
This endpoint allows checking slug uniqueness before creating an endpoint.
105+
- If neither marketplace_ids nor check_all_marketplaces is provided, only checks locally (fast)
106+
- If check_all_marketplaces is True, checks all active marketplaces
107+
- If marketplace_ids is provided, checks only those specific marketplaces
108+
109+
Args:
110+
request: Slug and optional marketplace options
111+
tenant: Current tenant (injected)
112+
113+
Returns:
114+
Availability status for local and each requested marketplace
115+
"""
116+
return handler.check_slug_availability(
117+
request.slug,
118+
request.marketplace_ids,
119+
request.check_all_marketplaces,
120+
tenant,
121+
)
122+
94123
@router.get("/{slug}", response_model=EndpointDetailResponse)
95124
async def get_endpoint(
96125
slug: str,

0 commit comments

Comments
 (0)