diff --git a/backend/README.md b/backend/README.md
index aa42fc0b..c1fc9ce1 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -142,6 +142,7 @@ bash scripts/live_migration_smoke.sh
- `GET /api/v1/detection/models`
- `GET /api/v1/detection/model-assets`
- `POST /api/v1/detection/run`
+ - `POST /api/v1/detection/run-async` (production browser path)
- `GET /api/v1/detection/runs/{analysis_run_id}`
- `GET /api/v1/detection/runs/{analysis_run_id}/detections`
- YOLO/PyTorch real inference is not enabled in Sprint 8.
@@ -184,6 +185,7 @@ bash scripts/live_migration_smoke.sh
- Added segmentation endpoints:
- `GET /api/v1/segmentation/models`
- `POST /api/v1/segmentation/run`
+ - `POST /api/v1/segmentation/run-async` (production browser path)
- `GET /api/v1/segmentation/runs`
- `GET /api/v1/segmentation/runs/{analysis_run_id}`
- `GET /api/v1/segmentation/runs/{analysis_run_id}/segmentations`
@@ -191,6 +193,11 @@ bash scripts/live_migration_smoke.sh
- `POST /api/v1/segmentation/runs/{analysis_run_id}/qa/reference`
- Real SAM and YOLO-seg inference are not enabled in Sprint 9.
- Mask paths are provenance/debug artifacts; persisted PostGIS geometry is authoritative for QA, map display and GeoJSON.
+- Current configured detection and segmentation run through the async analysis
+ worker (`GEOINTEL_ANALYSIS_WORKER_ENABLED`) and are followed through
+ `GET /api/v1/projects/{project_id}/jobs/{job_id}`. The Unraid profile sets
+ `YOLO_REQUIRE_CUDA=true`, so both pipelines fail closed instead of silently
+ falling back from NVIDIA CUDA to CPU.
## Sprint 17 additions
- Added export foundation backed by the existing `exports` table.
diff --git a/backend/app/api/guest_scope.py b/backend/app/api/guest_scope.py
new file mode 100644
index 00000000..1baada3a
--- /dev/null
+++ b/backend/app/api/guest_scope.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import Request
+
+from app.core.errors import AppError
+
+
+def guest_project_scope(request: Request) -> UUID | None:
+ principal = getattr(request.state, "auth_principal", None)
+ if getattr(principal, "role", None) != "guest":
+ return None
+ project_id = getattr(principal, "project_id", None)
+ if isinstance(project_id, UUID):
+ return project_id
+ raise AppError(
+ code="GUEST_PROJECT_SCOPE_REQUIRED",
+ message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
+ status_code=403,
+ )
+
+
+def assert_guest_project_scope(request: Request, project_id: UUID) -> None:
+ guest_project_id = guest_project_scope(request)
+ if guest_project_id is not None and project_id != guest_project_id:
+ raise AppError(
+ code="GUEST_PROJECT_SCOPE_REQUIRED",
+ message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
+ status_code=403,
+ )
+
+
+def guest_scoped_project_filter(
+ request: Request,
+ requested_project_id: UUID | None,
+) -> UUID | None:
+ guest_project_id = guest_project_scope(request)
+ if guest_project_id is None:
+ return requested_project_id
+ if requested_project_id is not None:
+ assert_guest_project_scope(request, requested_project_id)
+ return guest_project_id
diff --git a/backend/app/api/routes/detection.py b/backend/app/api/routes/detection.py
index c61e0915..5ea713d8 100644
--- a/backend/app/api/routes/detection.py
+++ b/backend/app/api/routes/detection.py
@@ -2,9 +2,14 @@ from __future__ import annotations
from uuid import UUID
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
+from app.api.guest_scope import (
+ assert_guest_project_scope,
+ guest_project_scope,
+ guest_scoped_project_filter,
+)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
@@ -25,6 +30,7 @@ from app.schemas import (
YoloPreflightResponse,
)
from app.services.detection_comparison_service import DetectionComparisonService
+from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService
@@ -60,7 +66,12 @@ def get_yolo_preflight(
@router.post("/run", response_model=Envelope[DetectionRunResponse])
-def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
+def run_detection(
+ payload: DetectionRunRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
+ assert_guest_project_scope(request, payload.project_id)
result = DetectionService.run_detection(
db=db,
project_id=payload.project_id,
@@ -76,7 +87,11 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -
@router.post("/run-async", response_model=Envelope[JobRead])
-def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
+def queue_detection(
+ payload: DetectionRunRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
"""Queue a detection run for the background worker.
Tiled GPU inference takes minutes; ``POST /detection/run`` performs it
@@ -84,6 +99,7 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db))
``GET /jobs/{id}`` for the queued run instead.
"""
+ assert_guest_project_scope(request, payload.project_id)
job = DetectionService.enqueue_detection(
db=db,
project_id=payload.project_id,
@@ -100,12 +116,14 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db))
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
def list_detection_runs(
+ request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
+ project_id = guest_scoped_project_filter(request, project_id)
return envelope(
DetectionService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
@@ -114,8 +132,14 @@ def list_detection_runs(
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
-def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
- return envelope(DetectionService.get_run(db, analysis_run_id).model_dump())
+def get_detection_run(
+ analysis_run_id: UUID,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
+ run = DetectionService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
+ return envelope(run.model_dump())
@router.get(
@@ -124,6 +148,7 @@ def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> d
)
def list_detection_run_detections(
analysis_run_id: UUID,
+ request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -136,6 +161,9 @@ def list_detection_run_detections(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ run = DetectionService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.list_detections(
db,
@@ -155,6 +183,7 @@ def list_detection_run_detections(
)
def list_dataset_detections(
dataset_id: UUID,
+ request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -167,6 +196,9 @@ def list_dataset_detections(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ dataset = DatasetService.get_dataset(db, dataset_id)
+ assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.list_detections(
db,
@@ -181,8 +213,14 @@ def list_dataset_detections(
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
-def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
- return envelope(DetectionService.get_detection(db, detection_id).model_dump())
+def get_detection(
+ detection_id: UUID,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
+ detection = DetectionService.get_detection(db, detection_id)
+ assert_guest_project_scope(request, detection.project_id)
+ return envelope(detection.model_dump())
@router.get(
@@ -191,6 +229,7 @@ def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
)
def get_detection_run_geojson(
analysis_run_id: UUID,
+ request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
@@ -201,6 +240,9 @@ def get_detection_run_geojson(
),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ run = DetectionService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
@@ -218,6 +260,7 @@ def get_detection_run_geojson(
)
def get_dataset_detection_geojson(
dataset_id: UUID,
+ request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -229,6 +272,9 @@ def get_dataset_detection_geojson(
),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ dataset = DatasetService.get_dataset(db, dataset_id)
+ assert_guest_project_scope(request, dataset.project_id)
return envelope(
DetectionService.detections_to_geojson(
db,
@@ -269,8 +315,12 @@ def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = De
def compare_detection_run_with_reference(
analysis_run_id: UUID,
payload: DetectionQaRequest,
+ request: Request,
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ run = DetectionService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
return envelope(
DetectionService.compare_detections_with_reference(
db=db,
diff --git a/backend/app/api/routes/exports.py b/backend/app/api/routes/exports.py
index 399122c8..bab2be34 100644
--- a/backend/app/api/routes/exports.py
+++ b/backend/app/api/routes/exports.py
@@ -2,10 +2,11 @@ from __future__ import annotations
from uuid import UUID
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
+from app.api.guest_scope import assert_guest_project_scope, guest_project_scope
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas import Envelope
@@ -20,13 +21,30 @@ from app.schemas.export import (
ReportExportRequest,
)
from app.services.export_service import ExportService
+from app.services.dataset_service import DatasetService
+from app.services.detection_service import DetectionService
+from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
router = APIRouter(prefix="/exports", tags=["exports"])
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
-def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
+def export_geojson(
+ payload: GeoJsonExportRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ if guest_project_scope(request) is not None:
+ if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None:
+ dataset = DatasetService.get_dataset(db, payload.dataset_id)
+ assert_guest_project_scope(request, dataset.project_id)
+ elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
+ run = DetectionService.get_run(db, payload.analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
+ elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
+ run = SegmentationService.get_run(db, payload.analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
return envelope(
ExportService.export_vector_selection_geojson(
@@ -61,17 +79,32 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
-def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)):
+def export_project_metadata(
+ payload: MetadataExportRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/report", response_model=Envelope[ExportCreateResponse])
-def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)):
+def export_project_report(
+ payload: ReportExportRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
-def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
+def export_map_result(
+ payload: MapResultExportRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ assert_guest_project_scope(request, payload.project_id)
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
@@ -81,25 +114,35 @@ def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get
)
def list_project_exports(
project_id: UUID,
+ request: Request,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
+ assert_guest_project_scope(request, project_id)
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
@router.get("/{export_id}", response_model=Envelope[ExportRead])
-def get_export(export_id: UUID, db: Session = Depends(get_db)):
- return envelope(ExportService.get_export(db, export_id).model_dump(mode="json"))
+def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
+ export = ExportService.get_export(db, export_id)
+ assert_guest_project_scope(request, export.project_id)
+ return envelope(export.model_dump(mode="json"))
@router.get("/{export_id}/download")
-def download_export(export_id: UUID, db: Session = Depends(get_db)):
+def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
+ if guest_project_scope(request) is not None:
+ export = ExportService.get_export(db, export_id)
+ assert_guest_project_scope(request, export.project_id)
path = ExportService.get_export_download_path(db, export_id)
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
return FileResponse(path, filename=path.name, media_type=media_type)
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
-def get_export_content(export_id: UUID, db: Session = Depends(get_db)):
+def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)):
+ if guest_project_scope(request) is not None:
+ export = ExportService.get_export(db, export_id)
+ assert_guest_project_scope(request, export.project_id)
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
diff --git a/backend/app/api/routes/segmentation.py b/backend/app/api/routes/segmentation.py
index aba33bb7..7113bb9d 100644
--- a/backend/app/api/routes/segmentation.py
+++ b/backend/app/api/routes/segmentation.py
@@ -2,9 +2,14 @@ from __future__ import annotations
from uuid import UUID
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
+from app.api.guest_scope import (
+ assert_guest_project_scope,
+ guest_project_scope,
+ guest_scoped_project_filter,
+)
from app.db.session import get_db
from app.schemas import (
AnalysisQaResponse,
@@ -21,6 +26,7 @@ from app.schemas import (
SegmentationRunResponse,
)
from app.services.model_registry_service import ModelRegistryService
+from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope
@@ -34,7 +40,12 @@ def list_segmentation_models() -> dict:
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
-def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
+def run_segmentation(
+ payload: SegmentationRunRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
+ assert_guest_project_scope(request, payload.project_id)
result = SegmentationService.run_segmentation(
db=db,
project_id=payload.project_id,
@@ -49,13 +60,18 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_
@router.post("/run-async", response_model=Envelope[JobRead])
-def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
+def queue_segmentation(
+ payload: SegmentationRunRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
"""Queue a segmentation run for the background worker.
Configured segmentation walks the same tile manifest as detection and is
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
"""
+ assert_guest_project_scope(request, payload.project_id)
job = SegmentationService.enqueue_segmentation(
db=db,
project_id=payload.project_id,
@@ -71,12 +87,14 @@ def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(ge
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
def list_segmentation_runs(
+ request: Request,
project_id: UUID | None = None,
dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
+ project_id = guest_scoped_project_filter(request, project_id)
return envelope(
SegmentationService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
@@ -85,8 +103,14 @@ def list_segmentation_runs(
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
-def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
- return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump())
+def get_segmentation_run(
+ analysis_run_id: UUID,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
+ run = SegmentationService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
+ return envelope(run.model_dump())
@router.get(
@@ -95,6 +119,7 @@ def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -
)
def list_segmentation_run_outputs(
analysis_run_id: UUID,
+ request: Request,
dataset_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -107,6 +132,9 @@ def list_segmentation_run_outputs(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ run = SegmentationService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
@@ -126,6 +154,7 @@ def list_segmentation_run_outputs(
)
def list_dataset_segmentations(
dataset_id: UUID,
+ request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -138,6 +167,9 @@ def list_dataset_segmentations(
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ dataset = DatasetService.get_dataset(db, dataset_id)
+ assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.list_segmentations(
db,
@@ -152,8 +184,14 @@ def list_dataset_segmentations(
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
-def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict:
- return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump())
+def get_segmentation(
+ segmentation_id: UUID,
+ request: Request,
+ db: Session = Depends(get_db),
+) -> dict:
+ segmentation = SegmentationService.get_segmentation(db, segmentation_id)
+ assert_guest_project_scope(request, segmentation.project_id)
+ return envelope(segmentation.model_dump())
@router.get(
@@ -162,6 +200,7 @@ def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> di
)
def get_segmentation_run_geojson(
analysis_run_id: UUID,
+ request: Request,
class_name: str | None = None,
min_confidence: float | None = None,
limit: int = Query(
@@ -172,6 +211,9 @@ def get_segmentation_run_geojson(
),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ run = SegmentationService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
@@ -189,6 +231,7 @@ def get_segmentation_run_geojson(
)
def get_dataset_segmentation_geojson(
dataset_id: UUID,
+ request: Request,
analysis_run_id: UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
@@ -200,6 +243,9 @@ def get_dataset_segmentation_geojson(
),
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ dataset = DatasetService.get_dataset(db, dataset_id)
+ assert_guest_project_scope(request, dataset.project_id)
return envelope(
SegmentationService.segmentations_to_geojson(
db,
@@ -219,8 +265,12 @@ def get_dataset_segmentation_geojson(
def compare_segmentation_run_with_reference(
analysis_run_id: UUID,
payload: SegmentationQaRequest,
+ request: Request,
db: Session = Depends(get_db),
) -> dict:
+ if guest_project_scope(request) is not None:
+ run = SegmentationService.get_run(db, analysis_run_id)
+ assert_guest_project_scope(request, run.project_id)
return envelope(
SegmentationService.compare_segmentations_with_reference(
db=db,
diff --git a/backend/app/main.py b/backend/app/main.py
index 0ce07d41..f9fcd5f8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -264,7 +264,9 @@ def create_app() -> FastAPI:
}
guest_scoped_analysis_post_paths = {
f"{settings.api_prefix}/detection/run",
+ f"{settings.api_prefix}/detection/run-async",
f"{settings.api_prefix}/segmentation/run",
+ f"{settings.api_prefix}/segmentation/run-async",
f"{settings.api_prefix}/qa/detections-vs-reference",
f"{settings.api_prefix}/exports/geojson",
f"{settings.api_prefix}/exports/metadata",
diff --git a/backend/app/schemas/segmentation.py b/backend/app/schemas/segmentation.py
index 42e5f3ce..a90155c7 100644
--- a/backend/app/schemas/segmentation.py
+++ b/backend/app/schemas/segmentation.py
@@ -102,4 +102,9 @@ class SegmentationRead(BaseModel):
class SegmentationListResponse(BaseModel):
items: list[SegmentationRead]
+ # ``total`` describes the complete filtered population; ``items`` is one
+ # stable confidence-ranked page of it.
total: int
+ limit: int | None = None
+ offset: int = 0
+ truncated: bool = False
diff --git a/backend/app/services/outbound_request_guard.py b/backend/app/services/outbound_request_guard.py
index b8f0cdf4..ec205965 100644
--- a/backend/app/services/outbound_request_guard.py
+++ b/backend/app/services/outbound_request_guard.py
@@ -18,7 +18,7 @@ import socket
from collections.abc import Callable
from typing import Any
from urllib.parse import urlparse
-from urllib.request import HTTPRedirectHandler, build_opener, urlopen
+from urllib.request import HTTPRedirectHandler, build_opener
from app.core.errors import AppError
@@ -38,12 +38,30 @@ class _RejectRedirects(HTTPRedirectHandler):
return None
+class _ValidatedRedirects(HTTPRedirectHandler):
+ """Validate a redirect target before urllib opens the next connection."""
+
+ def __init__(self, expected_url: str) -> None:
+ super().__init__()
+ self.expected_url = expected_url
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102
+ assert_same_origin_redirect(self.expected_url, newurl)
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
def no_redirect_opener():
"""An opener that will not follow a redirect anywhere."""
return build_opener(_RejectRedirects())
+def validated_redirect_opener(expected_url: str):
+ """An opener that validates each redirect before following it."""
+
+ return build_opener(_ValidatedRedirects(expected_url))
+
+
def _reject(code: str, message: str, **details: Any) -> AppError:
return AppError(code=code, message=message, details=details or None, status_code=502)
@@ -88,6 +106,20 @@ def assert_public_http_url(url: str) -> None:
host = parsed.hostname
if not host:
raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url)
+ if parsed.username is not None or parsed.password is not None:
+ raise _reject(
+ "OUTBOUND_URL_NOT_ALLOWED",
+ "Bounded acquisition refuses credentials embedded in an outbound URL.",
+ host=host,
+ )
+ try:
+ parsed.port
+ except ValueError as error:
+ raise _reject(
+ "OUTBOUND_URL_NOT_ALLOWED",
+ "Outbound request contains an invalid port.",
+ host=host,
+ ) from error
literal = host.strip("[]")
candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host)
@@ -133,15 +165,28 @@ def assert_same_origin_redirect(original_url: str, final_url: str) -> None:
"The official endpoint redirected from HTTPS to an unprotected scheme.",
redirect_scheme=final.scheme,
)
+ # This catches embedded credentials, invalid ports and non-public
+ # resolutions before the redirect handler can construct the next request.
assert_public_http_url(final_url)
+ original_port = original.port or (443 if original.scheme == "https" else 80)
+ final_port = final.port or (443 if final.scheme == "https" else 80)
+ same_scheme_port = final.scheme == original.scheme and final_port == original_port
+ safe_https_upgrade = original.scheme == "http" and final.scheme == "https" and final_port == 443
+ if not (same_scheme_port or safe_https_upgrade):
+ raise _reject(
+ "OUTBOUND_REDIRECT_NOT_ALLOWED",
+ "The official endpoint redirected to a different network origin.",
+ expected_port=original_port,
+ redirect_port=final_port,
+ )
def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callable[..., Any]:
- """An ``urlopen`` replacement that verifies where the response came from.
+ """An ``urlopen`` replacement that keeps redirects on the expected origin.
- ``urlopen`` has already followed the redirect chain by the time it returns,
- so the check is on ``response.url``: the body is still unread, and raising
- here means nothing off-origin is ever parsed or persisted.
+ Redirect targets are validated by the handler *before* urllib opens the
+ next connection. The final response URL is checked again as a defensive
+ invariant for injected/custom transports.
``allow_redirect=False`` refuses any redirect at all, which is what the
paged OGC feature readers want: a page URL they built themselves should be
@@ -151,7 +196,11 @@ def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callabl
assert_public_http_url(expected_url)
- default_transport = urlopen if allow_redirect else no_redirect_opener().open
+ default_transport = (
+ validated_redirect_opener(expected_url).open
+ if allow_redirect
+ else no_redirect_opener().open
+ )
def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
response = (_transport or default_transport)(request, *args, **kwargs)
diff --git a/backend/app/services/segmentation_adapter.py b/backend/app/services/segmentation_adapter.py
index 73b4b1bc..5d33510d 100644
--- a/backend/app/services/segmentation_adapter.py
+++ b/backend/app/services/segmentation_adapter.py
@@ -61,6 +61,41 @@ class _UltralyticsSegmentationAdapterBase:
message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].",
status_code=503,
)
+ self.validate_runtime()
+
+ def validate_runtime(self) -> None:
+ """Fail closed when the deployment contract requires NVIDIA CUDA.
+
+ Detection and segmentation share ``YOLO_DEVICE`` and
+ ``YOLO_REQUIRE_CUDA``. Without this check segmentation could advertise
+ a GPU job while Ultralytics silently used CPU or failed only after the
+ model had already been loaded.
+ """
+
+ if not self.settings.yolo_require_cuda:
+ return
+ try:
+ import torch
+ except Exception as exc:
+ raise AppError(
+ code="SEGMENTATION_ACCELERATOR_UNAVAILABLE",
+ message="NVIDIA CUDA is required for configured segmentation, but PyTorch is not importable.",
+ status_code=503,
+ ) from exc
+ if not torch.cuda.is_available():
+ raise AppError(
+ code="SEGMENTATION_ACCELERATOR_UNAVAILABLE",
+ message="NVIDIA CUDA is required for configured segmentation, but no CUDA device is available.",
+ details={"configured_device": self.settings.yolo_device},
+ status_code=503,
+ )
+ if not str(self.settings.yolo_device).lower().startswith(("cuda", "0", "1", "2", "3")):
+ raise AppError(
+ code="SEGMENTATION_ACCELERATOR_MISCONFIGURED",
+ message="NVIDIA CUDA is required, but YOLO_DEVICE does not select a CUDA device.",
+ details={"configured_device": self.settings.yolo_device},
+ status_code=503,
+ )
def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]:
if not tile_path.exists() or not tile_path.is_file():
diff --git a/backend/app/services/segmentation_service.py b/backend/app/services/segmentation_service.py
index be3052a9..62031149 100644
--- a/backend/app/services/segmentation_service.py
+++ b/backend/app/services/segmentation_service.py
@@ -272,6 +272,8 @@ class SegmentationService:
dataset_id: uuid.UUID | None = None,
class_name: str | None = None,
min_confidence: float | None = None,
+ limit: int | None = None,
+ offset: int = 0,
) -> SegmentationListResponse:
if analysis_run_id is not None:
run = db.get(AnalysisRun, analysis_run_id)
@@ -284,8 +286,19 @@ class SegmentationService:
class_name=class_name,
min_confidence=min_confidence,
)
- items = [SegmentationRead.model_validate(row) for row in rows]
- return SegmentationListResponse(items=items, total=len(items))
+ resolved_limit = DetectionService.DEFAULT_RESULT_LIMIT if limit is None else int(limit)
+ page, total, truncated = DetectionService.paginate(
+ rows,
+ limit=resolved_limit,
+ offset=offset,
+ )
+ return SegmentationListResponse(
+ items=[SegmentationRead.model_validate(row) for row in page],
+ total=total,
+ limit=resolved_limit,
+ offset=max(0, int(offset)),
+ truncated=truncated,
+ )
@staticmethod
def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead:
@@ -847,7 +860,6 @@ class SegmentationService:
"suppressed_segmentation_count": len(candidates) - len(filtered_candidates),
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
"containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold),
- "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
"runtime_model_provenance": runtime_model_provenance.as_dict(),
}
diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py
index 6b94cd30..70b9dfe2 100644
--- a/backend/tests/frontend_contract.py
+++ b/backend/tests/frontend_contract.py
@@ -93,6 +93,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
),
"shell": (
"App.tsx",
+ "WorkbenchApp.tsx",
"components/shell/WorkbenchNavigation.tsx",
"components/shell/SecondaryDisplay.tsx",
"components/inspector/WorkbenchInspector.tsx",
diff --git a/backend/tests/test_guest_resource_scope.py b/backend/tests/test_guest_resource_scope.py
new file mode 100644
index 00000000..3a719e34
--- /dev/null
+++ b/backend/tests/test_guest_resource_scope.py
@@ -0,0 +1,347 @@
+from __future__ import annotations
+
+from pathlib import Path
+from uuid import UUID
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.core.config import get_settings
+from app.db.session import get_db
+from app.main import create_app
+from app.models import AnalysisRun, Dataset, Detection, Export, Job, Segmentation
+from app.schemas import (
+ DetectionRunListResponse,
+ DetectionRunResponse,
+ SegmentationRunListResponse,
+ SegmentationRunResponse,
+)
+from app.services.auth_service import AuthService
+from app.services.detection_service import DetectionService
+from app.services.segmentation_service import SegmentationService
+
+
+GUEST_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000123")
+OTHER_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000999")
+DATASET_ID = UUID("00000000-0000-0000-0000-000000000201")
+DETECTION_RUN_ID = UUID("00000000-0000-0000-0000-000000000202")
+SEGMENTATION_RUN_ID = UUID("00000000-0000-0000-0000-000000000203")
+DETECTION_ID = UUID("00000000-0000-0000-0000-000000000204")
+SEGMENTATION_ID = UUID("00000000-0000-0000-0000-000000000205")
+EXPORT_ID = UUID("00000000-0000-0000-0000-000000000206")
+JOB_ID = UUID("00000000-0000-0000-0000-000000000207")
+
+
+class FakeSession:
+ def __init__(self, objects: dict[tuple[type, UUID], object]) -> None:
+ self.objects = objects
+
+ def get(self, model, row_id):
+ return self.objects.get((model, row_id))
+
+
+def _guest_client(monkeypatch, db: FakeSession) -> TestClient:
+ password_hash = AuthService.hash_password(
+ "operator-password",
+ salt=b"guest-scope-test-salt",
+ iterations=100_000,
+ )
+ monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true")
+ monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
+ monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
+ monkeypatch.setenv(
+ "GEOINTEL_AUTH_SESSION_SECRET",
+ "guest-scope-test-session-secret-value",
+ )
+ monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true")
+ monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast")
+
+ client = TestClient(create_app())
+
+ def fake_db():
+ yield db
+
+ client.app.dependency_overrides[get_db] = fake_db
+ token = AuthService.create_session_token(
+ "Gast",
+ get_settings(),
+ role="guest",
+ project_id=GUEST_PROJECT_ID,
+ )
+ client.cookies.set("geointel_session", token)
+ return client
+
+
+def _project_objects(project_id: UUID, export_path: Path) -> dict[tuple[type, UUID], object]:
+ return {
+ (Dataset, DATASET_ID): Dataset(
+ id=DATASET_ID,
+ project_id=project_id,
+ name="scope-test.tif",
+ dataset_type="raster",
+ source="fixture",
+ ),
+ (AnalysisRun, DETECTION_RUN_ID): AnalysisRun(
+ id=DETECTION_RUN_ID,
+ project_id=project_id,
+ dataset_id=DATASET_ID,
+ analysis_type="detection",
+ status="success",
+ parameters_json={},
+ ),
+ (AnalysisRun, SEGMENTATION_RUN_ID): AnalysisRun(
+ id=SEGMENTATION_RUN_ID,
+ project_id=project_id,
+ dataset_id=DATASET_ID,
+ analysis_type="segmentation",
+ status="success",
+ parameters_json={},
+ ),
+ (Detection, DETECTION_ID): Detection(
+ id=DETECTION_ID,
+ project_id=project_id,
+ dataset_id=DATASET_ID,
+ analysis_run_id=DETECTION_RUN_ID,
+ model_name="fixture-detector",
+ class_name="building",
+ confidence=0.9,
+ geometry="SRID=4326;POINT (5 51)",
+ ),
+ (Segmentation, SEGMENTATION_ID): Segmentation(
+ id=SEGMENTATION_ID,
+ project_id=project_id,
+ dataset_id=DATASET_ID,
+ analysis_run_id=SEGMENTATION_RUN_ID,
+ model_name="fixture-segmenter",
+ class_name="building",
+ confidence=0.9,
+ geometry="SRID=4326;MULTIPOLYGON (((5 51, 5.1 51, 5.1 51.1, 5 51)))",
+ ),
+ (Export, EXPORT_ID): Export(
+ id=EXPORT_ID,
+ project_id=project_id,
+ export_type="dataset_geojson",
+ storage_path=str(export_path),
+ metadata_json={},
+ ),
+ }
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ f"/api/v1/detection/runs/{DETECTION_RUN_ID}",
+ f"/api/v1/detection/runs/{DETECTION_RUN_ID}/detections",
+ f"/api/v1/detection/runs/{DETECTION_RUN_ID}/geojson",
+ f"/api/v1/detection/datasets/{DATASET_ID}/detections",
+ f"/api/v1/detection/datasets/{DATASET_ID}/geojson",
+ f"/api/v1/detection/detections/{DETECTION_ID}",
+ f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}",
+ f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/segmentations",
+ f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/geojson",
+ f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations",
+ f"/api/v1/segmentation/datasets/{DATASET_ID}/geojson",
+ f"/api/v1/segmentation/segmentations/{SEGMENTATION_ID}",
+ f"/api/v1/exports/{EXPORT_ID}",
+ f"/api/v1/exports/{EXPORT_ID}/content",
+ f"/api/v1/exports/{EXPORT_ID}/download",
+ f"/api/v1/exports/projects/{OTHER_PROJECT_ID}/exports",
+ ],
+)
+def test_matching_guest_query_cannot_authorize_another_projects_resource(
+ path: str,
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ artifact = tmp_path / "other-project.geojson"
+ artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
+ client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact)))
+
+ response = client.get(f"{path}?project_id={GUEST_PROJECT_ID}")
+
+ assert response.status_code == 403
+ assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
+
+
+@pytest.mark.parametrize(
+ ("path", "payload"),
+ [
+ (
+ "/api/v1/detection/run",
+ {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
+ ),
+ (
+ "/api/v1/detection/run-async",
+ {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
+ ),
+ (
+ "/api/v1/segmentation/run",
+ {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
+ ),
+ (
+ "/api/v1/segmentation/run-async",
+ {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"},
+ ),
+ (
+ "/api/v1/detection/runs/{run_id}/qa/reference".format(run_id=DETECTION_RUN_ID),
+ {"reference_dataset_id": str(DATASET_ID)},
+ ),
+ (
+ "/api/v1/segmentation/runs/{run_id}/qa/reference".format(run_id=SEGMENTATION_RUN_ID),
+ {"reference_dataset_id": str(DATASET_ID)},
+ ),
+ (
+ "/api/v1/exports/geojson",
+ {"export_kind": "dataset", "dataset_id": str(DATASET_ID)},
+ ),
+ (
+ "/api/v1/exports/geojson",
+ {"export_kind": "detection_run", "analysis_run_id": str(DETECTION_RUN_ID)},
+ ),
+ (
+ "/api/v1/exports/geojson",
+ {"export_kind": "segmentation_run", "analysis_run_id": str(SEGMENTATION_RUN_ID)},
+ ),
+ (
+ "/api/v1/exports/metadata",
+ {"project_id": str(OTHER_PROJECT_ID)},
+ ),
+ (
+ "/api/v1/exports/report",
+ {"project_id": str(OTHER_PROJECT_ID)},
+ ),
+ (
+ "/api/v1/exports/map-result",
+ {
+ "project_id": str(OTHER_PROJECT_ID),
+ "mode": "current",
+ "dataset_id": str(DATASET_ID),
+ "bbox": {"min_x": 5.0, "min_y": 51.0, "max_x": 5.1, "max_y": 51.1, "crs": "EPSG:4326"},
+ },
+ ),
+ ],
+)
+def test_matching_guest_query_cannot_override_post_body_or_target_scope(
+ path: str,
+ payload: dict,
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ artifact = tmp_path / "other-project.geojson"
+ artifact.write_text("{}", encoding="utf-8")
+ client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact)))
+
+ response = client.post(f"{path}?project_id={GUEST_PROJECT_ID}", json=payload)
+
+ assert response.status_code == 403
+ assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
+
+
+def test_guest_can_still_read_and_download_its_own_resources(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ artifact = tmp_path / "demo.geojson"
+ artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
+ client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact)))
+ suffix = f"?project_id={GUEST_PROJECT_ID}"
+
+ detection = client.get(f"/api/v1/detection/runs/{DETECTION_RUN_ID}{suffix}")
+ segmentation = client.get(f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}{suffix}")
+ export = client.get(f"/api/v1/exports/{EXPORT_ID}{suffix}")
+ download = client.get(f"/api/v1/exports/{EXPORT_ID}/download{suffix}")
+
+ assert detection.status_code == 200
+ assert segmentation.status_code == 200
+ assert export.status_code == 200
+ assert download.status_code == 200
+ assert download.json()["type"] == "FeatureCollection"
+
+
+def test_guest_run_lists_and_new_runs_remain_bound_to_the_session_project(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ artifact = tmp_path / "demo.geojson"
+ artifact.write_text("{}", encoding="utf-8")
+ client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact)))
+ observed: list[UUID] = []
+
+ def detection_list(_db, *, project_id, **_kwargs):
+ observed.append(project_id)
+ return DetectionRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False)
+
+ def segmentation_list(_db, *, project_id, **_kwargs):
+ observed.append(project_id)
+ return SegmentationRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False)
+
+ def detection_run(**kwargs):
+ observed.append(kwargs["project_id"])
+ return DetectionRunResponse(
+ analysis_run_id=DETECTION_RUN_ID,
+ job_id=JOB_ID,
+ project_id=kwargs["project_id"],
+ dataset_id=kwargs["dataset_id"],
+ model_id=kwargs["model_id"],
+ status="success",
+ detection_count=0,
+ message="Demo run completed",
+ )
+
+ def segmentation_run(**kwargs):
+ observed.append(kwargs["project_id"])
+ return SegmentationRunResponse(
+ analysis_run_id=SEGMENTATION_RUN_ID,
+ job_id=JOB_ID,
+ project_id=kwargs["project_id"],
+ dataset_id=kwargs["dataset_id"],
+ model_id=kwargs["model_id"],
+ status="success",
+ segmentation_count=0,
+ message="Demo run completed",
+ )
+
+ monkeypatch.setattr(DetectionService, "list_runs", detection_list)
+ monkeypatch.setattr(SegmentationService, "list_runs", segmentation_list)
+ monkeypatch.setattr(DetectionService, "run_detection", detection_run)
+ monkeypatch.setattr(SegmentationService, "run_segmentation", segmentation_run)
+
+ def enqueue_detection(**kwargs):
+ observed.append(kwargs["project_id"])
+ return Job(
+ id=JOB_ID,
+ job_type="detection.run",
+ status="queued",
+ project_id=kwargs["project_id"],
+ dataset_id=kwargs["dataset_id"],
+ parameters_json={},
+ )
+
+ monkeypatch.setattr(DetectionService, "enqueue_detection", enqueue_detection)
+
+ def enqueue_segmentation(**kwargs):
+ observed.append(kwargs["project_id"])
+ return Job(
+ id=JOB_ID,
+ job_type="segmentation.run",
+ status="queued",
+ project_id=kwargs["project_id"],
+ dataset_id=kwargs["dataset_id"],
+ parameters_json={},
+ )
+
+ monkeypatch.setattr(SegmentationService, "enqueue_segmentation", enqueue_segmentation)
+ query = f"?project_id={GUEST_PROJECT_ID}"
+ payload = {"project_id": str(GUEST_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}
+
+ responses = [
+ client.get(f"/api/v1/detection/runs{query}"),
+ client.get(f"/api/v1/segmentation/runs{query}"),
+ client.post(f"/api/v1/detection/run{query}", json=payload),
+ client.post(f"/api/v1/detection/run-async{query}", json=payload),
+ client.post(f"/api/v1/segmentation/run{query}", json=payload),
+ client.post(f"/api/v1/segmentation/run-async{query}", json=payload),
+ ]
+
+ assert all(response.status_code == 200 for response in responses)
+ assert observed == [GUEST_PROJECT_ID] * 6
diff --git a/backend/tests/test_outbound_request_guard.py b/backend/tests/test_outbound_request_guard.py
index c0bc3e68..f866cd5f 100644
--- a/backend/tests/test_outbound_request_guard.py
+++ b/backend/tests/test_outbound_request_guard.py
@@ -18,8 +18,10 @@ import pytest
from app.core.errors import AppError
from app.services.outbound_request_guard import (
+ _ValidatedRedirects,
assert_public_http_url,
assert_same_origin_redirect,
+ validated_redirect_opener,
)
@@ -89,6 +91,30 @@ class TestRedirects:
def test_an_upgrade_to_https_stays_allowed(self) -> None:
assert_same_origin_redirect("http://geo.example.be/wcs", "https://geo.example.be/wcs")
+ def test_a_redirect_to_another_port_is_refused(self) -> None:
+ with pytest.raises(AppError) as exc_info:
+ assert_same_origin_redirect(
+ "https://geo.api.vlaanderen.be/wcs",
+ "https://geo.api.vlaanderen.be:8443/wcs",
+ )
+
+ assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED"
+
+ def test_embedded_credentials_are_refused(self) -> None:
+ with pytest.raises(AppError) as exc_info:
+ assert_public_http_url("https://operator:secret@geo.example.be/wcs")
+
+ assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
+
+ def test_a_redirect_with_an_invalid_port_fails_closed(self) -> None:
+ with pytest.raises(AppError) as exc_info:
+ assert_same_origin_redirect(
+ "https://geo.api.vlaanderen.be/wcs",
+ "https://geo.api.vlaanderen.be:not-a-port/wcs",
+ )
+
+ assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED"
+
def test_the_guard_opener_refuses_a_cross_host_redirect() -> None:
"""The opener is what the acquisition services actually call."""
@@ -249,6 +275,26 @@ def test_a_refused_redirect_is_never_requested() -> None:
assert "_RejectRedirects" in handlers
+def test_the_default_guard_validates_before_following_a_redirect() -> None:
+ opener = validated_redirect_opener("https://geo.api.vlaanderen.be/wcs")
+ handlers = [type(handler).__name__ for handler in opener.handlers]
+
+ assert "_ValidatedRedirects" in handlers
+
+ handler = _ValidatedRedirects("https://geo.api.vlaanderen.be/wcs")
+ with pytest.raises(AppError) as exc_info:
+ handler.redirect_request(
+ None,
+ None,
+ 302,
+ "Found",
+ {},
+ "http://169.254.169.254/latest/meta-data/",
+ )
+
+ assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED"
+
+
def test_the_rejecting_handler_returns_no_new_request() -> None:
from app.services.outbound_request_guard import _RejectRedirects
diff --git a/backend/tests/test_segmentation_adapter_runtime.py b/backend/tests/test_segmentation_adapter_runtime.py
new file mode 100644
index 00000000..f5d2d10b
--- /dev/null
+++ b/backend/tests/test_segmentation_adapter_runtime.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from app.core.config import Settings
+from app.core.errors import AppError
+from app.services.segmentation_adapter import YoloSegmentationAdapter
+
+
+def _settings(*, require_cuda: bool, device: str) -> Settings:
+ return Settings(
+ _env_file=None,
+ YOLO_REQUIRE_CUDA=require_cuda,
+ YOLO_DEVICE=device,
+ )
+
+
+def test_segmentation_runtime_allows_cpu_only_when_cuda_is_not_required() -> None:
+ adapter = YoloSegmentationAdapter(_settings(require_cuda=False, device="cpu"))
+
+ adapter.validate_runtime()
+
+
+def test_segmentation_runtime_rejects_missing_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setitem(
+ __import__("sys").modules,
+ "torch",
+ SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
+ )
+ adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0"))
+
+ with pytest.raises(AppError) as exc_info:
+ adapter.validate_runtime()
+
+ assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_UNAVAILABLE"
+
+
+def test_segmentation_runtime_rejects_cpu_device_when_cuda_is_required(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setitem(
+ __import__("sys").modules,
+ "torch",
+ SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)),
+ )
+ adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cpu"))
+
+ with pytest.raises(AppError) as exc_info:
+ adapter.validate_runtime()
+
+ assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_MISCONFIGURED"
+
+
+def test_segmentation_runtime_accepts_configured_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setitem(
+ __import__("sys").modules,
+ "torch",
+ SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)),
+ )
+ adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0"))
+
+ adapter.validate_runtime()
diff --git a/backend/tests/test_segmentation_result_pagination.py b/backend/tests/test_segmentation_result_pagination.py
new file mode 100644
index 00000000..b449001f
--- /dev/null
+++ b/backend/tests/test_segmentation_result_pagination.py
@@ -0,0 +1,151 @@
+"""Regression coverage for bounded, stable segmentation result listings."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from types import SimpleNamespace
+from uuid import UUID
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.api.routes import segmentation as segmentation_routes
+from app.db.session import get_db
+from app.schemas.segmentation import SegmentationListResponse
+from app.services.segmentation_service import SegmentationService
+
+
+RUN_ID = UUID("00000000-0000-0000-0000-000000000101")
+DATASET_ID = UUID("00000000-0000-0000-0000-000000000102")
+PROJECT_ID = UUID("00000000-0000-0000-0000-000000000103")
+
+
+def _segmentation(index: int) -> SimpleNamespace:
+ return SimpleNamespace(
+ id=UUID(int=index + 1),
+ project_id=PROJECT_ID,
+ dataset_id=DATASET_ID,
+ analysis_run_id=RUN_ID,
+ job_id=None,
+ model_name="segmentation-test-model",
+ model_version="1",
+ class_name="building",
+ confidence=0.99 - index / 100,
+ bbox_json=None,
+ area_m2=float(index + 1),
+ mask_path=None,
+ source_tile_path=None,
+ tile_index=index,
+ properties_json={},
+ provenance_json={},
+ created_at=datetime(2026, 8, 23, tzinfo=UTC),
+ )
+
+
+class _Session:
+ def get(self, _model, identifier):
+ if identifier == RUN_ID:
+ return SimpleNamespace(analysis_type="segmentation")
+ return None
+
+
+def test_service_returns_one_stable_page_with_complete_metadata(monkeypatch) -> None:
+ rows = [_segmentation(index) for index in range(5)]
+ monkeypatch.setattr(
+ SegmentationService,
+ "_query_segmentation_rows",
+ staticmethod(lambda _db, **_filters: rows),
+ )
+
+ result = SegmentationService.list_segmentations(
+ _Session(),
+ analysis_run_id=RUN_ID,
+ dataset_id=DATASET_ID,
+ limit=2,
+ offset=1,
+ )
+
+ assert [item.id for item in result.items] == [rows[1].id, rows[2].id]
+ assert result.total == 5
+ assert result.limit == 2
+ assert result.offset == 1
+ assert result.truncated is True
+
+
+def test_service_pages_cover_the_stably_ordered_population_once(monkeypatch) -> None:
+ rows = [_segmentation(index) for index in range(5)]
+ monkeypatch.setattr(
+ SegmentationService,
+ "_query_segmentation_rows",
+ staticmethod(lambda _db, **_filters: rows),
+ )
+
+ seen = []
+ for offset in (0, 2, 4):
+ result = SegmentationService.list_segmentations(
+ _Session(),
+ dataset_id=DATASET_ID,
+ limit=2,
+ offset=offset,
+ )
+ seen.extend(item.id for item in result.items)
+ assert result.total == len(rows)
+ assert result.offset == offset
+
+ assert seen == [row.id for row in rows]
+
+
+@pytest.mark.parametrize(
+ ("path", "expected_run_id", "expected_dataset_id"),
+ [
+ (f"/api/v1/segmentation/runs/{RUN_ID}/segmentations", RUN_ID, None),
+ (f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations", None, DATASET_ID),
+ ],
+)
+def test_both_listing_routes_forward_the_page_window_and_return_it(
+ monkeypatch,
+ path: str,
+ expected_run_id: UUID | None,
+ expected_dataset_id: UUID | None,
+) -> None:
+ calls: list[dict] = []
+
+ def _list(_db, analysis_run_id=None, **parameters):
+ calls.append({"analysis_run_id": analysis_run_id, **parameters})
+ return SegmentationListResponse(
+ items=[],
+ total=9,
+ limit=2,
+ offset=4,
+ truncated=True,
+ )
+
+ monkeypatch.setattr(SegmentationService, "list_segmentations", staticmethod(_list))
+ app = FastAPI()
+ app.include_router(segmentation_routes.router, prefix="/api/v1")
+ app.dependency_overrides[get_db] = lambda: object()
+
+ response = TestClient(app).get(
+ path,
+ params={"limit": 2, "offset": 4, "class_name": "building", "min_confidence": 0.5},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["data"] == {
+ "items": [],
+ "total": 9,
+ "limit": 2,
+ "offset": 4,
+ "truncated": True,
+ }
+ assert calls == [
+ {
+ "analysis_run_id": expected_run_id,
+ "limit": 2,
+ "offset": 4,
+ "dataset_id": expected_dataset_id,
+ "class_name": "building",
+ "min_confidence": 0.5,
+ }
+ ]
diff --git a/backend/tests/test_sprint104_ai_lab_action_guardrails.py b/backend/tests/test_sprint104_ai_lab_action_guardrails.py
index 264d67c0..340f5032 100644
--- a/backend/tests/test_sprint104_ai_lab_action_guardrails.py
+++ b/backend/tests/test_sprint104_ai_lab_action_guardrails.py
@@ -15,7 +15,8 @@ def test_detection_lab_distinguishes_configured_model_from_ui_runnable_action()
assert "detectionRunBlockedReason" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Klaar om gebouwen te zoeken" in lab
- assert "disabled={runningDetection || !detectionRunReady}" in lab
+ assert "disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}" in lab
+ assert "detectionJob?.status === 'queued' || detectionJob?.status === 'running'" in lab
def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action() -> None:
@@ -26,7 +27,8 @@ def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action
assert "segmentationRunBlockedReason" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Analyse" in lab
- assert "disabled={runningSegmentation || !segmentationRunReady}" in lab
+ assert "disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}" in lab
+ assert "segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'" in lab
def test_ai_lab_guardrail_styles_remain_compact() -> None:
diff --git a/backend/tests/test_sprint123_raster_detection_handoff_operational.py b/backend/tests/test_sprint123_raster_detection_handoff_operational.py
index cfaf7140..938e2a02 100644
--- a/backend/tests/test_sprint123_raster_detection_handoff_operational.py
+++ b/backend/tests/test_sprint123_raster_detection_handoff_operational.py
@@ -28,7 +28,12 @@ def test_raster_controls_show_manifest_details_and_ai_handoff_action() -> None:
def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_explicit() -> None:
- app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
+ app = "\n".join(
+ (
+ (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8"),
+ (ROOT / "frontend" / "src" / "WorkbenchApp.tsx").read_text(encoding="utf-8"),
+ )
+ )
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
@@ -40,7 +45,7 @@ def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_expl
assert "setSelectedDetectionDatasetId(selectedDataset.id)" in app
assert "setSelectedDetectionModelId('yolo-configured')" in app
assert "setDetectionConfidenceThreshold(0.25)" in app
- assert "loadYoloPreflight(manifestPath).catch(() => null)" in app
+ assert "loadYoloPreflight(manifestPath).catch(() => meldLaadfout('modelcontrole'))" in app
assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))]
assert "Gekoppelde beeldtegels" in lab
assert "Gekoppelde beeldtegels" in lab
diff --git a/backend/tests/test_sprint195_guided_detection_workflow.py b/backend/tests/test_sprint195_guided_detection_workflow.py
index 0f8b4221..59a4e87e 100644
--- a/backend/tests/test_sprint195_guided_detection_workflow.py
+++ b/backend/tests/test_sprint195_guided_detection_workflow.py
@@ -26,8 +26,10 @@ def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None:
assert "effectiveModelId" in hook
assert "effectiveModelAssetId" in hook
assert "await loadDetectionResults(result.analysis_run_id)" in hook
- assert "model_id: selectedDetectionModelId" in hook
- assert "model_asset_id: selectedModelAssetId || null" in hook
+ assert "model_id: modelId" in hook
+ assert "model_asset_id: modelAssetId || null" in hook
+ assert "effectiveModelId" in hook
+ assert "effectiveModelAssetId" in hook
def test_guided_detection_upload_uses_existing_dataset_persistence_boundary() -> None:
@@ -63,7 +65,8 @@ def test_detection_qa_remains_persisted_and_primary_not_parallel() -> None:
assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab
assert "als kwaliteitscontrole in de database bewaard" in lab
assert "detectionApi.compareWithReference" in hook
- assert "await loadQualityChecks(selectedProjectId)" in hook
+ assert "await loadQualityChecks(projectId)" in hook
+ assert "detectionQaRequestSequence.current" in hook
assert "Minimale IoU voor een match" in lab
assert "detectionQaResult.iou_threshold.toFixed(2)" in lab
diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md
index 122216c6..2a94b0a8 100644
--- a/docs/API_CONTRACTS.md
+++ b/docs/API_CONTRACTS.md
@@ -1528,7 +1528,10 @@ Response:
## Detection Lab
-Sprint 8 implements Detection Lab foundation only. YOLO/PyTorch real inference is not enabled, no model is downloaded, and fixture detections require explicit fixture mode.
+Detection Lab exposes the governed local YOLO/PyTorch runtime only when model,
+dependencies and the configured NVIDIA accelerator pass preflight. GeoIntel
+never downloads a model implicitly; fixture detections still require explicit
+fixture mode and are not production inference.
### Guided browser orchestration
@@ -1537,11 +1540,17 @@ The current frontend offers one guided building-analysis action, but does not ad
1. optional explicit `POST /api/v1/projects/{project_id}/datasets/upload` for a georeferenced GeoTIFF;
2. `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/raster/tile` with 512 px tiles and 64 px overlap;
3. `GET /api/v1/detection/yolo/preflight` with the returned manifest and selected local model asset;
-4. `POST /api/v1/detection/run` only after successful preflight;
-5. persisted run, Detection list and Detection GeoJSON reads;
-6. optional persisted reference QA through the existing detection QA endpoint.
+4. `POST /api/v1/detection/run-async` only after successful preflight;
+5. project-bound polling through
+ `GET /api/v1/projects/{project_id}/jobs/{job_id}` until a terminal state;
+6. persisted run, Detection list and Detection GeoJSON reads;
+7. optional persisted reference QA through the existing detection QA endpoint.
-The strict `POST /api/v1/detection/run` contract still requires `tile_manifest_path` for configured YOLO. The frontend does not create fake tiles, bypass tile limits, fetch external imagery or download model weights.
+The strict async request contract still requires `tile_manifest_path` for
+configured YOLO. The production frontend does not fall back to the synchronous
+inference route, create fake tiles, bypass tile limits, fetch external imagery
+or download model weights. A zero-count success remains a completed inference,
+not proof that the selected area contains no objects.
### GET `/api/v1/detection/models`
@@ -1758,8 +1767,12 @@ rejected immediately rather than by a job that fails minutes later.
Queued jobs are executed by the background analysis worker
(`GEOINTEL_ANALYSIS_WORKER_ENABLED`, poll interval
`GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS`), which claims a job before dispatching
-it so the same run is never started twice. Poll `GET /api/v1/jobs/{id}` for
-progress. `POST /api/v1/segmentation/run-async` behaves identically.
+it so the same run is never started twice. Poll the project-bound
+`GET /api/v1/projects/{project_id}/jobs/{job_id}` endpoint for progress.
+`POST /api/v1/segmentation/run-async` behaves identically. Guest sessions may
+queue and read analysis only for the project id embedded in their signed
+session; query parameters never authorize a run, result or export belonging to
+another project.
Unavailable model response:
@@ -2032,7 +2045,12 @@ Same pattern as object detection, but output includes masks and polygonized geom
## Segmentation Lab
-Sprint 9 implements Segmentation Lab foundation only. Real SAM and YOLO-seg inference are not enabled, no model is downloaded, and fixture segmentations require explicit fixture mode.
+Segmentation Lab exposes a configured local YOLO-seg or SAM runtime when its
+model file, immutable runtime provenance and dependencies validate. No model is
+downloaded. On the NVIDIA server, `YOLO_REQUIRE_CUDA=true` makes both configured
+segmentation adapters fail closed when CUDA is absent or `YOLO_DEVICE` selects
+CPU. Fixture segmentations remain explicit test-only data and the production
+browser never queues that model.
### GET `/api/v1/segmentation/models`
@@ -2043,6 +2061,9 @@ Returns segmentation model capability descriptors:
- `yolo-seg-configured`: `not_configured`
- `sam-configured`: `not_configured`
+The two configured entries become `configured` only when their corresponding
+enable flag, local model file and provenance sidecar validate.
+
### POST `/api/v1/segmentation/run`
Creates a segmentation job and segmentation analysis run. If the requested model is unavailable, the job and analysis run are marked `failed` with `SEGMENTATION_MODEL_UNAVAILABLE`.
@@ -2063,6 +2084,16 @@ Request:
Fixture segmenter mode is test/demo-only. It persists only explicit `parameters_json.fixture_segmentations` entries when `parameters_json.fixture_mode=true`; it is never invoked automatically and does not represent production inference.
+The production frontend uses `POST /api/v1/segmentation/run-async`, then polls
+`GET /api/v1/projects/{project_id}/jobs/{job_id}` and reconciles the terminal
+job with its persisted `AnalysisRun` and polygon records. It does not fall back
+to the synchronous route. A configured model requires an existing
+`tile_manifest_path`; missing CUDA fails with
+`SEGMENTATION_ACCELERATOR_UNAVAILABLE` or
+`SEGMENTATION_ACCELERATOR_MISCONFIGURED` when CUDA is required. A valid
+zero-polygon run is shown as an empty model result, never as proof that the AOI
+contains no relevant objects.
+
Validation errors:
- `INVALID_DATASET_TYPE` when the dataset is not raster.
@@ -2086,6 +2117,11 @@ Returns persisted segmentation records for a segmentation analysis run. Optional
- `dataset_id`
- `class_name`
- `min_confidence`
+- `limit` (`0` means every matching record, otherwise capped at `50000`)
+- `offset`
+
+The response reports `total`, `limit`, `offset` and `truncated`; clients must
+not present a truncated page as the complete polygon population.
### GET `/api/v1/segmentation/datasets/{dataset_id}/segmentations`
diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md
index 5d7ef2e5..1eae78b1 100644
--- a/docs/CODEX_EXECUTION_LOG.md
+++ b/docs/CODEX_EXECUTION_LOG.md
@@ -12867,3 +12867,71 @@ Open:
- Browser emulation covers responsive layout and interaction; certification on
physical touch hardware and with a screen reader remains a separate human QA
activity.
+
+## 2026-08-23 - Sol Ultra product-, runtime- en betrouwbaarheidsronde
+
+### Delivered
+
+- Split the public landing foundation from the lazy workbench and MapLibre
+ styles. The initial production CSS payload dropped from roughly 219 kB to
+ 38.38 kB while the authenticated workbench keeps its complete styling.
+- Extended the reproducible browser audit to cover the landing and workbench at
+ 390 x 844, 1366 x 768 and 2560 x 1080, including mobile navigation,
+ keyboard tabs, loading state, advanced map flow and every guest workspace.
+- Corrected the smartphone shell hierarchy: topbar, guest banner and page
+ heading no longer overlap, and the live Selecteer/Bronnen/Verwerk/Controleer
+ rail now sits below the map actions instead of behind the fixed navigation.
+- Kept full workspace titles for headings and accessible names while shortening
+ the two mobile navigation labels to `AI-beeld` and `Export`; the browser gate
+ now rejects any visible sidebar label whose text box is clipped.
+- Made map-analysis failures outrank empty states and added an explicit retry;
+ new selections clear stale coverage immediately.
+- Replaced synchronous browser inference with governed async detection and
+ segmentation queues, project-bound job polling and persisted-run
+ reconciliation. Detection has NVIDIA preflight; segmentation now fails
+ closed under the same server CUDA contract. Zero-result runs are communicated
+ without claiming that the AOI is object-free.
+- Bound guest detection, segmentation and export reads/writes to the signed
+ demo project at the resource level. Matching query parameters can no longer
+ authorize another project's run, dataset, result or download.
+- Closed stale-response races in temporal comparison and the local GeoAI
+ assistant, plus detection/segmentation run, result and QA flows across project
+ switches. Previously visited workspaces no longer reload together after every
+ navigation change.
+- Added keyboard-complete pipeline tabs and React-driven model-dialog state,
+ initial focus and trigger-focus restoration. Landing scrolling now respects
+ `prefers-reduced-motion`.
+- Hardened outbound acquisition redirects before the redirected request is
+ opened, including origin/port and embedded-credential rejection, and fixed
+ bounded pagination for segmentation result lists.
+- Fixed segmentation readiness and section status: configured production
+ models now require a real tile manifest before queueing, fixture mode is
+ visibly test-only, and queued/running NVIDIA work has an explicit live state.
+- Localised known model registrations and availability states in the Dutch UI;
+ raw English backend placeholder copy no longer leaks into the primary model
+ selector or readiness guidance.
+
+### Verification
+
+- Frontend TypeScript check and production build passed.
+- Complete frontend suite: 36 files / 151 tests passed.
+- Relevant backend release set: 121 tests passed, covering async analysis jobs,
+ atomic claims, guest/resource isolation, redirect policy, segmentation
+ pagination, NVIDIA runtime enforcement and current AI-lab contracts.
+- Ruff passed over every changed backend Python module and test.
+- Browser evidence passed across three landing and three authenticated
+ workbench viewports with zero horizontal overflow, console errors or failed
+ API requests in `.codex-artifacts/sol-ultra-final-l/manifest.json`; focused
+ AI-workspace and segmentation screenshots are stored beside it.
+- Production build passed. Initial landing CSS remains 38.38 kB (8.22 kB
+ gzip); the lazy workbench JS is 479.76 kB (129.25 kB gzip) and MapLibre stays
+ isolated in its own lazy chunk.
+
+### Boundaries
+
+- This pass improves runtime correctness and presentation; it does not invent a
+ new accuracy claim or promote a model checkpoint. Existing governed model
+ evidence and regional release gates remain authoritative.
+- Physical touch-device and screen-reader certification remain human QA. No
+ commit or deployment was performed because the active execution brief
+ explicitly forbids committing unless requested.
diff --git a/docs/TODO.md b/docs/TODO.md
index df77f3c9..9b5a1cd1 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1109,6 +1109,32 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Loading-, empty-, unavailable- en errorstates plus toetsenbord- en dialogbediening.
- [x] Zoekbare en bredere kaartthemalijst met volledig leesbare labels.
- [x] Compacte analysecontextbalk en rustige desktop/tablet/mobiele hiërarchie.
+
+# Sprint 237 - Sol Ultra productupgrade (2026-08-23)
+
+- [x] Splits publieke landing-CSS en MapLibre van de lazy werkbankbundel.
+- [x] Valideer landing en werkbank op 390, 1366 en 2560 px zonder overflow,
+ consolefouten of mislukte API-requests.
+- [x] Herstel mobiele topbar/banner/kop- en procesrailbotsingen.
+- [x] Voorkom afgekapte mobiele navigatielabels met korte zichtlabels en een
+ automatische clipping-gate.
+- [x] Toon analysefouten vóór lege states en bied een herhaalactie.
+- [x] Wis oude dekkingsdata zodra een nieuwe AOI wordt opgelost.
+- [x] Sluit stale-response races in tijdvergelijking en AI-vragen.
+- [x] Sluit late detectie-/segmentatiejobs, resultaten en QA na een
+ werkruimtewissel uit.
+- [x] Voer productie-detectie uitsluitend via async NVIDIA/GPU-jobs uit en
+ verzoen het resultaat met de bewaarde AnalysisRun.
+- [x] Voer productie-segmentatie uitsluitend via async serverjobs uit, eis een
+ tegelmanifest en laat de NVIDIA-runtime fail-closed valideren.
+- [x] Bind gast-detecties, segmentaties en downloads aan het gesigneerde
+ demoproject op resourceniveau.
+- [x] Valideer redirects vóór netwerktoegang en begrens segmentatieresultaten.
+- [x] Maak pipeline-tabs en modeldialoog volledig toetsenbordbedienbaar.
+- [x] Lokaliseer bekende modelnamen en beschikbaarheidsmeldingen in de primaire
+ Nederlandse AI-flow.
+- [ ] Voer vóór formele toegankelijkheidscertificatie nog fysieke touch- en
+ screenreader-QA uit; browseremulatie en automatische naamcontrole zijn groen.
- [x] Uitschuifbare inzichten behouden; analyse blijft uitsluitend expliciet na themakeuze.
- [x] 51 frontendtests en productiebuild groen.
- [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing.
diff --git a/frontend/e2e/uxAudit.mjs b/frontend/e2e/uxAudit.mjs
index 8a3202ae..02a5145d 100644
--- a/frontend/e2e/uxAudit.mjs
+++ b/frontend/e2e/uxAudit.mjs
@@ -63,9 +63,34 @@ async function auditInteractiveNames(page, label) {
return unnamed.length
}
+async function prepareAuditSession(page, baseUrl) {
+ const sessionResponse = await page.request.get(`${baseUrl}/api/v1/auth/session`)
+ assert(sessionResponse.ok(), `Session preflight failed with HTTP ${sessionResponse.status()}`)
+ const sessionEnvelope = await sessionResponse.json()
+ const session = sessionEnvelope?.data
+
+ if (!session?.authentication_required || session.authenticated) return session
+ assert.equal(
+ session.guest_access_enabled,
+ true,
+ 'UX audit needs an authenticated session or enabled guest access',
+ )
+
+ const guestResponse = await page.request.post(`${baseUrl}/api/v1/auth/guest`)
+ assert(guestResponse.ok(), `Guest audit session failed with HTTP ${guestResponse.status()}`)
+ const guestEnvelope = await guestResponse.json()
+ return guestEnvelope?.data
+}
+
async function layoutEvidence(page) {
return page.evaluate(() => {
const root = document.documentElement
+ const rect = (selector) => {
+ const bounds = document.querySelector(selector)?.getBoundingClientRect()
+ return bounds
+ ? { top: bounds.top, bottom: bounds.bottom, left: bounds.left, right: bounds.right, width: bounds.width, height: bounds.height }
+ : null
+ }
const main = document.querySelector('.workbench-main')?.getBoundingClientRect()
const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect()
const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect()
@@ -75,6 +100,11 @@ async function layoutEvidence(page) {
document_width: root.scrollWidth,
body_width: document.body.scrollWidth,
horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth),
+ shell_navigation: rect('.workbench-sidebar'),
+ topbar: rect('.workbench-topbar'),
+ guest_banner: rect('.guest-mode-banner'),
+ explorer_header: rect('.geo-explorer-header'),
+ live_analysis_journey: rect('.live-analysis-journey'),
main: main ? { left: main.left, right: main.right, width: main.width } : null,
map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null,
theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null,
@@ -82,6 +112,57 @@ async function layoutEvidence(page) {
})
}
+async function runLandingViewport(browser, baseUrl, outputDir, viewport) {
+ const page = await browser.newPage({ viewport })
+ const consoleErrors = []
+ const failedRequests = []
+ page.on('console', (message) => {
+ if (message.type() === 'error') consoleErrors.push(message.text())
+ })
+ page.on('pageerror', (error) => consoleErrors.push(error.message))
+ page.on('requestfailed', (request) => {
+ if (request.url().startsWith(baseUrl)) {
+ failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`)
+ }
+ })
+ try {
+ await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
+ await page.locator('.landing-page').waitFor({ state: 'visible', timeout: 15_000 })
+ await auditInteractiveNames(page, `${viewport.width}px landing`)
+ const horizontalOverflow = await page.evaluate(() => (
+ Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth)
+ ))
+ assert.equal(horizontalOverflow, 0, `${viewport.width}px landing overflows horizontally`)
+ assert.equal(
+ await page.getByRole('heading', { level: 1 }).count(),
+ 1,
+ `${viewport.width}px landing needs one clear primary heading`,
+ )
+
+ if (viewport.width <= 760) {
+ const menu = page.locator('.landing-menu-toggle')
+ assert.equal(await menu.getAttribute('aria-label'), 'Navigatie openen')
+ await menu.click()
+ assert.equal(await menu.getAttribute('aria-expanded'), 'true')
+ await page.getByRole('navigation', { name: 'Landingspagina' }).waitFor({ state: 'visible' })
+ await page.getByRole('button', { name: 'Navigatie sluiten' }).click()
+ }
+
+ await page.screenshot({
+ path: path.join(outputDir, `landing-${viewport.width}x${viewport.height}.png`),
+ fullPage: true,
+ })
+ return {
+ viewport,
+ horizontal_overflow_px: horizontalOverflow,
+ console_errors: consoleErrors,
+ failed_requests: failedRequests,
+ }
+ } finally {
+ await page.close()
+ }
+}
+
async function runViewport(browser, baseUrl, outputDir, viewport) {
const page = await browser.newPage({ viewport })
const consoleErrors = []
@@ -96,14 +177,41 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
}
})
try {
+ await prepareAuditSession(page, baseUrl)
const startedAt = Date.now()
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 })
const readyMs = Date.now() - startedAt
await auditInteractiveNames(page, `${viewport.width}px map explorer`)
const layout = await layoutEvidence(page)
+ const clippedNavigationLabels = await page.locator('.nav-item span').evaluateAll((labels) => labels
+ .filter((label) => label.getClientRects().length > 0 && label.scrollWidth > label.clientWidth + 1)
+ .map((label) => label.textContent?.trim() || ''))
assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`)
+ assert.deepEqual(clippedNavigationLabels, [], `${viewport.width}px navigation clips visible labels`)
assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`)
+ if (layout.topbar && layout.guest_banner) {
+ assert(
+ layout.topbar.bottom <= layout.guest_banner.top + 1,
+ `${viewport.width}px topbar overlaps the guest access banner`,
+ )
+ }
+ if (layout.guest_banner && layout.explorer_header) {
+ assert(
+ layout.guest_banner.bottom <= layout.explorer_header.top + 1,
+ `${viewport.width}px guest access banner overlaps the explorer heading`,
+ )
+ }
+ if (layout.shell_navigation && layout.live_analysis_journey) {
+ const verticalOverlap = Math.min(layout.shell_navigation.bottom, layout.live_analysis_journey.bottom)
+ - Math.max(layout.shell_navigation.top, layout.live_analysis_journey.top)
+ const horizontalOverlap = Math.min(layout.shell_navigation.right, layout.live_analysis_journey.right)
+ - Math.max(layout.shell_navigation.left, layout.live_analysis_journey.left)
+ assert(
+ verticalOverlap <= 1 || horizontalOverlap <= 1,
+ `${viewport.width}px navigation overlaps the live analysis journey`,
+ )
+ }
const currentTab = page.getByRole('tab', { name: 'Laatste toestand' })
const evolutionTab = page.getByRole('tab', { name: 'Evolutie' })
@@ -127,6 +235,7 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
viewport,
ready_ms: readyMs,
layout,
+ clipped_navigation_labels: clippedNavigationLabels,
console_errors: consoleErrors,
failed_requests: failedRequests,
}
@@ -144,6 +253,7 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) {
await route.continue()
})
try {
+ const auditSession = await prepareAuditSession(page, baseUrl)
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 })
const loadingStatus = page.getByRole('status', { name: '' }).filter({
hasText: 'Databronnen worden gecontroleerd',
@@ -175,10 +285,30 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) {
await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') })
const auditedWorkspaces = []
- for (const workspace of ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview', 'system']) {
+ const workspaceKeys = ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview']
+ if (auditSession?.role === 'guest') {
+ assert.equal(
+ await page.getByTestId('workspace-nav-system').count(),
+ 0,
+ 'Guest session exposes operator-only system settings',
+ )
+ } else {
+ workspaceKeys.push('system')
+ }
+ for (const workspace of workspaceKeys) {
await page.getByTestId(`workspace-nav-${workspace}`).click()
await page.waitForTimeout(100)
await auditInteractiveNames(page, `${workspace} workspace`)
+ if (workspace === 'ai') {
+ await page.screenshot({ path: path.join(outputDir, 'ai-workspace.png'), fullPage: true })
+ const segmentationDisclosure = page.locator('.segmentation-disclosure')
+ await segmentationDisclosure.scrollIntoViewIfNeeded()
+ await segmentationDisclosure.locator('summary').first().click()
+ await page.waitForTimeout(150)
+ await auditInteractiveNames(page, 'open segmentation lab')
+ await segmentationDisclosure.locator('.ai-lab-run-surface').scrollIntoViewIfNeeded()
+ await page.screenshot({ path: path.join(outputDir, 'ai-segmentation.png') })
+ }
auditedWorkspaces.push(workspace)
}
@@ -203,21 +333,27 @@ async function main() {
schema_version: 1,
base_url: args.baseUrl,
started_at: new Date().toISOString(),
+ landing_viewports: [],
viewports: [],
bootstrap: null,
status: 'running',
}
try {
- for (const viewport of [
+ const viewports = [
{ width: 390, height: 844 },
{ width: 1366, height: 768 },
{ width: 2560, height: 1080 },
- ]) {
+ ]
+ for (const viewport of viewports) {
+ evidence.landing_viewports.push(await runLandingViewport(browser, args.baseUrl, outputDir, viewport))
+ }
+ for (const viewport of viewports) {
evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport))
}
evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir)
- const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors)
- const unexpectedFailedRequests = evidence.viewports.flatMap((item) => item.failed_requests)
+ const auditedPages = [...evidence.landing_viewports, ...evidence.viewports]
+ const unexpectedConsoleErrors = auditedPages.flatMap((item) => item.console_errors)
+ const unexpectedFailedRequests = auditedPages.flatMap((item) => item.failed_requests)
assert.deepEqual(unexpectedConsoleErrors, [], 'UX audit captured console errors')
assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests')
evidence.status = 'passed'
diff --git a/frontend/src/WorkbenchApp.tsx b/frontend/src/WorkbenchApp.tsx
index 7f953679..438ae313 100644
--- a/frontend/src/WorkbenchApp.tsx
+++ b/frontend/src/WorkbenchApp.tsx
@@ -84,8 +84,8 @@ const workspaceNavItems: WorkspaceNavigationItem[] = [
{ key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' },
{ key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' },
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
- { key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
- { key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
+ { key: 'ai', label: 'Beeldanalyse', navigationLabel: 'AI-beeld', description: 'Gebouwen herkennen op luchtbeelden' },
+ { key: 'exports', label: 'Downloads', navigationLabel: 'Export', description: 'Resultaten bewaren en delen' },
{ key: 'system', label: 'Systeem', description: 'Bronkoppelingen en operationele status' },
]
@@ -364,6 +364,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
+ detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
@@ -438,11 +439,14 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
+ segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
+ segmentationTotal,
+ segmentationTruncated,
segmentationGeoJson,
segmentationClassFilter,
segmentationMinConfidenceFilter,
@@ -986,7 +990,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
Tijdelijke demowerkruimte
- Alle analysemodellen en werkfuncties zijn beschikbaar. Beheer, instellingen en blijvende gegevenswijzigingen blijven afgeschermd.
+ De demo gebruikt dezelfde geconfigureerde analysemodellen en werkfuncties als een gebruiker. Beheer, instellingen en blijvende gegevenswijzigingen blijven afgeschermd.