diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7840ced1..ba48ce4f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,8 +20,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -70,15 +68,6 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: Deploy enterprise docs gate - run: bash deploy.sh - working-directory: docs-fumadocs/lambda-edge/enterprise-auth - env: - AWS_REGION: us-east-1 - DOCS_AUTH_LAMBDA_NAME: ${{ secrets.DOCS_AUTH_LAMBDA_NAME }} - DOCS_ENTERPRISE_PASSWORD: ${{ secrets.DOCS_ENTERPRISE_PASSWORD }} - DOCS_ENTERPRISE_COOKIE_SECRET: ${{ secrets.DOCS_ENTERPRISE_COOKIE_SECRET }} - - name: Deploy to S3 run: aws s3 sync ./out s3://${{ secrets.AWS_S3_BUCKET }} --delete diff --git a/.gitignore b/.gitignore index 71be2043..a502d377 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,8 @@ Thumbs.db node_modules/ frontend/node_modules/ frontend/dist/ +dist.root.bak/ +frontend/node_modules.root.bak/ .npm .yarn *.tsbuildinfo diff --git a/app/api/v1/api.py b/app/api/v1/api.py index 90fb2602..804ba6ce 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -40,6 +40,7 @@ call_import_evaluations, judge_alignment, workspaces, + workspace_iam, ) api_router = APIRouter() @@ -83,3 +84,4 @@ api_router.include_router(call_import_evaluations.router) api_router.include_router(judge_alignment.router) api_router.include_router(workspaces.router) +api_router.include_router(workspace_iam.router) diff --git a/app/api/v1/routes/agents.py b/app/api/v1/routes/agents.py index 9bcefbbc..812b4f93 100644 --- a/app/api/v1/routes/agents.py +++ b/app/api/v1/routes/agents.py @@ -652,3 +652,13 @@ async def get_agent_delete_impact( "can_delete_without_force": len(dependencies) == 0, } + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/call_import_evaluations.py b/app/api/v1/routes/call_import_evaluations.py index ce91d644..33562c96 100644 --- a/app/api/v1/routes/call_import_evaluations.py +++ b/app/api/v1/routes/call_import_evaluations.py @@ -23,6 +23,8 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified +from app.core.auth import Principal, get_principal +from app.core.auth.capabilities import REPORTS_GENERATE, capability_denied_message from app.database import get_db from app.dependencies import ( get_api_key, @@ -30,6 +32,7 @@ get_workspace_id, require_enterprise_feature, ) +from app.services.workspace_rbac import resolve_workspace_capabilities from app.models.database import ( AIProvider, CallImport, @@ -184,6 +187,36 @@ def _require_import( return call_import +def require_call_import_capability(capability: str): + """Ensure the caller has *capability* in the call import's workspace (not just the header).""" + + def _dep( + call_import_id: UUID, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), + ) -> CallImport: + call_import = _require_import(db, call_import_id, organization_id) + caps, _, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=call_import.workspace_id, + organization_id=organization_id, + ) + if capability not in caps: + raise HTTPException( + status_code=403, + detail=capability_denied_message( + capability, + role_name=role.name if role else None, + workspace_label="the active workspace", + ), + ) + return call_import + + return _dep + + def _flatten_transcript(text: Optional[str]) -> str: """Collapse a multi-line transcript onto a single line for spreadsheet export. @@ -432,6 +465,9 @@ def _serialize_eval( llm_provider=row.llm_provider, llm_model=row.llm_model, llm_credential_id=row.llm_credential_id, + llm_config=( + row.llm_config if isinstance(getattr(row, "llm_config", None), dict) else None + ), metric_llm_overrides=( row.metric_llm_overrides if isinstance(row.metric_llm_overrides, dict) @@ -717,6 +753,8 @@ async def create_call_import_evaluation( ) if override.credential_id is not None: override_dict["credential_id"] = str(override.credential_id) + if override.llm_config is not None: + override_dict["llm_config"] = override.llm_config if override_dict: for leaf_id in target_leaf_ids: metric_overrides_payload[leaf_id] = override_dict @@ -887,6 +925,7 @@ def _name_for_source(source: str) -> Optional[str]: llm_provider=llm_provider_norm, llm_model=llm_model_norm, llm_credential_id=payload.llm_credential_id, + llm_config=payload.llm_config, metric_llm_overrides=metric_overrides_payload, stt_provider=stt_provider_norm, stt_model=stt_model_norm, @@ -3214,6 +3253,7 @@ async def list_call_import_evaluation_baseline_candidates( @router.post( "/{eval_id}/pdf-report", operation_id="generateCallImportEvaluationPdfReport", + dependencies=[Depends(require_call_import_capability(REPORTS_GENERATE))], ) async def generate_call_import_evaluation_pdf_report( call_import_id: UUID, @@ -7984,6 +8024,9 @@ def _apply_retry_overrides( ) evaluation.llm_credential_id = payload.llm_credential_id + if payload.llm_config is not None: + evaluation.llm_config = payload.llm_config + # --- Per-metric LLM overrides --- # We accept the same dict shape as the create endpoint but # constrain keys to leaf metrics that are actually in this run. @@ -8036,6 +8079,8 @@ def _apply_retry_overrides( ) if override.credential_id is not None: override_dict["credential_id"] = str(override.credential_id) + if override.llm_config is not None: + override_dict["llm_config"] = override.llm_config if override_dict: overrides_payload[metric_id] = override_dict evaluation.metric_llm_overrides = overrides_payload or None @@ -8583,3 +8628,15 @@ async def retry_call_import_evaluation_row( created_at=eval_row.created_at, updated_at=eval_row.updated_at, ) + + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW, REPORTS_GENERATE +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, + report_capability=REPORTS_GENERATE, +) diff --git a/app/api/v1/routes/call_import_schemas.py b/app/api/v1/routes/call_import_schemas.py index 4f4fba7b..86b414b8 100644 --- a/app/api/v1/routes/call_import_schemas.py +++ b/app/api/v1/routes/call_import_schemas.py @@ -396,3 +396,13 @@ async def delete_call_import_schema( db.delete(schema) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) + + +from app.core.auth.capabilities import CALLS_IMPORT, CALLS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=CALLS_VIEW, + manage_capability=CALLS_IMPORT, +) diff --git a/app/api/v1/routes/call_imports.py b/app/api/v1/routes/call_imports.py index 947e7538..8be373a8 100644 --- a/app/api/v1/routes/call_imports.py +++ b/app/api/v1/routes/call_imports.py @@ -3746,3 +3746,14 @@ async def get_call_import_insights( evaluation_count=len(evaluations), metrics=metrics_payload, ) + + +from app.core.auth.capabilities import CALLS_DELETE, CALLS_IMPORT, CALLS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=CALLS_VIEW, + manage_capability=CALLS_IMPORT, + delete_capability=CALLS_DELETE, +) diff --git a/app/api/v1/routes/chat.py b/app/api/v1/routes/chat.py index 31b6184a..b25d731e 100644 --- a/app/api/v1/routes/chat.py +++ b/app/api/v1/routes/chat.py @@ -4,7 +4,7 @@ """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Any from uuid import UUID from pydantic import BaseModel @@ -26,6 +26,7 @@ class ChatRequest(BaseModel): model: str temperature: Optional[float] = 0.7 max_tokens: Optional[int] = None + llm_config: Optional[Dict[str, Any]] = None class ChatResponse(BaseModel): @@ -52,8 +53,10 @@ async def chat_completion( llm_model=request.model, organization_id=organization_id, db=db, + llm_config=request.llm_config, temperature=request.temperature, - max_tokens=request.max_tokens + max_tokens=request.max_tokens, + task_defaults={"temperature": 0.7}, ) return ChatResponse( diff --git a/app/api/v1/routes/data_sources.py b/app/api/v1/routes/data_sources.py index 4f7599a5..ce96497a 100644 --- a/app/api/v1/routes/data_sources.py +++ b/app/api/v1/routes/data_sources.py @@ -8,6 +8,7 @@ from app.dependencies import get_api_key, get_organization_id from app.models.schemas import MessageResponse, S3ListFilesResponse, S3FileInfo, S3BrowseResponse, S3FolderInfo +from app.services.storage.blob_paths import assert_key_belongs_to_org from app.services.storage.s3_service import s3_service from app.core.exceptions import StorageError from uuid import UUID @@ -206,7 +207,11 @@ async def upload_to_s3( ) @router.get("/files/{file_key:path}/download", operation_id="downloadFromS3") -async def download_from_s3(file_key: str, api_key: str = Depends(get_api_key)): +async def download_from_s3( + file_key: str, + api_key: str = Depends(get_api_key), + organization_id: UUID = Depends(get_organization_id), +): """Download a file from the S3 bucket.""" if not s3_service.is_enabled(): raise HTTPException( @@ -215,13 +220,20 @@ async def download_from_s3(file_key: str, api_key: str = Depends(get_api_key)): ) try: - file_bytes = s3_service.download_file_by_key(file_key) - filename = file_key.split("/")[-1] + validated_key = assert_key_belongs_to_org( + file_key, + organization_id, + storage_prefix=s3_service.prefix, + ) + file_bytes = s3_service.download_file_by_key(validated_key) + filename = validated_key.split("/")[-1] return StreamingResponse( io.BytesIO(file_bytes), media_type="application/octet-stream", headers={"Content-Disposition": f"attachment; filename=\"{filename}\""} ) + except HTTPException: + raise except StorageError as e: if "not found" in str(e).lower(): raise HTTPException( @@ -250,6 +262,7 @@ async def get_s3_presigned_url( file_key: str, expiration: int = 3600, api_key: str = Depends(get_api_key), + organization_id: UUID = Depends(get_organization_id), ): """Get presigned URL for S3 file playback/access.""" if not s3_service.is_enabled(): @@ -259,10 +272,16 @@ async def get_s3_presigned_url( ) try: - import urllib.parse - decoded_key = urllib.parse.unquote(file_key) - url = s3_service.generate_presigned_url_by_key(decoded_key, expiration=expiration) + validated_key = assert_key_belongs_to_org( + file_key, + organization_id, + storage_prefix=s3_service.prefix, + decode=True, + ) + url = s3_service.generate_presigned_url_by_key(validated_key, expiration=expiration) return PresignedUrlResponse(url=url, expires_in=expiration) + except HTTPException: + raise except StorageError as e: if "not found" in str(e).lower(): raise HTTPException( @@ -281,7 +300,11 @@ async def get_s3_presigned_url( @router.delete("/files/{file_key:path}", response_model=MessageResponse, operation_id="deleteFromS3") -async def delete_from_s3(file_key: str, api_key: str = Depends(get_api_key)): +async def delete_from_s3( + file_key: str, + api_key: str = Depends(get_api_key), + organization_id: UUID = Depends(get_organization_id), +): """Delete a file from the S3 bucket.""" if not s3_service.is_enabled(): raise HTTPException( @@ -290,8 +313,15 @@ async def delete_from_s3(file_key: str, api_key: str = Depends(get_api_key)): ) try: - s3_service.delete_file_by_key(file_key) + validated_key = assert_key_belongs_to_org( + file_key, + organization_id, + storage_prefix=s3_service.prefix, + ) + s3_service.delete_file_by_key(validated_key) return {"message": "File deleted successfully."} + except HTTPException: + raise except StorageError as e: if "not found" in str(e).lower(): raise HTTPException( diff --git a/app/api/v1/routes/evaluations.py b/app/api/v1/routes/evaluations.py index a45ee937..840d3803 100644 --- a/app/api/v1/routes/evaluations.py +++ b/app/api/v1/routes/evaluations.py @@ -172,3 +172,14 @@ def delete_evaluation( return {"message": "Evaluation deleted successfully"} + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, +) + diff --git a/app/api/v1/routes/evaluator_results.py b/app/api/v1/routes/evaluator_results.py index 1c69cd64..c61c8480 100644 --- a/app/api/v1/routes/evaluator_results.py +++ b/app/api/v1/routes/evaluator_results.py @@ -745,3 +745,14 @@ def re_evaluate_result( db.refresh(result) return result + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, +) + diff --git a/app/api/v1/routes/evaluators.py b/app/api/v1/routes/evaluators.py index 2dfa0b11..610ba16d 100644 --- a/app/api/v1/routes/evaluators.py +++ b/app/api/v1/routes/evaluators.py @@ -228,6 +228,7 @@ def create_evaluator( metric_ids=validated_metric_ids if (is_custom and validated_metric_ids) else None, llm_provider=evaluator_data.llm_provider.value if evaluator_data.llm_provider else None, llm_model=evaluator_data.llm_model, + llm_config=evaluator_data.llm_config, tags=evaluator_data.tags, ) db.add(evaluator) @@ -478,6 +479,9 @@ def update_evaluator( if evaluator_data.llm_model is not None: evaluator.llm_model = evaluator_data.llm_model + if evaluator_data.llm_config is not None: + evaluator.llm_config = evaluator_data.llm_config + db.commit() db.refresh(evaluator) @@ -651,3 +655,13 @@ def run_evaluators( evaluator_results=evaluator_results ) + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/judge_alignment.py b/app/api/v1/routes/judge_alignment.py index 55f32d09..9fab1ee6 100644 --- a/app/api/v1/routes/judge_alignment.py +++ b/app/api/v1/routes/judge_alignment.py @@ -914,3 +914,14 @@ def recompute_metrics( db.commit() db.refresh(run) return run + + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, +) diff --git a/app/api/v1/routes/manual_evaluations.py b/app/api/v1/routes/manual_evaluations.py index 4811114e..e88be1e6 100644 --- a/app/api/v1/routes/manual_evaluations.py +++ b/app/api/v1/routes/manual_evaluations.py @@ -11,6 +11,7 @@ from app.models.database import ManualTranscription, ModelProvider, AudioFile from app.models.schemas import MessageResponse, S3ListFilesResponse, S3FileInfo from app.services.ai.transcription_service import transcription_service +from app.services.storage.blob_paths import assert_key_belongs_to_org from app.services.storage.s3_service import s3_service router = APIRouter(prefix="/manual-evaluations", tags=["Manual Evaluations"]) @@ -124,14 +125,13 @@ async def get_presigned_url( ) try: - from urllib.parse import unquote - decoded_key = unquote(file_key) - - # Verify file exists in S3 (we skip DB check since we are listing from S3 directly) - # In a stricter environment, we might want to verify ownership if files are namespaced by org - - # Generate presigned URL - url = s3_service.generate_presigned_url_by_key(decoded_key, expiration=expiration) + validated_key = assert_key_belongs_to_org( + file_key, + organization_id, + storage_prefix=s3_service.prefix, + decode=True, + ) + url = s3_service.generate_presigned_url_by_key(validated_key, expiration=expiration) return PresignedUrlResponse(url=url, expires_in=expiration) except HTTPException: diff --git a/app/api/v1/routes/metrics.py b/app/api/v1/routes/metrics.py index f4bcc2c3..7d43248c 100644 --- a/app/api/v1/routes/metrics.py +++ b/app/api/v1/routes/metrics.py @@ -2197,3 +2197,13 @@ def parse_bulk_metric( return MetricParseBulkResponse(metrics=drafts, parent=parent_payload) + +from app.core.auth.capabilities import METRICS_MANAGE, METRICS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=METRICS_VIEW, + manage_capability=METRICS_MANAGE, +) + diff --git a/app/api/v1/routes/observability.py b/app/api/v1/routes/observability.py index 52c60dd9..61770404 100644 --- a/app/api/v1/routes/observability.py +++ b/app/api/v1/routes/observability.py @@ -582,3 +582,14 @@ async def evaluate_call( "message": "Evaluation queued successfully", } + +from app.core.auth.capabilities import REPORTS_GENERATE, REPORTS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=REPORTS_VIEW, + manage_capability=REPORTS_GENERATE, + run_capability=REPORTS_GENERATE, +) + diff --git a/app/api/v1/routes/personas.py b/app/api/v1/routes/personas.py index 379d8470..1472b3c6 100644 --- a/app/api/v1/routes/personas.py +++ b/app/api/v1/routes/personas.py @@ -841,3 +841,13 @@ async def seed_demo_data( detail=f"Unexpected error seeding demo data: {str(e)}" ) + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/playground.py b/app/api/v1/routes/playground.py index 421c8aeb..02651ab1 100644 --- a/app/api/v1/routes/playground.py +++ b/app/api/v1/routes/playground.py @@ -1978,3 +1978,13 @@ async def summarize_transcript( "usage": result.get("usage", {}), } + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/prompt_optimization.py b/app/api/v1/routes/prompt_optimization.py index 19033e40..f0805797 100644 --- a/app/api/v1/routes/prompt_optimization.py +++ b/app/api/v1/routes/prompt_optimization.py @@ -370,3 +370,13 @@ def _get_candidate( raise HTTPException(404, "Candidate not found") return candidate + + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) diff --git a/app/api/v1/routes/prompt_partials.py b/app/api/v1/routes/prompt_partials.py index 138f8e95..f1672597 100644 --- a/app/api/v1/routes/prompt_partials.py +++ b/app/api/v1/routes/prompt_partials.py @@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse, Response from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from typing import List, Optional +from typing import List, Optional, Dict, Any from uuid import UUID from pydantic import BaseModel from loguru import logger @@ -40,6 +40,7 @@ class GeneratePromptRequest(BaseModel): format_style: Optional[str] = "structured" provider: Optional[str] = None model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None class ImprovePromptRequest(BaseModel): @@ -47,6 +48,7 @@ class ImprovePromptRequest(BaseModel): instructions: Optional[str] = None provider: Optional[str] = None model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None class GenerateFlowchartRequest(BaseModel): @@ -213,8 +215,8 @@ async def generate_prompt_with_ai( llm_model=model_str, organization_id=organization_id, db=db, - temperature=0.7, - max_tokens=4000, + llm_config=data.llm_config, + task_defaults={"temperature": 0.7, "max_tokens": 4000}, ) return {"content": result["text"], "provider": provider_enum.value, "model": model_str} except Exception as e: @@ -255,8 +257,8 @@ async def improve_prompt_with_ai( llm_model=model_str, organization_id=organization_id, db=db, - temperature=0.3, - max_tokens=4000, + llm_config=data.llm_config, + task_defaults={"temperature": 0.3, "max_tokens": 4000}, ) return {"content": result["text"], "provider": provider_enum.value, "model": model_str} except Exception as e: @@ -838,3 +840,13 @@ async def map_prompt_partial_flowchart_nodes( ) db.refresh(partial) return _agent_flowchart_response(partial) + + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) diff --git a/app/api/v1/routes/results.py b/app/api/v1/routes/results.py index 345858bb..1b58d44a 100644 --- a/app/api/v1/routes/results.py +++ b/app/api/v1/routes/results.py @@ -200,3 +200,13 @@ def compare_evaluations( comparison_metrics=comparison_metrics, ) + +from app.core.auth.capabilities import EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_VIEW, +) + diff --git a/app/api/v1/routes/scenarios.py b/app/api/v1/routes/scenarios.py index 428b7a97..80cd9080 100644 --- a/app/api/v1/routes/scenarios.py +++ b/app/api/v1/routes/scenarios.py @@ -206,3 +206,13 @@ async def delete_scenario( return Response(status_code=204) + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/settings.py b/app/api/v1/routes/settings.py index 095e67ff..a9a5900d 100644 --- a/app/api/v1/routes/settings.py +++ b/app/api/v1/routes/settings.py @@ -14,7 +14,14 @@ import secrets from pydantic import BaseModel -from app.dependencies import get_db, get_api_key, get_organization_id, get_workspace_id +from app.dependencies import ( + get_db, + get_api_key, + get_organization_id, + get_workspace_id, + require_capability, +) +from app.core.auth.capabilities import REPORTS_VIEW, WORKSPACE_SETTINGS from app.models.database import APIKey, User, Organization, Workspace from app.models.schemas import MessageResponse from app.api.v1.routes.profile import get_current_user @@ -130,7 +137,11 @@ def _report_branding_response(workspace: Workspace) -> ReportBrandingResponse: ) -@router.get("/report-branding", response_model=ReportBrandingResponse) +@router.get( + "/report-branding", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(REPORTS_VIEW))], +) def get_report_branding( api_key: str = Depends(get_api_key), organization_id: UUID = Depends(get_organization_id), @@ -151,7 +162,11 @@ def get_report_branding( return _report_branding_response(workspace) -@router.patch("/report-branding", response_model=ReportBrandingResponse) +@router.patch( + "/report-branding", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(WORKSPACE_SETTINGS))], +) def update_report_branding( payload: ReportBrandingUpdateRequest, api_key: str = Depends(get_api_key), @@ -181,7 +196,11 @@ def update_report_branding( return _report_branding_response(workspace) -@router.post("/report-branding/images", response_model=ReportBrandingResponse) +@router.post( + "/report-branding/images", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(WORKSPACE_SETTINGS))], +) async def upload_report_branding_images( files: List[UploadFile] = File(...), role: Literal["internal", "external", "generic"] = Form("generic"), @@ -265,7 +284,11 @@ async def upload_report_branding_images( return _report_branding_response(workspace) -@router.delete("/report-branding/images/{image_id}", response_model=ReportBrandingResponse) +@router.delete( + "/report-branding/images/{image_id}", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(WORKSPACE_SETTINGS))], +) def delete_report_branding_image( image_id: str, api_key: str = Depends(get_api_key), diff --git a/app/api/v1/routes/telephony.py b/app/api/v1/routes/telephony.py index 50536b5b..6805e13e 100644 --- a/app/api/v1/routes/telephony.py +++ b/app/api/v1/routes/telephony.py @@ -23,6 +23,7 @@ TelephonyVerifyStartResponse, ) from app.services.telephony.telephony_service import telephony_service +from app.services.telephony.webhook_auth import verify_plivo_webhook router = APIRouter(prefix="/telephony", tags=["Telephony"]) @@ -334,6 +335,7 @@ async def _read_webhook_params(request: Request) -> Dict[str, Any]: @router.post("/webhooks/answer") async def telephony_answer_webhook(request: Request, db: Session = Depends(get_db)): params = await _read_webhook_params(request) + verify_plivo_webhook(request, params, "answer", db) xml = telephony_service.handle_answer_webhook(params, db) return Response(content=xml, media_type="application/xml") @@ -341,6 +343,7 @@ async def telephony_answer_webhook(request: Request, db: Session = Depends(get_d @router.post("/webhooks/events") async def telephony_events_webhook(request: Request, db: Session = Depends(get_db)): params = await _read_webhook_params(request) + verify_plivo_webhook(request, params, "events", db) telephony_service.handle_event_webhook(params, db) return {"status": "ok"} @@ -348,5 +351,6 @@ async def telephony_events_webhook(request: Request, db: Session = Depends(get_d @router.post("/webhooks/masking") async def telephony_masking_webhook(request: Request, db: Session = Depends(get_db)): params = await _read_webhook_params(request) + verify_plivo_webhook(request, params, "masking", db) xml = telephony_service.handle_masking_webhook(params, db) return Response(content=xml, media_type="application/xml") diff --git a/app/api/v1/routes/test_agents.py b/app/api/v1/routes/test_agents.py index 28e1420c..0c402156 100644 --- a/app/api/v1/routes/test_agents.py +++ b/app/api/v1/routes/test_agents.py @@ -274,3 +274,13 @@ async def delete_conversation( db.commit() return None + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/voice_playground.py b/app/api/v1/routes/voice_playground.py index 57ec9ba6..3213e6aa 100644 --- a/app/api/v1/routes/voice_playground.py +++ b/app/api/v1/routes/voice_playground.py @@ -287,6 +287,7 @@ class GenerateSamplesRequest(BaseModel): count: int = 5 length: Optional[str] = "short" # "short" | "medium" | "long" | "paragraph" temperature: Optional[float] = 0.8 + llm_config: Optional[Dict[str, Any]] = None class CustomVoiceCreate(BaseModel): @@ -400,7 +401,7 @@ async def generate_sample_texts( llm_provider_str = data.provider llm_model_str = data.model - temperature = data.temperature or 0.8 + request_llm_config = data.llm_config if data.voice_bundle_id: bundle = db.query(VoiceBundle).filter( @@ -413,8 +414,20 @@ async def generate_sample_texts( raise HTTPException(400, "Selected voice bundle has no LLM configured") llm_provider_str = bundle.llm_provider llm_model_str = bundle.llm_model - if bundle.llm_temperature is not None: - temperature = bundle.llm_temperature + from app.services.ai.llm_generation_config import merge_llm_config + + request_llm_config = merge_llm_config( + request_llm_config or bundle.llm_config, + legacy_temperature=bundle.llm_temperature, + legacy_max_tokens=bundle.llm_max_tokens, + ) + if data.temperature is not None and not (request_llm_config or {}).get("temperature"): + request_llm_config = {**(request_llm_config or {}), "temperature": data.temperature} + elif data.temperature is not None: + request_llm_config = { + **(request_llm_config or {}), + "temperature": data.temperature or 0.8, + } if not llm_provider_str or not llm_model_str: raise HTTPException(400, "Either voice_bundle_id or both provider and model are required") @@ -449,8 +462,8 @@ async def generate_sample_texts( llm_model=llm_model_str, organization_id=organization_id, db=db, - temperature=temperature, - max_tokens=max_tokens, + llm_config=request_llm_config, + task_defaults={"temperature": 0.8, "max_tokens": max_tokens}, ) except Exception as e: logger.error(f"[VoicePlayground] LLM generation failed: {e}") @@ -2470,3 +2483,14 @@ def _recompute_summary(comparison: TTSComparison, db: Session): comparison.evaluation_summary = summary db.commit() + + +from app.core.auth.capabilities import REPORTS_GENERATE, REPORTS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=REPORTS_VIEW, + manage_capability=REPORTS_GENERATE, + run_capability=REPORTS_GENERATE, +) diff --git a/app/api/v1/routes/workspace_iam.py b/app/api/v1/routes/workspace_iam.py new file mode 100644 index 00000000..c381f120 --- /dev/null +++ b/app/api/v1/routes/workspace_iam.py @@ -0,0 +1,454 @@ +"""Workspace IAM: membership and role management.""" + +from __future__ import annotations + +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.auth import Principal, get_principal +from app.core.auth.capabilities import ( + WORKSPACE_MEMBERS_MANAGE, + WORKSPACE_MEMBERS_VIEW, + capability_denied_message, + capabilities_for_registry, + normalize_capabilities, +) +from app.core.auth.rbac import require_admin +from app.database import get_db +from app.dependencies import get_organization_id +from app.models.database import OrganizationMember, User, Workspace, WorkspaceMember, WorkspaceRole +from app.models.schemas import ( + CapabilityDomainResponse, + WorkspaceMemberCreate, + WorkspaceMemberResponse, + WorkspaceMemberUpdate, + WorkspaceRoleCreate, + WorkspaceRoleResponse, + WorkspaceRoleUpdate, +) +from app.services.workspace_rbac import ( + add_workspace_member, + count_workspace_admins, + is_workspace_admin_role, + resolve_workspace_capabilities, + seed_system_workspace_roles, +) + + +router = APIRouter(tags=["Workspace IAM"]) + + +def _require_workspace_in_org( + db: Session, + *, + organization_id: UUID, + workspace_id: UUID, +) -> Workspace: + workspace = ( + db.query(Workspace) + .filter( + Workspace.id == workspace_id, + Workspace.organization_id == organization_id, + ) + .first() + ) + if workspace is None: + raise HTTPException(status_code=404, detail="Workspace not found.") + return workspace + + +def _require_workspace_capability( + db: Session, + *, + principal: Principal, + organization_id: UUID, + workspace_id: UUID, + capability: str, +) -> None: + _require_workspace_in_org( + db, + organization_id=organization_id, + workspace_id=workspace_id, + ) + caps, _, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace_id, + organization_id=organization_id, + ) + if capability not in caps: + raise HTTPException( + status_code=403, + detail=capability_denied_message( + capability, + role_name=role.name if role else None, + ), + ) + + +def _member_response(db: Session, member: WorkspaceMember) -> WorkspaceMemberResponse: + user = db.query(User).filter(User.id == member.user_id).first() + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + return WorkspaceMemberResponse( + id=member.id, + workspace_id=member.workspace_id, + user_id=member.user_id, + role_id=member.role_id, + role_name=role.name if role else "Unknown", + user_email=user.email if user else "", + user_name=user.name if user else None, + added_by_user_id=member.added_by_user_id, + created_at=member.created_at, + ) + + +@router.get("/capabilities", response_model=List[CapabilityDomainResponse]) +def list_capabilities( + organization_id: UUID = Depends(get_organization_id), +): + """Return the capability registry for the role-builder UI.""" + del organization_id # auth gate only; registry is static + return capabilities_for_registry() + + +@router.get("/workspace-roles", response_model=List[WorkspaceRoleResponse]) +def list_workspace_roles( + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + roles = ( + db.query(WorkspaceRole) + .filter(WorkspaceRole.organization_id == organization_id) + .order_by(WorkspaceRole.is_system.desc(), WorkspaceRole.name.asc()) + .all() + ) + return roles + + +@router.post( + "/workspace-roles", + response_model=WorkspaceRoleResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_admin)], +) +def create_workspace_role( + payload: WorkspaceRoleCreate, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + caps = sorted(normalize_capabilities(payload.capabilities)) + if not caps: + raise HTTPException(status_code=400, detail="At least one valid capability is required.") + + existing = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.organization_id == organization_id, + WorkspaceRole.name == payload.name.strip(), + ) + .first() + ) + if existing: + raise HTTPException(status_code=409, detail="A role with this name already exists.") + + role = WorkspaceRole( + organization_id=organization_id, + name=payload.name.strip(), + description=payload.description, + capabilities=caps, + is_system=False, + ) + db.add(role) + db.commit() + db.refresh(role) + return role + + +@router.patch( + "/workspace-roles/{role_id}", + response_model=WorkspaceRoleResponse, + dependencies=[Depends(require_admin)], +) +def update_workspace_role( + role_id: UUID, + payload: WorkspaceRoleUpdate, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if role is None: + raise HTTPException(status_code=404, detail="Role not found.") + if role.is_system: + raise HTTPException(status_code=400, detail="System roles cannot be modified.") + + if payload.name is not None: + new_name = payload.name.strip() + conflict = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.organization_id == organization_id, + WorkspaceRole.name == new_name, + WorkspaceRole.id != role_id, + ) + .first() + ) + if conflict: + raise HTTPException(status_code=409, detail="A role with this name already exists.") + role.name = new_name + if payload.description is not None: + role.description = payload.description + if payload.capabilities is not None: + caps = sorted(normalize_capabilities(payload.capabilities)) + if not caps: + raise HTTPException(status_code=400, detail="At least one valid capability is required.") + role.capabilities = caps + + db.commit() + db.refresh(role) + return role + + +@router.delete( + "/workspace-roles/{role_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)], +) +def delete_workspace_role( + role_id: UUID, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if role is None: + raise HTTPException(status_code=404, detail="Role not found.") + if role.is_system: + raise HTTPException(status_code=400, detail="System roles cannot be deleted.") + + assigned = ( + db.query(WorkspaceMember) + .filter(WorkspaceMember.role_id == role_id) + .count() + ) + if assigned: + raise HTTPException( + status_code=409, + detail="Role is assigned to workspace members and cannot be deleted.", + ) + + db.delete(role) + db.commit() + return None + + +@router.get("/workspaces/{workspace_id}/members", response_model=List[WorkspaceMemberResponse]) +def list_workspace_members( + workspace_id: UUID, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_VIEW, + ) + members = ( + db.query(WorkspaceMember) + .filter(WorkspaceMember.workspace_id == workspace_id) + .order_by(WorkspaceMember.created_at.asc()) + .all() + ) + return [_member_response(db, member) for member in members] + + +@router.post( + "/workspaces/{workspace_id}/members", + response_model=WorkspaceMemberResponse, + status_code=status.HTTP_201_CREATED, +) +def add_workspace_member_route( + workspace_id: UUID, + payload: WorkspaceMemberCreate, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_MANAGE, + ) + + org_member = ( + db.query(OrganizationMember) + .filter( + OrganizationMember.organization_id == organization_id, + OrganizationMember.user_id == payload.user_id, + ) + .first() + ) + if org_member is None: + raise HTTPException(status_code=400, detail="User is not a member of this organization.") + + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == payload.role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if role is None: + raise HTTPException(status_code=404, detail="Role not found.") + + member = add_workspace_member( + db, + workspace_id=workspace_id, + user_id=payload.user_id, + role_id=payload.role_id, + added_by_user_id=principal.user_id, + ) + db.commit() + db.refresh(member) + return _member_response(db, member) + + +@router.patch( + "/workspaces/{workspace_id}/members/{user_id}", + response_model=WorkspaceMemberResponse, +) +def update_workspace_member_route( + workspace_id: UUID, + user_id: UUID, + payload: WorkspaceMemberUpdate, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_MANAGE, + ) + + member = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == user_id, + ) + .first() + ) + if member is None: + raise HTTPException(status_code=404, detail="Workspace member not found.") + + new_role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == payload.role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if new_role is None: + raise HTTPException(status_code=404, detail="Role not found.") + + old_role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + is_self = principal.user_id == user_id + if ( + is_self + and is_workspace_admin_role(old_role) + and not is_workspace_admin_role(new_role) + ): + raise HTTPException( + status_code=403, + detail=( + "You cannot demote your own Workspace Admin role. " + "Ask another admin to change your role." + ), + ) + if is_workspace_admin_role(old_role) and not is_workspace_admin_role(new_role): + if count_workspace_admins(db, workspace_id=workspace_id) <= 1: + raise HTTPException( + status_code=409, + detail="Cannot demote the last Workspace Admin of this workspace.", + ) + + member.role_id = payload.role_id + db.commit() + db.refresh(member) + return _member_response(db, member) + + +@router.delete( + "/workspaces/{workspace_id}/members/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def remove_workspace_member_route( + workspace_id: UUID, + user_id: UUID, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + _require_workspace_in_org( + db, + organization_id=organization_id, + workspace_id=workspace_id, + ) + + is_self = principal.user_id == user_id + if not is_self: + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_MANAGE, + ) + + member = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == user_id, + ) + .first() + ) + if member is None: + raise HTTPException(status_code=404, detail="Workspace member not found.") + + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + if is_workspace_admin_role(role) and count_workspace_admins(db, workspace_id=workspace_id) <= 1: + raise HTTPException( + status_code=409, + detail="Cannot remove the last Workspace Admin of this workspace.", + ) + + db.delete(member) + db.commit() + return None diff --git a/app/api/v1/routes/workspaces.py b/app/api/v1/routes/workspaces.py index c86945d7..5f7a4a8d 100644 --- a/app/api/v1/routes/workspaces.py +++ b/app/api/v1/routes/workspaces.py @@ -1,12 +1,4 @@ -"""Workspace management routes. - -Workspaces are the in-org isolation boundary picked up by the -``X-Workspace-Id`` header (see ``app.dependencies.get_workspace_id``). -Members can list, create, rename and delete workspaces within their -own organization; the Default workspace can never be deleted because -the rest of the system uses it as the fallback when a request arrives -without an explicit workspace header. -""" +"""Workspace management routes with capability-based access control.""" from __future__ import annotations @@ -18,63 +10,98 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core.auth import Principal, get_principal +from app.core.auth.capabilities import WORKSPACE_SETTINGS, capability_denied_message +from app.core.auth.rbac import get_org_role, require_admin, require_writer from app.database import get_db from app.dependencies import get_organization_id -from app.models.database import Workspace +from app.models.database import RoleEnum, Workspace, WorkspaceMember, WorkspaceRole from app.models.schemas import ( WorkspaceCreate, WorkspaceResponse, WorkspaceUpdate, ) +from app.services.workspace_rbac import ( + ensure_creator_workspace_admin, + resolve_workspace_capabilities, + seed_system_workspace_roles, +) router = APIRouter(prefix="/workspaces", tags=["Workspaces"]) - -# Slugs use underscores so they round-trip cleanly through the UI and URL -# path (no percent-encoded hyphens, no collisions with our default -# ``"default"`` slug). _SLUG_PATTERN = re.compile(r"[^a-z0-9_]+") def _slugify(value: str) -> str: - """Best-effort slug derivation from a display name. - - Lower-cases, replaces runs of non-alphanumerics with underscores, - and trims leading/trailing underscores. Empty inputs collapse to - ``"workspace"`` so we never persist an empty slug. - """ cleaned = _SLUG_PATTERN.sub("_", value.strip().lower()).strip("_") return cleaned or "workspace" +def _workspace_response( + db: Session, + *, + workspace: Workspace, + principal: Principal, +) -> WorkspaceResponse: + caps, _membership, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace.id, + organization_id=workspace.organization_id, + ) + return WorkspaceResponse( + id=workspace.id, + organization_id=workspace.organization_id, + name=workspace.name, + slug=workspace.slug, + is_default=workspace.is_default, + created_at=workspace.created_at, + updated_at=workspace.updated_at, + role_id=role.id if role else None, + role_name=role.name if role else ("Org Admin" if caps else None), + capabilities=sorted(caps), + ) + + @router.get("", response_model=List[WorkspaceResponse]) def list_workspaces( + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): - """List every workspace in the caller's organization.""" - workspaces = ( - db.query(Workspace) - .filter(Workspace.organization_id == organization_id) - # Default first, then alphabetical so the UI list reads naturally. - .order_by(Workspace.is_default.desc(), Workspace.name.asc()) - .all() - ) - return workspaces + """List workspaces the caller can access.""" + org_role = get_org_role(principal, db) + query = db.query(Workspace).filter(Workspace.organization_id == organization_id) + + if org_role != RoleEnum.ADMIN and principal.user_id is not None: + member_ws_ids = [ + row[0] + for row in db.query(WorkspaceMember.workspace_id) + .filter(WorkspaceMember.user_id == principal.user_id) + .all() + ] + if not member_ws_ids: + return [] + query = query.filter(Workspace.id.in_(member_ws_ids)) + + workspaces = query.order_by(Workspace.is_default.desc(), Workspace.name.asc()).all() + return [_workspace_response(db, workspace=ws, principal=principal) for ws in workspaces] @router.post( "", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_writer)], ) def create_workspace( payload: WorkspaceCreate, + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): - """Create a new (non-default) workspace in the caller's org.""" + """Create a new (non-default) workspace; creator becomes Workspace Admin.""" slug = (payload.slug or _slugify(payload.name)).strip().lower() if not slug: raise HTTPException( @@ -82,10 +109,6 @@ def create_workspace( detail="Workspace slug cannot be empty.", ) - # Pre-check the slug so we can return a clean 409 even on backends - # whose IntegrityError messages don't include the constraint name - # (e.g. SQLite in tests). We still rely on the unique index for - # the race-condition safety net below. existing = ( db.query(Workspace) .filter( @@ -97,20 +120,26 @@ def create_workspace( if existing is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=( - f"A workspace with slug '{slug}' already exists in " - "this organization." - ), + detail=f"A workspace with slug '{slug}' already exists in this organization.", ) + seed_system_workspace_roles(db, organization_id=organization_id) + workspace = Workspace( organization_id=organization_id, name=payload.name.strip(), slug=slug, is_default=False, + created_by_user_id=principal.user_id, ) db.add(workspace) try: + db.flush() + ensure_creator_workspace_admin( + db, + workspace=workspace, + user_id=principal.user_id, + ) db.commit() except IntegrityError as exc: db.rollback() @@ -118,23 +147,24 @@ def create_workspace( if "uq_workspaces_org_slug" in message or "unique" in message: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=( - f"A workspace with slug '{slug}' already exists in " - "this organization." - ), + detail=f"A workspace with slug '{slug}' already exists in this organization.", ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Could not create workspace.", ) db.refresh(workspace) - return workspace + return _workspace_response(db, workspace=workspace, principal=principal) -@router.patch("/{workspace_id}", response_model=WorkspaceResponse) +@router.patch( + "/{workspace_id}", + response_model=WorkspaceResponse, +) def update_workspace( workspace_id: UUID, payload: WorkspaceUpdate, + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): @@ -148,30 +178,40 @@ def update_workspace( .first() ) if workspace is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found.") + + caps, _, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace_id, + organization_id=organization_id, + ) + if WORKSPACE_SETTINGS not in caps: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Workspace not found.", + status_code=status.HTTP_403_FORBIDDEN, + detail=capability_denied_message( + WORKSPACE_SETTINGS, + role_name=role.name if role else None, + ), ) workspace.name = payload.name.strip() db.commit() db.refresh(workspace) - return workspace + return _workspace_response(db, workspace=workspace, principal=principal) -@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{workspace_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)], +) def delete_workspace( workspace_id: UUID, organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): - """Delete a non-default workspace. - - The Default workspace is the safety net for headerless requests and - legacy rows, so it can never be deleted. Workspaces that still own - resources will be rejected by the ``ON DELETE RESTRICT`` FKs - declared in migrations 033/034 and surface here as a 409. - """ + """Delete a non-default workspace (org admin only).""" workspace = ( db.query(Workspace) .filter( @@ -181,10 +221,7 @@ def delete_workspace( .first() ) if workspace is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Workspace not found.", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found.") if workspace.is_default: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -198,9 +235,6 @@ def delete_workspace( db.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=( - "Workspace still contains resources. Move or delete " - "them before removing the workspace." - ), + detail="Workspace still contains resources. Move or delete them before removing the workspace.", ) return None diff --git a/app/config.py b/app/config.py index 368518ba..db4d1767 100644 --- a/app/config.py +++ b/app/config.py @@ -118,6 +118,14 @@ class Settings(BaseSettings): PLIVO_VERIFY_APP_UUID: str = "" PLIVO_WEBHOOK_BASE_URL: str = "" + # Recording URL fetch safety (SSRF guards for CSV/direct-URL imports) + RECORDING_URL_ALLOWED_HOST_SUFFIXES: List[str] = [ + "exotel.com", + "plivo.com", + "amazonaws.com", + "cloudfront.net", + ] + # Judge Alignment (AlignEval-style hybrid integration). # Operator-only knobs. Per-org thresholds and judge model selection # live in the database / UI, not here. @@ -275,6 +283,28 @@ def __init__(self, **kwargs): self.CELERY_RESULT_BACKEND = self.REDIS_URL +def validate_auth_configuration() -> None: + """Fail fast when external OIDC is licensed and enabled but misconfigured.""" + from app.core.license import has_auth_feature + + providers = {p.strip().lower() for p in (settings.AUTH_PROVIDERS or [])} + if "external_oidc" not in providers: + return + # Listing external_oidc in providers alone does not activate SSO — the + # enterprise license must include oidc_sso (same gate as ExternalOIDCProvider). + if not has_auth_feature("oidc_sso"): + return + missing = [] + if not settings.AUTH_OIDC_ISSUER: + missing.append("AUTH_OIDC_ISSUER") + if not settings.AUTH_OIDC_AUDIENCE: + missing.append("AUTH_OIDC_AUDIENCE") + if missing: + raise RuntimeError( + f"external_oidc is enabled but required settings are missing: {', '.join(missing)}" + ) + + def load_config_from_file(config_path: str) -> None: """Load configuration from a YAML file and update global settings.""" import yaml diff --git a/app/config/models.json b/app/config/models.json index 63ae8203..1392b0b8 100644 --- a/app/config/models.json +++ b/app/config/models.json @@ -71,6 +71,16 @@ "provider": "openai", "model_type": "llm" }, + "gpt-5.5": { + "provider": "openai", + "model_type": "llm", + "description": "OpenAI flagship — complex reasoning, agentic coding, 1M context" + }, + "gpt-5.5-pro": { + "provider": "openai", + "model_type": "llm", + "description": "Higher-accuracy GPT-5.5 variant with parallel test-time compute" + }, "tts-1": { "provider": "openai", "model_type": "tts" @@ -131,6 +141,106 @@ "provider": "anthropic", "model_type": "llm" }, + "claude-opus-4-7": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Opus 4.7 — stronger coding, vision, and complex multi-step tasks" + }, + "claude-opus-4-8": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Opus 4.8 — most capable Opus-tier model; 1M context, adaptive thinking" + }, + "claude-fable-5": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Fable 5 — Anthropic's most capable widely released model for demanding agentic work" + }, + "claude-mythos-5": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Mythos 5 — Fable 5 capabilities; limited availability via Project Glasswing" + }, + "grok-4.3": { + "provider": "xai", + "model_type": "llm", + "description": "xAI flagship — low hallucination, agentic tool calling, 1M context" + }, + "grok-build-0.1": { + "provider": "xai", + "model_type": "llm", + "description": "xAI fast coding model trained for agentic coding (early access)" + }, + "grok-4.20-0309-reasoning": { + "provider": "xai", + "model_type": "llm", + "description": "Grok 4.20 reasoning snapshot (Mar 2026)" + }, + "grok-4.20-0309-non-reasoning": { + "provider": "xai", + "model_type": "llm", + "description": "Grok 4.20 non-reasoning snapshot (Mar 2026)" + }, + "grok-4.20-multi-agent-0309": { + "provider": "xai", + "model_type": "llm", + "description": "Grok 4.20 multi-agent snapshot (Mar 2026)" + }, + "deepseek-v4-pro": { + "provider": "fireworks", + "model_type": "llm", + "description": "DeepSeek V4 Pro — frontier MoE reasoning and coding (1M context)" + }, + "deepseek-v4-flash": { + "provider": "fireworks", + "model_type": "llm", + "description": "DeepSeek V4 Flash — fast extraction, classification, and search" + }, + "kimi-k2p6": { + "provider": "fireworks", + "model_type": "llm", + "description": "Kimi K2.6 — native multimodal agentic model for long-horizon coding" + }, + "kimi-k2p5": { + "provider": "fireworks", + "model_type": "llm", + "description": "Kimi K2.5 — unified vision/text agentic model with controllable reasoning" + }, + "glm-5p1": { + "provider": "fireworks", + "model_type": "llm", + "description": "GLM 5.1 — frontier open model for reasoning and agentic workflows" + }, + "minimax-m2p7": { + "provider": "fireworks", + "model_type": "llm", + "description": "MiniMax M2.7 — MoE model for complex agent harnesses and productivity tasks" + }, + "minimax-m2p5": { + "provider": "fireworks", + "model_type": "llm", + "description": "MiniMax M2.5 — fast coding and agentic tool use at low cost" + }, + "qwen3p6-plus": { + "provider": "fireworks", + "model_type": "llm", + "description": "Qwen 3.6 Plus — flagship multimodal model (Fireworks exclusive outside Alibaba)" + }, + "gpt-oss-120b": { + "provider": "fireworks", + "model_type": "llm", + "description": "OpenAI gpt-oss-120b — high-quality open-weight model for general reasoning" + }, + "gpt-oss-20b": { + "provider": "fireworks", + "model_type": "llm", + "description": "OpenAI gpt-oss-20b — fast open-weight model for chat and classification" + }, + "firefunction-v2": { + "provider": "fireworks", + "model_type": "llm", + "description": "FireFunction V2 — Fireworks function-calling optimized model" + }, "google-speech-v2": { "provider": "google", "model_type": "stt" diff --git a/app/core/auth/capabilities.py b/app/core/auth/capabilities.py new file mode 100644 index 00000000..d814863e --- /dev/null +++ b/app/core/auth/capabilities.py @@ -0,0 +1,176 @@ +""" +Workspace capability registry. + +Capabilities are fixed strings defined in code. Workspace roles (system or +custom) are bundles of these strings stored in the database. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, FrozenSet, List, Set + + +# --------------------------------------------------------------------------- +# Capability constants +# --------------------------------------------------------------------------- + +CALLS_VIEW = "calls.view" +CALLS_IMPORT = "calls.import" +CALLS_DELETE = "calls.delete" + +METRICS_VIEW = "metrics.view" +METRICS_MANAGE = "metrics.manage" + +EVALS_VIEW = "evals.view" +EVALS_RUN = "evals.run" + +SIM_VIEW = "sim.view" +SIM_MANAGE = "sim.manage" + +REPORTS_VIEW = "reports.view" +REPORTS_GENERATE = "reports.generate" + +WORKSPACE_SETTINGS = "workspace.settings" +WORKSPACE_MEMBERS_VIEW = "workspace.members.view" +WORKSPACE_MEMBERS_MANAGE = "workspace.members.manage" + + +ALL_CAPABILITIES: FrozenSet[str] = frozenset( + { + CALLS_VIEW, + CALLS_IMPORT, + CALLS_DELETE, + METRICS_VIEW, + METRICS_MANAGE, + EVALS_VIEW, + EVALS_RUN, + SIM_VIEW, + SIM_MANAGE, + REPORTS_VIEW, + REPORTS_GENERATE, + WORKSPACE_SETTINGS, + WORKSPACE_MEMBERS_VIEW, + WORKSPACE_MEMBERS_MANAGE, + } +) + +VIEW_CAPABILITIES: FrozenSet[str] = frozenset( + cap for cap in ALL_CAPABILITIES if cap.endswith(".view") +) + +EDITOR_EXTRA_CAPABILITIES: FrozenSet[str] = frozenset( + { + CALLS_IMPORT, + METRICS_MANAGE, + EVALS_RUN, + SIM_MANAGE, + REPORTS_GENERATE, + } +) + +ADMIN_EXTRA_CAPABILITIES: FrozenSet[str] = frozenset( + { + CALLS_DELETE, + WORKSPACE_SETTINGS, + WORKSPACE_MEMBERS_MANAGE, + } +) + +VIEWER_ROLE_CAPABILITIES: List[str] = sorted(VIEW_CAPABILITIES) +EDITOR_ROLE_CAPABILITIES: List[str] = sorted( + VIEW_CAPABILITIES | EDITOR_EXTRA_CAPABILITIES +) +WORKSPACE_ADMIN_ROLE_CAPABILITIES: List[str] = sorted(ALL_CAPABILITIES) + +SYSTEM_ROLE_VIEWER = "Viewer" +SYSTEM_ROLE_EDITOR = "Editor" +SYSTEM_ROLE_ADMIN = "Workspace Admin" + + +@dataclass(frozen=True) +class CapabilityDomain: + """Grouping for the role-builder UI.""" + + key: str + label: str + capabilities: tuple[str, ...] + + +CAPABILITY_DOMAINS: tuple[CapabilityDomain, ...] = ( + CapabilityDomain("calls", "Calls", (CALLS_VIEW, CALLS_IMPORT, CALLS_DELETE)), + CapabilityDomain("metrics", "Metrics", (METRICS_VIEW, METRICS_MANAGE)), + CapabilityDomain("evals", "Evaluations", (EVALS_VIEW, EVALS_RUN)), + CapabilityDomain("sim", "Simulation", (SIM_VIEW, SIM_MANAGE)), + CapabilityDomain("reports", "Reports", (REPORTS_VIEW, REPORTS_GENERATE)), + CapabilityDomain( + "workspace", + "Workspace", + (WORKSPACE_SETTINGS, WORKSPACE_MEMBERS_VIEW, WORKSPACE_MEMBERS_MANAGE), + ), +) + + +def normalize_capabilities(raw: List[str] | None) -> Set[str]: + """Return known capabilities from a role's stored list.""" + if not raw: + return set() + return {cap for cap in raw if cap in ALL_CAPABILITIES} + + +def capabilities_for_registry() -> List[Dict[str, object]]: + """Serialize the registry for GET /capabilities.""" + return [ + { + "key": domain.key, + "label": domain.label, + "capabilities": [ + {"key": cap, "label": _capability_label(cap)} for cap in domain.capabilities + ], + } + for domain in CAPABILITY_DOMAINS + ] + + +def _capability_label(cap: str) -> str: + action = cap.split(".")[-1].replace("_", " ") + return action.title() + + +def required_workspace_role_label(capability: str) -> str: + """Return the minimum system workspace role that grants *capability*.""" + if capability in ADMIN_EXTRA_CAPABILITIES: + return SYSTEM_ROLE_ADMIN + if capability in EDITOR_EXTRA_CAPABILITIES: + return SYSTEM_ROLE_EDITOR + return SYSTEM_ROLE_VIEWER + + +def capability_denied_message( + capability: str, + *, + role_name: str | None = None, + workspace_label: str = "this workspace", +) -> str: + """ + User-facing 403 detail when a workspace capability check fails. + + Maps internal capability strings to system role names (Viewer / Editor / + Workspace Admin) instead of exposing raw capability keys in the UI. + """ + required_role = required_workspace_role_label(capability) + + if required_role == SYSTEM_ROLE_ADMIN: + base = ( + f"This action requires the Workspace Admin role in {workspace_label}." + ) + elif required_role == SYSTEM_ROLE_EDITOR: + base = ( + f"This action requires at least the Editor role in {workspace_label}." + ) + else: + base = f"You don't have permission to perform this action in {workspace_label}." + + if role_name: + return f"{base} Your current workspace role is {role_name}." + return f"{base} Ask a workspace admin to upgrade your access." diff --git a/app/core/auth/external_oidc.py b/app/core/auth/external_oidc.py index 2641ed50..7d282088 100644 --- a/app/core/auth/external_oidc.py +++ b/app/core/auth/external_oidc.py @@ -59,6 +59,12 @@ def authenticate(self, cred: RawCredential, db: Session) -> Principal: status_code=500, ) + if not audience: + raise AuthError( + "AUTH_OIDC_AUDIENCE must be configured for external OIDC.", + status_code=500, + ) + # Derive a sensible default JWKS URI when the operator didn't set one. if not jwks_uri: jwks_uri = issuer.rstrip("/") + "/.well-known/jwks.json" diff --git a/app/core/auth/oidc_common.py b/app/core/auth/oidc_common.py index 225e6d5a..a455db23 100644 --- a/app/core/auth/oidc_common.py +++ b/app/core/auth/oidc_common.py @@ -82,6 +82,9 @@ def verify_jwt( Matches the key by `kid`. Falls back to trying every key if `kid` is missing - this happens with some cloud IdPs on signature rollover. """ + if not audience: + raise AuthError("OIDC audience is not configured") + keys = fetch_jwks(jwks_uri) try: header = jwt.get_unverified_header(token) @@ -98,16 +101,12 @@ def verify_jwt( last_error: Optional[Exception] = None for key in candidate_keys: try: - options = {} - if audience is None: - options["verify_aud"] = False return jwt.decode( token, key, algorithms=[key.get("alg", "RS256")], issuer=issuer, audience=audience, - options=options, ) except JWTError as e: last_error = e diff --git a/app/core/auth/rbac.py b/app/core/auth/rbac.py index 50b542fc..16f2bfe5 100644 --- a/app/core/auth/rbac.py +++ b/app/core/auth/rbac.py @@ -24,8 +24,8 @@ from fastapi import Depends, HTTPException, status from sqlalchemy.orm import Session -from app.core.auth.principal import Principal from app.core.auth.dependency import get_principal +from app.core.auth.principal import Principal from app.database import get_db from app.models.database import OrganizationMember, RoleEnum diff --git a/app/core/auth/workspace_route_capabilities.py b/app/core/auth/workspace_route_capabilities.py new file mode 100644 index 00000000..449014c8 --- /dev/null +++ b/app/core/auth/workspace_route_capabilities.py @@ -0,0 +1,66 @@ +"""Apply default workspace capability dependencies to a FastAPI router.""" + +from __future__ import annotations + +from typing import Iterable, Set + +from fastapi import Depends +from starlette.routing import BaseRoute + +from app.dependencies import require_capability + + +def apply_workspace_route_capabilities( + router, + *, + view_capability: str, + manage_capability: str, + run_capability: str | None = None, + report_capability: str | None = None, + delete_capability: str | None = None, + skip_paths: Iterable[str] | None = None, +) -> None: + """ + Attach capability dependencies to routes on ``router`` based on HTTP method. + + GET/HEAD -> view_capability + POST/PUT/PATCH -> manage_capability (or run_capability when path ends with /run, + or report_capability when path ends with /pdf-report) + DELETE -> delete_capability or manage_capability + """ + skipped = set(skip_paths or ()) + run_paths: Set[str] = set() + + for route in router.routes: + if not isinstance(route, BaseRoute): + continue + path = getattr(route, "path", "") or "" + if path in skipped: + continue + methods = getattr(route, "methods", None) or set() + deps = list(getattr(route, "dependencies", None) or []) + + if methods <= {"GET", "HEAD"} or (methods & {"GET", "HEAD"} and not methods - {"GET", "HEAD"}): + deps.append(Depends(require_capability(view_capability))) + elif "DELETE" in methods: + cap = delete_capability or manage_capability + deps.append(Depends(require_capability(cap))) + elif methods & {"POST", "PUT", "PATCH"}: + cap = manage_capability + if report_capability and _looks_like_report_route(path): + cap = report_capability + elif run_capability and _looks_like_run_route(path): + cap = run_capability + deps.append(Depends(require_capability(cap))) + + route.dependencies = deps + + +def _looks_like_run_route(path: str) -> bool: + lowered = path.lower() + return lowered.endswith("/run") or "/run/" in lowered + + +def _looks_like_report_route(path: str) -> bool: + lowered = path.lower() + return lowered.endswith("/pdf-report") or "/pdf-report" in lowered diff --git a/app/dependencies.py b/app/dependencies.py index 7407d72f..d2005f82 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -9,16 +9,22 @@ which carries user_id, organization_id, and auth_method in one place. """ -from typing import Optional +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Set from uuid import UUID -from fastapi import Depends, Header, HTTPException +from fastapi import Depends, Header, HTTPException, Request from sqlalchemy.orm import Session from app.core.auth import Principal, get_principal # noqa: F401 - re-exported +from app.core.auth.rbac import get_org_role from app.core.license import is_feature_enabled from app.database import get_db -from app.models.database import Workspace +from app.models.database import RoleEnum, Workspace, WorkspaceMember +from app.core.auth.capabilities import capability_denied_message +from app.services.workspace_rbac import resolve_workspace_capabilities def get_api_key( @@ -53,68 +59,178 @@ def get_organization_id( return principal.organization_id -def get_workspace_id( +@dataclass(frozen=True) +class WorkspaceContext: + """Resolved active workspace plus the caller's capabilities within it.""" + + workspace_id: UUID + organization_id: UUID + capabilities: frozenset[str] + role_id: UUID | None = None + role_name: str | None = None + is_org_admin: bool = False + + +def _resolve_workspace_row( + db: Session, + *, + organization_id: UUID, + workspace_id: UUID | None, + principal: Principal, +) -> Workspace: + org_role = get_org_role(principal, db) + is_org_admin = org_role == RoleEnum.ADMIN + + if workspace_id is not None: + workspace = ( + db.query(Workspace) + .filter( + Workspace.id == workspace_id, + Workspace.organization_id == organization_id, + ) + .first() + ) + if workspace is None: + raise HTTPException( + status_code=404, + detail="Workspace not found in this organization.", + ) + if not is_org_admin and principal.user_id is not None: + member = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace.id, + WorkspaceMember.user_id == principal.user_id, + ) + .first() + ) + if member is None: + raise HTTPException( + status_code=403, + detail="You don't have access to this workspace.", + ) + return workspace + + if is_org_admin or principal.user_id is None: + default_ws = ( + db.query(Workspace) + .filter( + Workspace.organization_id == organization_id, + Workspace.is_default.is_(True), + ) + .first() + ) + if default_ws is None: + raise HTTPException( + status_code=500, + detail=( + "No default workspace exists for this organization. " + "Please contact support; migration 033 may not have run." + ), + ) + return default_ws + + membership_rows = ( + db.query(WorkspaceMember, Workspace) + .join(Workspace, Workspace.id == WorkspaceMember.workspace_id) + .filter( + Workspace.organization_id == organization_id, + WorkspaceMember.user_id == principal.user_id, + ) + .order_by(Workspace.is_default.desc(), Workspace.name.asc()) + .all() + ) + if not membership_rows: + raise HTTPException( + status_code=403, + detail="You don't have access to any workspace in this organization.", + ) + return membership_rows[0][1] + + +def get_workspace_context( + request: Request, x_workspace_id: Optional[str] = Header(None, alias="X-Workspace-Id"), + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), -) -> UUID: - """Return the active workspace UUID for the authenticated caller. - - Resolution order: - 1. ``X-Workspace-Id`` header. The referenced workspace must belong - to the caller's organization, otherwise we 404 (don't leak the - existence of another org's workspace). - 2. The organization's Default workspace (``is_default = True``). - This is the back-compat path for existing API-key consumers - that don't know about workspaces yet - migration 033 seeds a - Default for every org so this always resolves. - - A 500 is raised if step 2 fails because the migration didn't run; - callers should never see that in healthy deployments. - """ +) -> WorkspaceContext: + """Resolve workspace + capability set once per request (cached on request.state).""" + cached = getattr(request.state, "workspace_context", None) + if cached is not None: + return cached + parsed_ws_id: UUID | None = None if x_workspace_id: try: - ws_id = UUID(x_workspace_id) + parsed_ws_id = UUID(x_workspace_id) except (ValueError, TypeError): raise HTTPException( status_code=400, detail="X-Workspace-Id must be a valid UUID.", ) - ws_id_row = ( - db.query(Workspace.id) - .filter( - Workspace.id == ws_id, - Workspace.organization_id == organization_id, - ) - .first() - ) - if ws_id_row is None: - raise HTTPException( - status_code=404, detail="Workspace not found in this organization." - ) - return ws_id_row[0] - - default_ws_row = ( - db.query(Workspace.id) - .filter( - Workspace.organization_id == organization_id, - Workspace.is_default.is_(True), - ) - .first() - ) - if default_ws_row is None: - # Should never happen post-migration. Raising 500 is preferable - # to silently writing rows with a NULL workspace_id and tripping - # the NOT NULL constraint downstream. - raise HTTPException( - status_code=500, - detail=( - "No default workspace exists for this organization. " - "Please contact support; migration 033 may not have run." - ), - ) - return default_ws_row[0] + + workspace = _resolve_workspace_row( + db, + organization_id=organization_id, + workspace_id=parsed_ws_id, + principal=principal, + ) + capabilities, membership, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace.id, + organization_id=organization_id, + ) + org_role = get_org_role(principal, db) + ctx = WorkspaceContext( + workspace_id=workspace.id, + organization_id=organization_id, + capabilities=frozenset(capabilities), + role_id=role.id if role else (membership.role_id if membership else None), + role_name=role.name if role else None, + is_org_admin=org_role == RoleEnum.ADMIN, + ) + request.state.workspace_context = ctx + return ctx + + +def get_workspace_id( + ctx: WorkspaceContext = Depends(get_workspace_context), +) -> UUID: + """Return the active workspace UUID for the authenticated caller.""" + return ctx.workspace_id + + +def get_workspace_capabilities( + ctx: WorkspaceContext = Depends(get_workspace_context), +) -> Set[str]: + """Return the caller's capability set for the active workspace.""" + return set(ctx.capabilities) + + +def require_capability(capability: str): + """ + Build a FastAPI dependency that ensures the caller has a workspace capability. + + Requires ``get_workspace_context`` to have run (directly or via + ``get_workspace_id``) so capabilities are resolved for the active workspace. + Org admins and unbound API keys receive all capabilities implicitly. + """ + + def _dep(ctx: WorkspaceContext = Depends(get_workspace_context)) -> WorkspaceContext: + if capability not in ctx.capabilities: + raise HTTPException( + status_code=403, + detail=capability_denied_message( + capability, + role_name=ctx.role_name, + workspace_label="the active workspace", + ), + ) + return ctx + + return _dep def get_db_session() -> Session: diff --git a/app/main.py b/app/main.py index 704ddbcd..9c4a5a74 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from app.config import load_config_from_file, settings +from app.config import load_config_from_file, settings, validate_auth_configuration from app.core.migration_middleware import MigrationCheckMiddleware from app.core.migrations import check_migrations_status, ensure_migrations_directory, run_migrations from app.core.rbac_middleware import ReaderReadOnlyMiddleware @@ -44,6 +44,16 @@ async def lifespan(app: FastAPI): ensure_migrations_directory() + try: + validate_auth_configuration() + logger.info("Authentication configuration validated") + except Exception as e: + logger.error("=" * 60) + logger.error("CRITICAL: Authentication configuration is invalid!") + logger.error("=" * 60) + logger.error(f"Error: {e}") + raise + try: init_db() logger.info("Database tables initialized") diff --git a/app/migrations/048_workspace_rbac.py b/app/migrations/048_workspace_rbac.py new file mode 100644 index 00000000..fee10322 --- /dev/null +++ b/app/migrations/048_workspace_rbac.py @@ -0,0 +1,282 @@ +""" +Migration: Workspace RBAC (roles, memberships, backfill). + +Adds: + * ``workspace_roles`` table - org-scoped capability bundles (system + custom). + * ``workspace_members`` table - user membership per workspace. + * Seeds Viewer / Editor / Workspace Admin system roles per org. + * Backfills every org member into every org workspace (preserves access on upgrade). + +Idempotent: every step checks for prior state before applying. +""" + +from __future__ import annotations + +import json + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = ( + "Add workspace_roles and workspace_members for capability-based workspace RBAC; " + "seed system roles and backfill memberships." +) + +SYSTEM_ROLES = ( + ( + "Viewer", + "Read-only access to workspace resources.", + sorted( + { + "calls.view", + "metrics.view", + "evals.view", + "sim.view", + "reports.view", + "workspace.members.view", + } + ), + ), + ( + "Editor", + "View and modify workspace resources without admin settings.", + sorted( + { + "calls.view", + "calls.import", + "metrics.view", + "metrics.manage", + "evals.view", + "evals.run", + "sim.view", + "sim.manage", + "reports.view", + "reports.generate", + "workspace.members.view", + } + ), + ), + ( + "Workspace Admin", + "Full access including workspace settings and member management.", + sorted( + { + "calls.view", + "calls.import", + "calls.delete", + "metrics.view", + "metrics.manage", + "evals.view", + "evals.run", + "sim.view", + "sim.manage", + "reports.view", + "reports.generate", + "workspace.settings", + "workspace.members.view", + "workspace.members.manage", + } + ), + ), +) + + +def _table_exists(db: Session, table_name: str) -> bool: + row = db.execute( + text( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_name = :table_name + """ + ), + {"table_name": table_name}, + ).first() + return row is not None + + +def _index_exists(db: Session, index_name: str) -> bool: + row = db.execute( + text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"), + {"index_name": index_name}, + ).first() + return row is not None + + +def upgrade(db: Session): + if not _table_exists(db, "workspace_roles"): + db.execute( + text( + """ + CREATE TABLE workspace_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL + REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + capabilities JSON NOT NULL DEFAULT '[]', + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_workspace_roles_org_name + UNIQUE (organization_id, name) + ) + """ + ) + ) + print("Created workspace_roles table") + else: + print("workspace_roles table already exists, skipping CREATE...") + + # Belt-and-braces: ensure ``id`` has the DB-side DEFAULT even when the + # table was created by ``Base.metadata.create_all`` (init_db runs before + # migrations). create_all does not emit DEFAULT clauses for Python-side + # ``default=uuid.uuid4``, which leaves the column NOT NULL but with no + # default, breaking the raw-SQL seed below. + db.execute( + text( + "ALTER TABLE workspace_roles ALTER COLUMN id SET DEFAULT gen_random_uuid()" + ) + ) + + db.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_workspace_roles_organization_id " + "ON workspace_roles(organization_id)" + ) + ) + + if not _table_exists(db, "workspace_members"): + db.execute( + text( + """ + CREATE TABLE workspace_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL + REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL + REFERENCES workspace_roles(id) ON DELETE RESTRICT, + added_by_user_id UUID NULL + REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_workspace_members_ws_user + UNIQUE (workspace_id, user_id) + ) + """ + ) + ) + print("Created workspace_members table") + else: + print("workspace_members table already exists, skipping CREATE...") + + db.execute( + text( + "ALTER TABLE workspace_members ALTER COLUMN id SET DEFAULT gen_random_uuid()" + ) + ) + + for index_sql in ( + "CREATE INDEX IF NOT EXISTS ix_workspace_members_workspace_id " + "ON workspace_members(workspace_id)", + "CREATE INDEX IF NOT EXISTS ix_workspace_members_user_id " + "ON workspace_members(user_id)", + "CREATE INDEX IF NOT EXISTS ix_workspace_members_role_id " + "ON workspace_members(role_id)", + ): + db.execute(text(index_sql)) + + org_rows = db.execute(text("SELECT id FROM organizations")).fetchall() + for (org_id,) in org_rows: + role_ids: dict[str, str] = {} + for name, desc, caps in SYSTEM_ROLES: + existing = db.execute( + text( + """ + SELECT id FROM workspace_roles + WHERE organization_id = :org_id AND name = :name + """ + ), + {"org_id": org_id, "name": name}, + ).first() + if existing: + role_ids[name] = str(existing[0]) + continue + inserted = db.execute( + text( + """ + INSERT INTO workspace_roles + (id, organization_id, name, description, capabilities, is_system) + VALUES + (gen_random_uuid(), :org_id, :name, :description, + CAST(:capabilities AS JSON), TRUE) + RETURNING id + """ + ), + { + "org_id": org_id, + "name": name, + "description": desc, + "capabilities": json.dumps(caps), + }, + ).first() + role_ids[name] = str(inserted[0]) + print(f"Seeded system workspace roles for org {org_id}") + + workspaces = db.execute( + text("SELECT id FROM workspaces WHERE organization_id = :org_id"), + {"org_id": org_id}, + ).fetchall() + members = db.execute( + text( + """ + SELECT user_id, role FROM organization_members + WHERE organization_id = :org_id + """ + ), + {"org_id": org_id}, + ).fetchall() + + for workspace_id, in workspaces: + for user_id, org_role in members: + role_name = "Workspace Admin" + if org_role == "writer": + role_name = "Editor" + elif org_role == "reader": + role_name = "Viewer" + role_id = role_ids[role_name] + exists = db.execute( + text( + """ + SELECT 1 FROM workspace_members + WHERE workspace_id = :ws_id AND user_id = :user_id + """ + ), + {"ws_id": workspace_id, "user_id": user_id}, + ).first() + if exists: + continue + db.execute( + text( + """ + INSERT INTO workspace_members (id, workspace_id, user_id, role_id) + VALUES (gen_random_uuid(), :ws_id, :user_id, CAST(:role_id AS UUID)) + """ + ), + { + "ws_id": workspace_id, + "user_id": user_id, + "role_id": role_id, + }, + ) + print(f"Backfilled workspace memberships for org {org_id}") + + db.commit() + print("Workspace RBAC schema is in place") + + +def downgrade(db: Session): + db.execute(text("DROP TABLE IF EXISTS workspace_members")) + db.execute(text("DROP TABLE IF EXISTS workspace_roles")) + db.commit() diff --git a/app/migrations/049_llm_generation_config.py b/app/migrations/049_llm_generation_config.py new file mode 100644 index 00000000..720736db --- /dev/null +++ b/app/migrations/049_llm_generation_config.py @@ -0,0 +1,49 @@ +"""Migration: Add llm_config JSON to evaluators and call_import_evaluations.""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = ( + "Add llm_config JSON columns for user-tunable LLM generation parameters." +) + + +def _column_exists(db: Session, table_name: str, column_name: str) -> bool: + row = db.execute( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_name = :table_name AND column_name = :column_name + """ + ), + {"table_name": table_name, "column_name": column_name}, + ).first() + return row is not None + + +def upgrade(db: Session): + if not _column_exists(db, "evaluators", "llm_config"): + db.execute(text("ALTER TABLE evaluators ADD COLUMN llm_config JSON")) + print("Added evaluators.llm_config") + else: + print("evaluators.llm_config already exists, skipping") + + if not _column_exists(db, "call_import_evaluations", "llm_config"): + db.execute( + text("ALTER TABLE call_import_evaluations ADD COLUMN llm_config JSON") + ) + print("Added call_import_evaluations.llm_config") + else: + print("call_import_evaluations.llm_config already exists, skipping") + + +def downgrade(db: Session): + if _column_exists(db, "evaluators", "llm_config"): + db.execute(text("ALTER TABLE evaluators DROP COLUMN llm_config")) + print("Dropped evaluators.llm_config") + if _column_exists(db, "call_import_evaluations", "llm_config"): + db.execute( + text("ALTER TABLE call_import_evaluations DROP COLUMN llm_config") + ) + print("Dropped call_import_evaluations.llm_config") diff --git a/app/models/database.py b/app/models/database.py index 336c966d..a4042f68 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -66,6 +66,11 @@ class Organization(Base): back_populates="organization", cascade="all, delete-orphan", ) + workspace_roles = relationship( + "WorkspaceRole", + back_populates="organization", + cascade="all, delete-orphan", + ) class Workspace(Base): @@ -74,8 +79,9 @@ class Workspace(Base): Every organization has at least one workspace (``is_default = True``, seeded by migration 033). Users pick an "active workspace" in the UI; list endpoints filter by it so users only see calls/metrics from the - project they're currently working in. There's no per-workspace ACL in - v1 - every org member can switch into any of their org's workspaces. + project they're currently working in. Access is governed by + ``workspace_members`` and org-scoped ``workspace_roles`` (capability + bundles); org admins implicitly access all workspaces. """ __tablename__ = "workspaces" @@ -121,6 +127,92 @@ class Workspace(Base): ) organization = relationship("Organization", back_populates="workspaces") + members = relationship( + "WorkspaceMember", + back_populates="workspace", + cascade="all, delete-orphan", + ) + + +class WorkspaceRole(Base): + """Org-scoped workspace role (system or custom) as a capability bundle.""" + + __tablename__ = "workspace_roles" + __table_args__ = ( + UniqueConstraint("organization_id", "name", name="uq_workspace_roles_org_name"), + ) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=text("gen_random_uuid()"), + ) + organization_id = Column( + UUID(as_uuid=True), + ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + capabilities = Column(JSON, nullable=False, default=list) + is_system = Column(Boolean, nullable=False, default=False, server_default="false") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + organization = relationship("Organization", back_populates="workspace_roles") + members = relationship("WorkspaceMember", back_populates="role") + + +class WorkspaceMember(Base): + """User membership in a workspace with an assigned workspace role.""" + + __tablename__ = "workspace_members" + __table_args__ = ( + UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_ws_user"), + ) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=text("gen_random_uuid()"), + ) + workspace_id = Column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + role_id = Column( + UUID(as_uuid=True), + ForeignKey("workspace_roles.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + added_by_user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + workspace = relationship("Workspace", back_populates="members") + user = relationship("User", foreign_keys=[user_id]) + role = relationship("WorkspaceRole", back_populates="members") + added_by = relationship("User", foreign_keys=[added_by_user_id]) # Partial unique index: "at most one default workspace per org". This @@ -665,6 +757,7 @@ class Evaluator(Base): # LLM configuration for evaluation (overrides hardcoded defaults) llm_provider = Column(String, nullable=True) # e.g. "openai", "anthropic", "google" llm_model = Column(String, nullable=True) # e.g. "gpt-4.1", "claude-sonnet-4-20250514" + llm_config = Column(JSON, nullable=True) # Tags for categorization tags = Column(JSON, nullable=True) # Array of tag strings @@ -2050,6 +2143,7 @@ class CallImportEvaluation(Base): ForeignKey("aiproviders.id", ondelete="SET NULL"), nullable=True, ) + llm_config = Column(JSON, nullable=True) # Optional per-metric LLM override: # ``{"": {"provider": "...", "model": "...", "credential_id": "..."}}``. # Each entry overrides the run-level default for that metric only; diff --git a/app/models/enums.py b/app/models/enums.py index 096fd298..dba91cf1 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -115,6 +115,7 @@ class ModelProvider(str, enum.Enum): ANTHROPIC = "anthropic" GOOGLE = "google" XAI = "xai" + FIREWORKS = "fireworks" COHERE = "cohere" MISTRAL = "mistral" META = "meta" diff --git a/app/models/schemas.py b/app/models/schemas.py index 618d2247..eaf315c2 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -719,6 +719,22 @@ def convert_provider(cls, v): model_config = ConfigDict(from_attributes=True) +class LLMGenerationConfig(BaseModel): + """User-tunable LLM sampling / generation parameters.""" + + temperature: Optional[float] = Field(None, ge=0.0, le=2.0) + max_tokens: Optional[int] = Field(None, gt=0) + top_p: Optional[float] = Field(None, ge=0.0, le=1.0) + top_k: Optional[int] = Field(None, ge=0) + frequency_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0) + presence_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0) + seed: Optional[int] = Field(None, ge=0) + + def to_dict(self) -> Dict[str, Any]: + """Return only explicitly set fields.""" + return self.model_dump(exclude_none=True) + + # VoiceBundle Schemas class VoiceBundleCreate(BaseModel): """Schema for creating a VoiceBundle.""" @@ -1002,6 +1018,7 @@ class EvaluatorCreate(BaseModel): metric_ids: Optional[List[UUID]] = None llm_provider: Optional[ModelProvider] = None llm_model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None tags: Optional[List[str]] = None @@ -1015,6 +1032,7 @@ class EvaluatorUpdate(BaseModel): metric_ids: Optional[List[UUID]] = None llm_provider: Optional[ModelProvider] = None llm_model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None tags: Optional[List[str]] = None @@ -1031,6 +1049,7 @@ class EvaluatorResponse(BaseModel): metric_ids: Optional[List[UUID]] = None llm_provider: Optional[ModelProvider] = None llm_model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None tags: Optional[List[str]] created_at: datetime updated_at: datetime @@ -2591,6 +2610,10 @@ class CallImportEvaluationLLMOverride(BaseModel): default=None, description="Optional AIProvider id when the org has multiple credentials.", ) + llm_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Optional per-metric generation parameters (temperature, top_p, etc.).", + ) CallImportEvaluationTranscriptSource = Literal["production", "diarised"] @@ -2662,6 +2685,10 @@ def _validate_transcript_sources( default=None, description="Optional AIProvider row to pin for the run-level LLM.", ) + llm_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Run-level LLM generation parameters (temperature, top_p, etc.).", + ) metric_llm_overrides: Optional[ Dict[str, CallImportEvaluationLLMOverride] ] = Field( @@ -2876,6 +2903,10 @@ class CallImportEvaluationRetryRequest(BaseModel): "When omitted, the resolver falls back to the org default." ), ) + llm_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Override run-level LLM generation parameters for this retry.", + ) metric_llm_overrides: Optional[ Dict[str, CallImportEvaluationLLMOverride] ] = Field( @@ -3023,6 +3054,7 @@ class CallImportEvaluationResponse(BaseModel): llm_provider: Optional[str] = None llm_model: Optional[str] = None llm_credential_id: Optional[UUID] = None + llm_config: Optional[Dict[str, Any]] = None metric_llm_overrides: Optional[Dict[str, Any]] = None stt_provider: Optional[str] = None stt_model: Optional[str] = None @@ -4201,5 +4233,71 @@ class WorkspaceResponse(BaseModel): is_default: bool created_at: datetime updated_at: datetime + role_id: Optional[UUID] = None + role_name: Optional[str] = None + capabilities: List[str] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) + + +class WorkspaceRoleBase(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + capabilities: List[str] = Field(default_factory=list) + + +class WorkspaceRoleCreate(WorkspaceRoleBase): + pass + + +class WorkspaceRoleUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + description: Optional[str] = None + capabilities: Optional[List[str]] = None + + +class WorkspaceRoleResponse(BaseModel): + id: UUID + organization_id: UUID + name: str + description: Optional[str] = None + capabilities: List[str] + is_system: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkspaceMemberResponse(BaseModel): + id: UUID + workspace_id: UUID + user_id: UUID + role_id: UUID + role_name: str + user_email: str + user_name: Optional[str] = None + added_by_user_id: Optional[UUID] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkspaceMemberCreate(BaseModel): + user_id: UUID + role_id: UUID + + +class WorkspaceMemberUpdate(BaseModel): + role_id: UUID + + +class CapabilityInfoResponse(BaseModel): + key: str + label: str + + +class CapabilityDomainResponse(BaseModel): + key: str + label: str + capabilities: List[CapabilityInfoResponse] diff --git a/app/services/ai/llm_generation_config.py b/app/services/ai/llm_generation_config.py new file mode 100644 index 00000000..5b94580e --- /dev/null +++ b/app/services/ai/llm_generation_config.py @@ -0,0 +1,119 @@ +"""Helpers for merging and applying user LLM generation parameters.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def merge_llm_config( + primary: Optional[Dict[str, Any]] = None, + *, + legacy_temperature: Optional[float] = None, + legacy_max_tokens: Optional[int] = None, +) -> Dict[str, Any]: + """Merge ``llm_config`` JSON with legacy VoiceBundle columns. + + Legacy ``llm_temperature`` / ``llm_max_tokens`` apply only when the + corresponding key is absent from ``primary``. + """ + merged: Dict[str, Any] = dict(primary or {}) + if merged.get("temperature") is None and legacy_temperature is not None: + merged["temperature"] = legacy_temperature + if merged.get("max_tokens") is None and legacy_max_tokens is not None: + merged["max_tokens"] = legacy_max_tokens + return merged + + +def resolve_effective_llm_config( + *, + llm_config: Optional[Dict[str, Any]] = None, + override_llm_config: Optional[Dict[str, Any]] = None, + legacy_temperature: Optional[float] = None, + legacy_max_tokens: Optional[int] = None, + task_defaults: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Resolve config with override > entity > legacy columns > task defaults.""" + base = merge_llm_config( + llm_config, + legacy_temperature=legacy_temperature, + legacy_max_tokens=legacy_max_tokens, + ) + if override_llm_config: + for key, value in override_llm_config.items(): + if value is not None: + base[key] = value + + effective = dict(task_defaults or {}) + for key, value in base.items(): + if value is not None: + effective[key] = value + return effective + + +def build_litellm_kwargs( + *, + llm_config: Optional[Dict[str, Any]] = None, + override_llm_config: Optional[Dict[str, Any]] = None, + legacy_temperature: Optional[float] = None, + legacy_max_tokens: Optional[int] = None, + task_defaults: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build kwargs for :meth:`LLMService.generate_response`. + + Returns a dict with ``temperature``, ``max_tokens``, and ``config`` + (extra LiteLLM params such as top_p / top_k / penalties). + """ + effective = resolve_effective_llm_config( + llm_config=llm_config, + override_llm_config=override_llm_config, + legacy_temperature=legacy_temperature, + legacy_max_tokens=legacy_max_tokens, + task_defaults=task_defaults, + ) + + temperature = effective.pop("temperature", (task_defaults or {}).get("temperature", 0.7)) + max_tokens = effective.pop("max_tokens", (task_defaults or {}).get("max_tokens")) + extra = {k: v for k, v in effective.items() if v is not None} + return { + "temperature": temperature, + "max_tokens": max_tokens, + "config": extra or None, + } + + +def build_efficientai_input_params(provider: str, config: Optional[Dict[str, Any]]): + """Map merged config dict to the EfficientAI SDK InputParams for *provider*.""" + if not config: + return None + + provider_key = (provider or "").lower() + params_dict = { + k: v + for k, v in { + "temperature": config.get("temperature"), + "top_p": config.get("top_p"), + "top_k": config.get("top_k"), + "max_tokens": config.get("max_tokens"), + "frequency_penalty": config.get("frequency_penalty"), + "presence_penalty": config.get("presence_penalty"), + "seed": config.get("seed"), + }.items() + if v is not None + } + if not params_dict: + return None + + if provider_key == "openai": + from efficientai.services.openai.llm import OpenAILLMService + + return OpenAILLMService.InputParams(**params_dict) + if provider_key == "google": + from efficientai.services.google.llm import GoogleLLMService + + return GoogleLLMService.InputParams(**params_dict) + if provider_key == "anthropic": + from efficientai.services.anthropic.llm import AnthropicLLMService + + return AnthropicLLMService.InputParams(**params_dict) + + return None diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index ec40719b..407ee51d 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -19,6 +19,7 @@ from app.models.database import ModelProvider, AIProvider from app.services.credentials import resolve_ai_provider +from app.services.ai.llm_generation_config import build_litellm_kwargs # LiteLLM will silently drop params the target provider doesn't support # rather than raising an error. @@ -33,6 +34,8 @@ "aws": "bedrock", "deepseek": "deepseek", "groq": "groq", + "xai": "xai", + "fireworks": "fireworks_ai", } # Matches the model-name half of the Gemini 2.5 family: ``gemini-2.5-pro``, @@ -162,6 +165,8 @@ def _litellm_model_name(provider: ModelProvider, model: str) -> str: """Build the ``provider/model`` string that LiteLLM expects.""" provider_value = provider.value if hasattr(provider, "value") else str(provider) prefix = _LITELLM_PROVIDER_PREFIX.get(provider_value.lower(), provider_value.lower()) + if provider_value.lower() == "fireworks" and not model.startswith("accounts/"): + model = f"accounts/fireworks/models/{model}" return f"{prefix}/{model}" def generate_response( @@ -174,6 +179,9 @@ def generate_response( temperature: Optional[float] = 0.7, max_tokens: Optional[int] = None, config: Optional[Dict[str, Any]] = None, + llm_config: Optional[Dict[str, Any]] = None, + override_llm_config: Optional[Dict[str, Any]] = None, + task_defaults: Optional[Dict[str, Any]] = None, credential_id: Optional[UUID] = None, ) -> Dict[str, Any]: """Generate a text response using the specified LLM via LiteLLM. @@ -182,7 +190,21 @@ def generate_response( organization has multiple keys for the same provider; when omitted the resolver falls back to the row marked ``is_default`` (or the most recently updated active row for back-compat). + + Generation parameters resolve as: + override_llm_config > llm_config/config > legacy temperature/max_tokens > task_defaults. """ + gen_kwargs = build_litellm_kwargs( + llm_config=llm_config or config, + override_llm_config=override_llm_config, + legacy_temperature=temperature, + legacy_max_tokens=max_tokens, + task_defaults=task_defaults, + ) + temperature = gen_kwargs["temperature"] + max_tokens = gen_kwargs["max_tokens"] + config = gen_kwargs["config"] + start_time = time.time() # --- resolve API key from database -------------------------------- diff --git a/app/services/judge_alignment/model_catalog.py b/app/services/judge_alignment/model_catalog.py index 2c847181..e7f4afda 100644 --- a/app/services/judge_alignment/model_catalog.py +++ b/app/services/judge_alignment/model_catalog.py @@ -33,6 +33,7 @@ ModelProvider.ANTHROPIC.value, ModelProvider.GOOGLE.value, ModelProvider.XAI.value, + ModelProvider.FIREWORKS.value, ModelProvider.COHERE.value, ModelProvider.MISTRAL.value, ModelProvider.META.value, @@ -52,6 +53,7 @@ def _provider_label(provider_value: str) -> str: "anthropic": "Anthropic", "google": "Google", "xai": "xAI", + "fireworks": "Fireworks AI", "cohere": "Cohere", "mistral": "Mistral", "meta": "Meta", diff --git a/app/services/organization_provisioning.py b/app/services/organization_provisioning.py index f052c5d1..54981021 100644 --- a/app/services/organization_provisioning.py +++ b/app/services/organization_provisioning.py @@ -7,6 +7,11 @@ from sqlalchemy.orm import Session from app.models.database import Workspace +from app.services.workspace_rbac import ( + backfill_org_workspace_memberships, + ensure_creator_workspace_admin, + seed_system_workspace_roles, +) def provision_default_workspace( @@ -16,6 +21,8 @@ def provision_default_workspace( created_by_user_id: UUID | None = None, ) -> Workspace: """Create the canonical Default workspace for an organization (idempotent).""" + seed_system_workspace_roles(db, organization_id=organization_id) + existing = ( db.query(Workspace) .filter( @@ -25,6 +32,7 @@ def provision_default_workspace( .first() ) if existing is not None: + backfill_org_workspace_memberships(db, organization_id=organization_id) return existing workspace = Workspace( @@ -36,4 +44,10 @@ def provision_default_workspace( ) db.add(workspace) db.flush() + ensure_creator_workspace_admin( + db, + workspace=workspace, + user_id=created_by_user_id, + ) + backfill_org_workspace_memberships(db, organization_id=organization_id) return workspace diff --git a/app/services/storage/blob_paths.py b/app/services/storage/blob_paths.py index 9494bb27..865ef042 100644 --- a/app/services/storage/blob_paths.py +++ b/app/services/storage/blob_paths.py @@ -1,8 +1,11 @@ """Shared object key/path helpers for cloud blob storage backends.""" from typing import Optional +from urllib.parse import unquote import uuid +from fastapi import HTTPException + def normalize_prefix(prefix: str) -> str: """Ensure prefix ends with a single trailing slash.""" @@ -37,6 +40,23 @@ def get_organization_root_prefix(prefix: str, organization_id: str) -> str: return f"{normalize_prefix(prefix)}organizations/{organization_id}/" +def assert_key_belongs_to_org( + file_key: str, + organization_id: uuid.UUID, + *, + storage_prefix: str, + decode: bool = False, +) -> str: + """Validate that a blob key belongs to the caller's organization namespace.""" + key = unquote(file_key) if decode else file_key + if "\x00" in key or "/../" in f"/{key}/" or key.startswith("../"): + raise HTTPException(status_code=403, detail="Access denied") + expected = get_organization_root_prefix(storage_prefix, str(organization_id)) + if not key.startswith(expected): + raise HTTPException(status_code=403, detail="Access denied") + return key + + def content_type_for_format(file_format: str) -> str: """Map audio file extension to MIME content type.""" content_type_map = { diff --git a/app/services/telephony/recording_download.py b/app/services/telephony/recording_download.py index 24e9de60..7dc6ed54 100644 --- a/app/services/telephony/recording_download.py +++ b/app/services/telephony/recording_download.py @@ -2,10 +2,14 @@ from __future__ import annotations -from typing import Optional, Tuple, Union +import ipaddress +import socket +from typing import Callable, List, Optional, Tuple, Union +from urllib.parse import urlparse import httpx +from app.config import settings from app.services.telephony.exotel_client import ( DEFAULT_MAX_RECORDING_BYTES, DEFAULT_TIMEOUT_SECONDS, @@ -16,6 +20,128 @@ ExotelTransientError, ) +_DEFAULT_ALLOWED_HOST_SUFFIXES = ( + "exotel.com", + "plivo.com", + "amazonaws.com", + "cloudfront.net", +) + +_BLOCKED_NETWORKS = ( + ipaddress.ip_network("0.0.0.0/8"), + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("169.254.0.0/16"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ipaddress.ip_network("100.64.0.0/10"), + ipaddress.ip_network("::1/128"), + ipaddress.ip_network("fc00::/7"), + ipaddress.ip_network("fe80::/10"), +) + + +def _allowed_host_suffixes() -> List[str]: + configured = getattr(settings, "RECORDING_URL_ALLOWED_HOST_SUFFIXES", None) + if configured: + return list(configured) + return list(_DEFAULT_ALLOWED_HOST_SUFFIXES) + + +def _hostname_allowed(hostname: str, allowed_suffixes: List[str]) -> bool: + host = hostname.lower().rstrip(".") + for suffix in allowed_suffixes: + normalized = suffix.lower().lstrip(".") + if host == normalized or host.endswith(f".{normalized}"): + return True + return False + + +def _ip_is_blocked(ip: ipaddress._BaseAddress) -> bool: + for network in _BLOCKED_NETWORKS: + if ip in network: + return True + return False + + +def _resolve_host_ips(hostname: str) -> List[ipaddress._BaseAddress]: + try: + addr_infos = socket.getaddrinfo( + hostname, + None, + type=socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + raise ExotelInvalidContentError( + f"Recording URL hostname could not be resolved: {hostname}" + ) from exc + + ips: List[ipaddress._BaseAddress] = [] + seen = set() + for info in addr_infos: + sockaddr = info[4] + if not sockaddr: + continue + ip_str = sockaddr[0] + if ip_str in seen: + continue + seen.add(ip_str) + try: + ips.append(ipaddress.ip_address(ip_str)) + except ValueError as exc: + raise ExotelInvalidContentError( + f"Recording URL resolved to invalid IP address: {ip_str}" + ) from exc + if not ips: + raise ExotelInvalidContentError( + f"Recording URL hostname could not be resolved: {hostname}" + ) + return ips + + +def assert_recording_url_safe( + recording_url: str, + *, + user_supplied: bool, + allowed_suffixes: Optional[List[str]] = None, +) -> None: + """Validate a recording URL before any outbound HTTP request.""" + parsed = urlparse(recording_url.strip()) + if parsed.scheme not in {"http", "https"}: + raise ExotelInvalidContentError( + f"Recording URL must use http or https, got {parsed.scheme or 'none'}" + ) + if not parsed.hostname: + raise ExotelInvalidContentError("Recording URL is missing a hostname") + if parsed.username or parsed.password: + raise ExotelInvalidContentError( + "Recording URL must not embed credentials in the URL" + ) + + hostname = parsed.hostname + suffixes = allowed_suffixes or _allowed_host_suffixes() + + try: + literal_ip = ipaddress.ip_address(hostname) + if _ip_is_blocked(literal_ip): + raise ExotelInvalidContentError( + "Recording URL targets a blocked network address" + ) + if user_supplied: + raise ExotelInvalidContentError( + "User-supplied recording URLs must use allowlisted hostnames" + ) + except ValueError: + if not _hostname_allowed(hostname, suffixes): + raise ExotelInvalidContentError( + f"Recording URL hostname is not allowlisted: {hostname}" + ) + for resolved_ip in _resolve_host_ips(hostname): + if _ip_is_blocked(resolved_ip): + raise ExotelInvalidContentError( + "Recording URL resolves to a blocked network address" + ) + def download_recording_url( recording_url: str, @@ -23,6 +149,7 @@ def download_recording_url( auth: Optional[Union[Tuple[str, str], httpx.Auth]] = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, max_bytes: int = DEFAULT_MAX_RECORDING_BYTES, + user_supplied: bool = False, ) -> Tuple[bytes, str]: """Download a recording from a URL. @@ -31,10 +158,32 @@ def download_recording_url( """ if not recording_url: raise ExotelInvalidContentError("recording_url is empty") + if user_supplied and auth is not None: + raise ExotelInvalidContentError( + "User-supplied recording URLs must not be fetched with credentials" + ) + + assert_recording_url_safe(recording_url, user_supplied=user_supplied) + + request_hooks: Optional[dict[str, List[Callable[..., None]]]] = None + if not user_supplied: + + def _validate_redirect(request: httpx.Request) -> None: + assert_recording_url_safe(str(request.url), user_supplied=False) + + request_hooks = {"request": [_validate_redirect]} + + follow_redirects = not user_supplied try: - with httpx.Client(timeout=timeout_seconds, follow_redirects=True) as client: + with httpx.Client( + timeout=timeout_seconds, + follow_redirects=follow_redirects, + event_hooks=request_hooks, + ) as client: resp = client.get(recording_url, auth=auth) + except ExotelInvalidContentError: + raise except httpx.TimeoutException as exc: raise ExotelTransientError(f"Timeout fetching recording: {exc}") from exc except httpx.HTTPError as exc: @@ -78,10 +227,11 @@ def download_public_recording( timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, max_bytes: int = DEFAULT_MAX_RECORDING_BYTES, ) -> Tuple[bytes, str]: - """Download a recording from a public URL without authentication.""" + """Download a user-supplied recording URL without authentication.""" return download_recording_url( recording_url, auth=None, timeout_seconds=timeout_seconds, max_bytes=max_bytes, + user_supplied=True, ) diff --git a/app/services/telephony/webhook_auth.py b/app/services/telephony/webhook_auth.py new file mode 100644 index 00000000..d9254c56 --- /dev/null +++ b/app/services/telephony/webhook_auth.py @@ -0,0 +1,155 @@ +"""Plivo webhook signature verification for multi-tenant deployments.""" + +from __future__ import annotations + +from typing import Any, Dict, Literal, Optional + +from fastapi import HTTPException, Request, status +from loguru import logger +from sqlalchemy.orm import Session + +from app.config import settings +from app.core.encryption import decrypt_api_key +from app.models.database import CallRecording, TelephonyIntegration, TelephonyPhoneNumber +from app.services.credentials.resolver import resolve_telephony_integration +from app.services.telephony.plivo_client import normalize_e164 + +WebhookKind = Literal["answer", "events", "masking"] + + +def build_plivo_webhook_uri(request: Request) -> str: + """Build the callback URI Plivo used when signing the webhook.""" + configured_base = (settings.PLIVO_WEBHOOK_BASE_URL or "").strip().rstrip("/") + if configured_base: + uri = f"{configured_base}{request.url.path}" + if request.url.query: + uri = f"{uri}?{request.url.query}" + return uri + return str(request.url) + + +def _resolve_auth_token_for_phone( + phone_number: Optional[str], + db: Session, +) -> Optional[str]: + if not phone_number: + return None + try: + normalized = normalize_e164(phone_number) + except ValueError: + return None + + number = ( + db.query(TelephonyPhoneNumber) + .filter( + TelephonyPhoneNumber.phone_number == normalized, + TelephonyPhoneNumber.is_active.is_(True), + ) + .first() + ) + if not number: + return None + + integration = ( + db.query(TelephonyIntegration) + .filter( + TelephonyIntegration.id == number.telephony_integration_id, + TelephonyIntegration.is_active.is_(True), + TelephonyIntegration.provider == "plivo", + ) + .first() + ) + if not integration: + return None + return decrypt_api_key(integration.auth_token) + + +def _resolve_auth_token_for_call_event( + params: Dict[str, Any], + db: Session, +) -> Optional[str]: + call_uuid = ( + params.get("CallUUID") + or params.get("RequestUUID") + or params.get("CallSid") + or params.get("call_sid") + or params.get("Sid") + ) + if not call_uuid: + return None + + recording = ( + db.query(CallRecording) + .filter(CallRecording.provider_call_id == call_uuid) + .first() + ) + if not recording: + return None + + integration = resolve_telephony_integration( + "plivo", + db, + recording.organization_id, + ) + if not integration: + return None + return decrypt_api_key(integration.auth_token) + + +def resolve_plivo_auth_token( + webhook_kind: WebhookKind, + params: Dict[str, Any], + db: Session, +) -> Optional[str]: + if webhook_kind in {"answer", "masking"}: + phone_number = params.get("To") or params.get("to") + return _resolve_auth_token_for_phone(phone_number, db) + if webhook_kind == "events": + return _resolve_auth_token_for_call_event(params, db) + return None + + +def verify_plivo_webhook( + request: Request, + params: Dict[str, Any], + webhook_kind: WebhookKind, + db: Session, +) -> None: + """Validate X-Plivo-Signature before processing webhook params.""" + signature = request.headers.get("X-Plivo-Signature") + if not signature: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Missing webhook signature", + ) + + auth_token = resolve_plivo_auth_token(webhook_kind, params, db) + if not auth_token: + logger.warning( + "Plivo webhook rejected: unable to resolve auth token for kind={}", + webhook_kind, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid webhook signature", + ) + + try: + from plivo.utils import validate_signature + except ImportError as exc: # pragma: no cover - optional dependency + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Plivo SDK is not installed", + ) from exc + + uri = build_plivo_webhook_uri(request) + if not validate_signature(auth_token, uri, params, signature): + logger.warning( + "Plivo webhook rejected: invalid signature for kind={} uri={}", + webhook_kind, + uri, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid webhook signature", + ) diff --git a/app/services/testing/test_agent_service.py b/app/services/testing/test_agent_service.py index 6580f661..0719bbb7 100644 --- a/app/services/testing/test_agent_service.py +++ b/app/services/testing/test_agent_service.py @@ -348,9 +348,10 @@ def process_audio_chunk( llm_model=voice_bundle.llm_model, organization_id=organization_id, db=db, - temperature=voice_bundle.llm_temperature or 0.7, + llm_config=voice_bundle.llm_config, + temperature=voice_bundle.llm_temperature, max_tokens=voice_bundle.llm_max_tokens, - config=voice_bundle.llm_config, + task_defaults={"temperature": 0.7}, ) test_agent_text = llm_result.get("text", "").strip() diff --git a/app/services/voice_agent/voice_bundle.py b/app/services/voice_agent/voice_bundle.py index abce7a42..b1c4546f 100644 --- a/app/services/voice_agent/voice_bundle.py +++ b/app/services/voice_agent/voice_bundle.py @@ -320,17 +320,19 @@ def _get_llm_providers(): "openai": { "env_key": "OPENAI_API_KEY", "default_model": "gpt-4.1", - "factory": lambda api_key, model: _get_service("OpenAILLMService")( + "factory": lambda api_key, model, params=None: _get_service("OpenAILLMService")( api_key=api_key, model=model, + **({"params": params} if params else {}), ), }, "google": { "env_key": "GOOGLE_API_KEY", "default_model": "gemini-2.5-flash", - "factory": lambda api_key, model: _get_service("GoogleLLMService")( + "factory": lambda api_key, model, params=None: _get_service("GoogleLLMService")( api_key=api_key, model=model, + **({"params": params} if params else {}), ), }, } @@ -556,7 +558,20 @@ async def run_voice_bundle_fastapi( tts = tts_cfg["factory"](api_key=tts_api_key, voice_id=tts_voice_id, model=tts_model) llm_model = getattr(voice_bundle, "llm_model", None) or llm_cfg["default_model"] - llm = llm_cfg["factory"](api_key=llm_api_key, model=llm_model) + from app.services.ai.llm_generation_config import ( + build_efficientai_input_params, + merge_llm_config, + ) + + llm_params = build_efficientai_input_params( + llm_provider_value, + merge_llm_config( + getattr(voice_bundle, "llm_config", None), + legacy_temperature=getattr(voice_bundle, "llm_temperature", None), + legacy_max_tokens=getattr(voice_bundle, "llm_max_tokens", None), + ), + ) + llm = llm_cfg["factory"](api_key=llm_api_key, model=llm_model, params=llm_params) # Build context with provided system instruction or a default base_instruction = ( diff --git a/app/services/workspace_rbac.py b/app/services/workspace_rbac.py new file mode 100644 index 00000000..e6e6c969 --- /dev/null +++ b/app/services/workspace_rbac.py @@ -0,0 +1,239 @@ +"""Workspace RBAC helpers: role seeding, membership, capability resolution.""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Set, Tuple +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.core.auth.capabilities import ( + EDITOR_ROLE_CAPABILITIES, + SYSTEM_ROLE_ADMIN, + SYSTEM_ROLE_EDITOR, + SYSTEM_ROLE_VIEWER, + VIEWER_ROLE_CAPABILITIES, + WORKSPACE_ADMIN_ROLE_CAPABILITIES, + normalize_capabilities, +) +from app.core.auth.rbac import get_org_role +from app.core.auth.principal import Principal +from app.models.database import ( + OrganizationMember, + RoleEnum, + Workspace, + WorkspaceMember, + WorkspaceRole, +) + + +SYSTEM_ROLE_DEFINITIONS: Tuple[Tuple[str, str, List[str]], ...] = ( + (SYSTEM_ROLE_VIEWER, "Read-only access to workspace resources.", VIEWER_ROLE_CAPABILITIES), + ( + SYSTEM_ROLE_EDITOR, + "View and modify workspace resources without admin settings.", + EDITOR_ROLE_CAPABILITIES, + ), + ( + SYSTEM_ROLE_ADMIN, + "Full access including workspace settings and member management.", + WORKSPACE_ADMIN_ROLE_CAPABILITIES, + ), +) + + +def seed_system_workspace_roles( + db: Session, + *, + organization_id: UUID, +) -> Dict[str, WorkspaceRole]: + """Ensure the three system roles exist for an organization (idempotent).""" + by_name: Dict[str, WorkspaceRole] = {} + for name, description, capabilities in SYSTEM_ROLE_DEFINITIONS: + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.organization_id == organization_id, + WorkspaceRole.name == name, + ) + .first() + ) + if role is None: + role = WorkspaceRole( + organization_id=organization_id, + name=name, + description=description, + capabilities=capabilities, + is_system=True, + ) + db.add(role) + db.flush() + by_name[name] = role + return by_name + + +def org_role_to_system_workspace_role(org_role: Optional[RoleEnum | str]) -> str: + """Map org membership role to a system workspace role name for backfill.""" + if isinstance(org_role, str): + try: + org_role = RoleEnum(org_role) + except ValueError: + org_role = RoleEnum.READER + if org_role == RoleEnum.ADMIN: + return SYSTEM_ROLE_ADMIN + if org_role == RoleEnum.WRITER: + return SYSTEM_ROLE_EDITOR + return SYSTEM_ROLE_VIEWER + + +def add_workspace_member( + db: Session, + *, + workspace_id: UUID, + user_id: UUID, + role_id: UUID, + added_by_user_id: UUID | None = None, +) -> WorkspaceMember: + """Add or update a workspace membership.""" + existing = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == user_id, + ) + .first() + ) + if existing is not None: + existing.role_id = role_id + if added_by_user_id is not None: + existing.added_by_user_id = added_by_user_id + db.flush() + return existing + + member = WorkspaceMember( + workspace_id=workspace_id, + user_id=user_id, + role_id=role_id, + added_by_user_id=added_by_user_id, + ) + db.add(member) + db.flush() + return member + + +def ensure_creator_workspace_admin( + db: Session, + *, + workspace: Workspace, + user_id: UUID | None, +) -> None: + """Auto-add workspace creator as Workspace Admin.""" + if user_id is None: + return + roles = seed_system_workspace_roles(db, organization_id=workspace.organization_id) + admin_role = roles[SYSTEM_ROLE_ADMIN] + add_workspace_member( + db, + workspace_id=workspace.id, + user_id=user_id, + role_id=admin_role.id, + added_by_user_id=user_id, + ) + + +def resolve_workspace_capabilities( + db: Session, + *, + principal: Principal, + workspace_id: UUID, + organization_id: UUID, +) -> Tuple[Set[str], Optional[WorkspaceMember], Optional[WorkspaceRole]]: + """ + Resolve the caller's capability set for a workspace. + + Returns (capabilities, membership_row, role_row). Org admins and unbound + API keys receive all capabilities with no membership row. + """ + from app.core.auth.capabilities import ALL_CAPABILITIES + + org_role = get_org_role(principal, db) + if org_role == RoleEnum.ADMIN: + return set(ALL_CAPABILITIES), None, None + + if principal.user_id is None: + return set(ALL_CAPABILITIES), None, None + + membership = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == principal.user_id, + ) + .first() + ) + if membership is None: + return set(), None, None + + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == membership.role_id).first() + if role is None: + return set(), membership, None + + return normalize_capabilities(role.capabilities), membership, role + + +def is_workspace_admin_role(role: WorkspaceRole | None) -> bool: + if role is None: + return False + caps = normalize_capabilities(role.capabilities) + from app.core.auth.capabilities import WORKSPACE_SETTINGS, WORKSPACE_MEMBERS_MANAGE + + return WORKSPACE_SETTINGS in caps and WORKSPACE_MEMBERS_MANAGE in caps + + +def count_workspace_admins(db: Session, *, workspace_id: UUID) -> int: + """Count members whose role includes workspace admin capabilities.""" + members = ( + db.query(WorkspaceMember) + .filter(WorkspaceMember.workspace_id == workspace_id) + .all() + ) + count = 0 + for member in members: + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + if is_workspace_admin_role(role): + count += 1 + return count + + +def backfill_org_workspace_memberships(db: Session, *, organization_id: UUID) -> None: + """Add every org member to every org workspace (idempotent).""" + roles = seed_system_workspace_roles(db, organization_id=organization_id) + workspaces = ( + db.query(Workspace) + .filter(Workspace.organization_id == organization_id) + .all() + ) + members = ( + db.query(OrganizationMember) + .filter(OrganizationMember.organization_id == organization_id) + .all() + ) + for org_member in members: + role_name = org_role_to_system_workspace_role(org_member.role) + ws_role = roles[role_name] + for workspace in workspaces: + existing = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace.id, + WorkspaceMember.user_id == org_member.user_id, + ) + .first() + ) + if existing is None: + add_workspace_member( + db, + workspace_id=workspace.id, + user_id=org_member.user_id, + role_id=ws_role.id, + ) diff --git a/app/workers/tasks/evaluate_call_import_row.py b/app/workers/tasks/evaluate_call_import_row.py index fae2d13e..6653e48a 100644 --- a/app/workers/tasks/evaluate_call_import_row.py +++ b/app/workers/tasks/evaluate_call_import_row.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json from datetime import datetime, timezone from types import SimpleNamespace from typing import Any, List, Optional @@ -700,6 +701,11 @@ def _load_ai_providers() -> list: if comparison_metrics: run_provider = (evaluation.llm_provider or "").strip() or None run_model = (evaluation.llm_model or "").strip() or None + run_llm_config = ( + evaluation.llm_config + if isinstance(getattr(evaluation, "llm_config", None), dict) + else None + ) overrides = ( evaluation.metric_llm_overrides if isinstance(evaluation.metric_llm_overrides, dict) @@ -709,11 +715,13 @@ def _load_ai_providers() -> list: override = overrides.get(str(cmp_metric.id)) or {} provider = override.get("provider") or run_provider or None model = override.get("model") or run_model or None + llm_config = override.get("llm_config") or run_llm_config evaluator_obj = None if provider and model: evaluator_obj = SimpleNamespace( llm_provider=provider, llm_model=model, + llm_config=llm_config, custom_prompt=None, ) try: @@ -774,37 +782,47 @@ def _load_ai_providers() -> list: # ``evaluate_with_llm``. run_provider = (evaluation.llm_provider or "").strip() or None run_model = (evaluation.llm_model or "").strip() or None + run_llm_config = ( + evaluation.llm_config + if isinstance(getattr(evaluation, "llm_config", None), dict) + else None + ) overrides = ( evaluation.metric_llm_overrides if isinstance(evaluation.metric_llm_overrides, dict) else {} ) - def _resolve_pm(metric: Metric) -> tuple[str | None, str | None]: + def _llm_config_key(cfg: dict | None) -> str | None: + if not cfg: + return None + return json.dumps(cfg, sort_keys=True, default=str) + + def _resolve_pm( + metric: Metric, + ) -> tuple[str | None, str | None, dict | None]: override = overrides.get(str(metric.id)) or {} provider = ( override.get("provider") or run_provider or None ) model = override.get("model") or run_model or None - return provider, model + llm_config = override.get("llm_config") or run_llm_config + return provider, model, llm_config - # Bucket = ((provider, model), parent_id_or_None) -> metrics. - # parent_id_or_None keys hierarchical groups; ``None`` keys - # the standalone bucket. Splitting on parent_id lets us pass - # ``parent_metric`` to ``evaluate_with_llm`` for prompt rendering. - BucketKey = tuple[tuple[str | None, str | None], UUID | None] + # Bucket = ((provider, model, llm_config_key), parent_id_or_None) -> metrics. + BucketKey = tuple[tuple[str | None, str | None, str | None], UUID | None] groups: dict[BucketKey, list[Metric]] = {} for metric in standalone_metrics: - provider, model = _resolve_pm(metric) - groups.setdefault(((provider, model), None), []).append(metric) + provider, model, llm_config = _resolve_pm(metric) + groups.setdefault( + ((provider, model, _llm_config_key(llm_config)), None), + [], + ).append(metric) for parent_id, children in children_by_parent.items(): - # Children of the same parent MUST end up in one bucket - # (no per-child provider/model split inside a hierarchy - # group) — otherwise we lose the mutex / consistency - # guarantees. Use the first child's resolved config. - provider, model = _resolve_pm(children[0]) + provider, model, llm_config = _resolve_pm(children[0]) groups.setdefault( - ((provider, model), parent_id), [] + ((provider, model, _llm_config_key(llm_config)), parent_id), + [], ).extend(children) # Top-level metric discovery is opt-in per evaluation. When @@ -842,12 +860,14 @@ def _resolve_pm(metric: Metric) -> tuple[str | None, str | None]: metric_discovery_emitted = False for (config, parent_id), bucket in groups.items(): - provider, model = config + provider, model, llm_config_key = config + llm_config = json.loads(llm_config_key) if llm_config_key else None evaluator_obj = None if provider and model: evaluator_obj = SimpleNamespace( llm_provider=provider, llm_model=model, + llm_config=llm_config, custom_prompt=None, ) parent_metric = ( diff --git a/app/workers/tasks/helpers/llm_evaluation.py b/app/workers/tasks/helpers/llm_evaluation.py index 5fe3e92c..01d13fc3 100644 --- a/app/workers/tasks/helpers/llm_evaluation.py +++ b/app/workers/tasks/helpers/llm_evaluation.py @@ -1192,14 +1192,15 @@ def evaluate_with_llm( ) evaluation_start_time = time.time() + evaluator_llm_config = getattr(evaluator, "llm_config", None) if evaluator else None llm_result = llm_service.generate_response( messages=messages, llm_provider=llm_provider, llm_model=llm_model, organization_id=organization_id, db=db, - temperature=0.3, - max_tokens=dynamic_max_tokens, + llm_config=evaluator_llm_config, + task_defaults={"temperature": 0.3, "max_tokens": dynamic_max_tokens}, ) evaluation_time = time.time() - evaluation_start_time diff --git a/app/workers/tasks/process_call_import_row.py b/app/workers/tasks/process_call_import_row.py index 929e5b17..c02d2fad 100644 --- a/app/workers/tasks/process_call_import_row.py +++ b/app/workers/tasks/process_call_import_row.py @@ -308,14 +308,7 @@ def process_call_import_row_task(self, row_id: str): if audio_bytes is None and original_csv_url: try: - if ( - client is not None - and provider_lookup_supported - and hasattr(client, "download_recording") - ): - fetched = client.download_recording(original_csv_url) - else: - fetched = download_public_recording(original_csv_url) + fetched = download_public_recording(original_csv_url) audio_bytes, content_type = fetched used_url = original_csv_url if primary_failure is not None: diff --git a/docs-fumadocs/.gitignore b/docs-fumadocs/.gitignore index 142f2016..5e3faef9 100644 --- a/docs-fumadocs/.gitignore +++ b/docs-fumadocs/.gitignore @@ -9,7 +9,6 @@ .next/ /out/ /build -/lambda-edge/**/dist/ *.tsbuildinfo # misc diff --git a/docs-fumadocs/CUTOVER.md b/docs-fumadocs/CUTOVER.md index 2c173356..d0dab357 100644 --- a/docs-fumadocs/CUTOVER.md +++ b/docs-fumadocs/CUTOVER.md @@ -1,6 +1,6 @@ # Docs Cutover and Stabilization -This runbook tracks the Fumadocs rollout and rollback strategy. +This runbook tracks the Fumadocs rollout strategy. ## Cutover steps @@ -11,8 +11,6 @@ This runbook tracks the Fumadocs rollout and rollback strategy. - `/docs/getting-started/installation` - `/docs/products/agents` - `/docs/monitoring/calls` - - `/docs/enterprise/unlock/` (public unlock form) - - `/docs/enterprise/overview/` (requires CloudFront Lambda@Edge password gate in production) 4. Verify contributor sections render on all feature pages. 5. Record cutover timestamp in the release notes. @@ -22,8 +20,5 @@ If production docs regress, redeploy the last known-good commit that built and e ## Stabilization checklist -- Check CloudFront/S3 404 metrics for docs paths for at least one release window. - Track search failures and broken-link reports. - Confirm no unresolved internal links from `npm run check:links`. -- Regenerate contributor metadata weekly or when major docs edits land: - - `npm run contributors:generate` diff --git a/docs-fumadocs/README.md b/docs-fumadocs/README.md index 342753a1..d231e29c 100644 --- a/docs-fumadocs/README.md +++ b/docs-fumadocs/README.md @@ -14,27 +14,20 @@ Open `http://localhost:3000/docs/intro`. - Docs content: `content/docs` - Navigation: `content/docs/meta.json` and section-level `meta.json` -- Contributor metadata: +- Contributor metadata (optional, static): - Manual owner overrides: `content/feature-owners.json` - - Generated contributor data: `content/feature-contributors.json` + - Contributor data: `content/feature-contributors.json` ## Checks ```bash -npm run contributors:generate npm run ci:check ``` -`ci:check` runs contributor consistency, docs validation, link checks, type checks, and production build. +`ci:check` runs docs validation, link checks, type checks, and production build. ## Deployment -Deployment is handled by `.github/workflows/docs.yml` (the `deploy` job) and publishes static output from `docs-fumadocs/out`. +Deployment is handled by `.github/workflows/docs.yml` (the `deploy` job) and publishes static output from `docs-fumadocs/out` to S3/CloudFront on pushes to `main` (or via manual workflow dispatch). -Rollback instructions are documented in `CUTOVER.md`. - -## Enterprise documentation - -Enterprise feature guides live under `content/docs/enterprise/`. They are excluded from the public search index and marked `noindex`. - -Production access is gated at CloudFront with Lambda@Edge (`lambda-edge/enterprise-auth/`). Deployment is handled by the docs GitHub Actions workflow using repository secrets for the enterprise password and cookie signing secret. +The `build` job runs the same checks on pull requests. Only public docs content under `content/docs/` is included — there is no separate enterprise docs section or password gate. diff --git a/docs-fumadocs/app/docs/[[...slug]]/page.tsx b/docs-fumadocs/app/docs/[[...slug]]/page.tsx index be09d8e3..83e8a4a9 100644 --- a/docs-fumadocs/app/docs/[[...slug]]/page.tsx +++ b/docs-fumadocs/app/docs/[[...slug]]/page.tsx @@ -74,18 +74,8 @@ export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): P const page = source.getPage(params.slug); if (!page) notFound(); - const isEnterprise = params.slug?.[0] === 'enterprise'; - return { title: page.data.title, description: page.data.description, - ...(isEnterprise - ? { - robots: { - index: false, - follow: false, - }, - } - : {}), }; } diff --git a/docs-fumadocs/content/docs/enterprise/call-imports.mdx b/docs-fumadocs/content/docs/enterprise/call-imports.mdx deleted file mode 100644 index 399be592..00000000 --- a/docs-fumadocs/content/docs/enterprise/call-imports.mdx +++ /dev/null @@ -1,48 +0,0 @@ ---- -id: enterprise-call-imports -title: Call Imports -sidebar_position: 3 ---- - -# Call Imports - -Call Imports is an enterprise feature (`call_imports`) for bulk-importing production call recordings via CSV and running batch evaluations on them. - -## Workflow overview - -Each import batch moves through these stages: - -1. **Upload** — upload a CSV dataset and optional audio files. -2. **Map** — map CSV columns to call-import fields using a schema. -3. **Evaluate** — run metrics and insights against imported rows. -4. **Review** — inspect per-row transcripts, scores, and insights. - -## Upload modes - -### CSV datasets - -Upload a spreadsheet of call metadata (transcripts, IDs, tags, etc.). Map columns to your schema before evaluation. - -### Audio uploads - -Upload audio files linked to import rows for diarization and transcript comparison metrics. - -## Schemas - -Define reusable column-mapping schemas under **Call Imports → Schemas**. Schemas are workspace-scoped and can be referenced by multiple import batches. - -## Tags - -Organize batches with call-import tags. Filter the imports list by tag to find related production slices. - -## Prompt partials - -Call Imports integrates with [Prompt Partials](/docs/products/prompt-partials). Use partials for evaluation rubrics and insights prompts during import workflows. - -## Transcript comparison metrics - -Enable **Compare transcripts** on a metric to run a transcript-pair judge at evaluation time. The worker feeds both the production transcript and the diarized transcript to the LLM. See [Metrics](/docs/products/metrics) for configuration details. - -## License requirement - -Set `EFFICIENTAI_LICENSE` with the `call_imports` feature. Without it, import routes and the Call Imports UI are unavailable. diff --git a/docs-fumadocs/content/docs/enterprise/overview.mdx b/docs-fumadocs/content/docs/enterprise/overview.mdx deleted file mode 100644 index c126b4e4..00000000 --- a/docs-fumadocs/content/docs/enterprise/overview.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: enterprise-overview -title: Enterprise Features -sidebar_position: 1 ---- - -# Enterprise Features - -These guides cover EfficientAI features that require an enterprise license (`EFFICIENTAI_LICENSE`). - -## Available guides - -| Feature | License feature ID | Guide | -| --- | --- | --- | -| Voice Playground | `voice_playground` | [Voice Playground](/docs/enterprise/voice-playground) | -| Call Imports | `call_imports` | [Call Imports](/docs/enterprise/call-imports) | -| Prompt Optimization | `gepa_optimization` | [Prompt Optimization](/docs/enterprise/prompt-optimization) | - -## License setup - -Request an enterprise license from the EfficientAI team, then set it in `config.yml` or via the `EFFICIENTAI_LICENSE` environment variable. See [Authentication](/docs/getting-started/authentication) for the full enterprise auth and license walkthrough. - -## Documentation access - -Enterprise documentation is password-protected. If you were redirected here, enter the documentation password provided with your enterprise license. diff --git a/docs-fumadocs/content/docs/enterprise/prompt-optimization.mdx b/docs-fumadocs/content/docs/enterprise/prompt-optimization.mdx deleted file mode 100644 index b3442b1c..00000000 --- a/docs-fumadocs/content/docs/enterprise/prompt-optimization.mdx +++ /dev/null @@ -1,54 +0,0 @@ ---- -id: enterprise-prompt-optimization -title: Prompt Optimization -sidebar_position: 4 ---- - -# Prompt Optimization - -Prompt Optimization is an enterprise feature (`gepa_optimization`) for iteratively improving agent prompts using evaluation feedback. - -## What gets optimized - -Each run starts from a seed prompt: - -- provider prompt, if available, otherwise -- internal agent description. - -The optimizer generates candidate prompts, evaluates them against available examples and enabled metrics, and ranks them by score. - -## Run configuration - -Common run controls include: - -- `max_metric_calls` for evaluation budget -- `minibatch_size` for examples per iteration - -These settings determine optimization depth, runtime, and cost. - -## Candidate workflow - -For each optimization run: - -1. Review generated candidates and their scores. -2. Compare candidate prompt text against the seed prompt. -3. **Accept** the candidate you want to promote. -4. Optionally **Push to Provider** to update the linked external agent prompt. - -## Push behavior - -When a candidate is pushed: - -- EfficientAI updates the prompt in the external provider, -- updates local agent prompt fields, -- records push timing for traceability. - -## Best practices - -- Use representative completed evaluator results as optimization data. -- Keep enabled metric sets stable while comparing candidates. -- Review prompt semantics in addition to score before pushing. - -## License requirement - -Set `EFFICIENTAI_LICENSE` with the `gepa_optimization` feature. Without it, optimization routes and the Optimization UI are unavailable. diff --git a/docs-fumadocs/content/docs/enterprise/unlock.mdx b/docs-fumadocs/content/docs/enterprise/unlock.mdx deleted file mode 100644 index 59cd149d..00000000 --- a/docs-fumadocs/content/docs/enterprise/unlock.mdx +++ /dev/null @@ -1,14 +0,0 @@ ---- -id: enterprise-unlock -title: Unlock Enterprise Docs ---- - -import { EnterpriseUnlockForm } from '@/components/enterprise-unlock-form'; - -# Enterprise Documentation - -These guides are available to enterprise customers. Enter the documentation password shared with your organization. - - - -If you do not have a password, contact the EfficientAI team or your account representative. diff --git a/docs-fumadocs/content/docs/enterprise/voice-playground.mdx b/docs-fumadocs/content/docs/enterprise/voice-playground.mdx deleted file mode 100644 index b02ef837..00000000 --- a/docs-fumadocs/content/docs/enterprise/voice-playground.mdx +++ /dev/null @@ -1,44 +0,0 @@ ---- -id: enterprise-voice-playground -title: Voice Playground -sidebar_position: 2 ---- - -# Voice Playground - -Voice Playground is an enterprise feature (`voice_playground`) for A/B testing TTS providers with real synthesis, blind tests, and automated evaluation. - -## What it does - -From the Voice Playground you can: - -- compare multiple TTS voices side by side on the same script, -- run benchmark simulations across providers, -- create blind-test shares for human raters, -- evaluate outputs with workspace [metrics](/docs/products/metrics) enabled for the `voice_playground` surface. - -## Tabs - -### Playground - -Configure a comparison: pick voices, enter or paste script text, and synthesize audio from each provider. Review waveforms, playback, and per-voice metric scores. - -### Voices - -Manage custom voice entries used in comparisons. Custom voices persist in your active [workspace](/docs/getting-started/workspaces). - -### Simulations - -Browse past benchmark runs. Re-open a comparison to review scores, transcripts, and metric breakdowns. - -### Blind Tests - -Create shareable blind-test links so raters can compare samples without seeing provider names. Owner-side share management requires the `voice_playground` license. - -## Metrics - -Enable metrics for the **Voice Playground** surface under [Metrics](/docs/products/metrics). Only metrics with that surface enabled appear during playground evaluation. - -## License requirement - -Set `EFFICIENTAI_LICENSE` with the `voice_playground` feature. Without it, the app shows the enterprise upgrade screen and API routes return `403 enterprise_feature_required`. diff --git a/docs-fumadocs/content/docs/getting-started/authentication.mdx b/docs-fumadocs/content/docs/getting-started/authentication.mdx index 992f2403..faacacaf 100644 --- a/docs-fumadocs/content/docs/getting-started/authentication.mdx +++ b/docs-fumadocs/content/docs/getting-started/authentication.mdx @@ -139,6 +139,10 @@ they can do. The role is stored per membership, so the *same user* can be an `admin` in one org and a `reader` in another. +:::note Organization role is not the same as workspace role +Organization roles (`reader` / `writer` / `admin`) apply org-wide. **Workspace roles** (`Viewer` / `Editor` / `Workspace Admin`) apply per workspace and control access to call imports, metrics, agents, and other scoped data in the active workspace. Both layers apply together — for example, an org **writer** with workspace **Viewer** can browse a workspace but cannot import or delete calls there. See [Workspaces — Access control](/docs/getting-started/workspaces#access-control-organization-vs-workspace) for the full hierarchy and action matrix. +::: + ### Inviting a teammate Admins invite teammates from **Settings → Team**. An invitation captures @@ -243,7 +247,7 @@ auth: oidc: issuer: "https://.okta.com" # REQUIRED - audience: "efficientai" # expected `aud` claim + audience: "efficientai" # REQUIRED — expected `aud` claim client_id: "0oa..." # SPA client id from step 2 # Default org for new users whose token has no org claim. @@ -259,6 +263,11 @@ The backend verifies every incoming Bearer token against the IdP's JWKS, which it auto-discovers from `/.well-known/openid-configuration`. You never copy public keys by hand. +When `external_oidc` is enabled, `issuer` and `audience` are mandatory. +The application fails at startup if either is unset, and every token's `aud` +claim must match `audience` — tokens issued for other applications at the +same IdP are rejected. + The same settings as env vars: ```bash title=".env" diff --git a/docs-fumadocs/content/docs/getting-started/workspaces.mdx b/docs-fumadocs/content/docs/getting-started/workspaces.mdx index cff21672..1fdb7b0d 100644 --- a/docs-fumadocs/content/docs/getting-started/workspaces.mdx +++ b/docs-fumadocs/content/docs/getting-started/workspaces.mdx @@ -24,6 +24,10 @@ From the switcher you can: - Switch to another workspace in your organization - Create a new workspace (display name + slug) +![Create workspace modal](/screenshots/create_workspace.png) + +When creating a workspace, you set a display name and optional slug. You are added automatically as **Workspace Admin**; you can optionally invite org members and assign each a workspace role (Viewer, Editor, or Workspace Admin) before saving. + When you switch workspaces, all data views refetch automatically so you never see stale rows from the previous workspace. --- @@ -36,6 +40,120 @@ If a request arrives without the header, the backend falls back to the organizat --- +## Access control: organization vs workspace + +EfficientAI uses **two independent permission layers**. Both apply on every request: + +1. **Organization role** — set per membership in **Settings → Team** (`reader`, `writer`, or `admin`). +2. **Workspace role** — set per workspace in **Identity & Access Management → Workspace Members** (`Viewer`, `Editor`, or `Workspace Admin`, plus optional custom roles). + +A user must satisfy **both** layers to perform an action. Your organization role controls whether you can write **anywhere** in the org; your workspace role controls what you can do **inside the active workspace**. + +```mermaid +flowchart TD + request[API request with X-Workspace-Id] + orgCheck{Org role} + wsCheck{Workspace capabilities} + allow[Action succeeds] + denyOrg["403: org reader cannot mutate"] + denyWs["403: insufficient workspace role"] + + request --> orgCheck + orgCheck -->|reader + POST/PATCH/DELETE| denyOrg + orgCheck -->|writer or admin| wsCheck + wsCheck -->|capability present| allow + wsCheck -->|capability missing| denyWs +``` + +### Organization roles + +| Role | Scope | Typical use | +| ---- | ----- | ----------- | +| **Reader** | Read-only for the **entire organization** | Auditors, stakeholders who only view dashboards | +| **Writer** | Create, update, and delete most org resources | Engineers and operators doing day-to-day work | +| **Admin** | Everything a writer can do, plus user/team management, API keys, and org settings | Org owners and IT admins | + +:::info Org readers are always read-only +If your organization role is **Reader**, every mutating API call (`POST`, `PATCH`, `DELETE`) is blocked — even if you hold **Workspace Admin** in a workspace. Workspace roles cannot override an org-level read-only membership. +::: + +Org admins **bypass workspace membership checks** and receive all workspace capabilities in every workspace. API keys also bypass workspace RBAC (they remain org-scoped). + +Manage organization roles from **Settings → Team** (admin only). See [Authentication — Team management](/docs/getting-started/authentication#team-management-invitations--organizations). + +### Workspace roles (system) + +Each workspace has its own membership list. When you are added to a workspace, you receive one of three seeded system roles (or a custom role defined by an org admin): + +| Workspace role | Can do | Cannot do | +| -------------- | ------ | --------- | +| **Viewer** | View calls, metrics, evals, simulations, reports, and workspace members | Import, edit, delete, run evaluations, change settings, manage members | +| **Editor** | Everything Viewer can do, plus create/update resources (import calls, manage metrics, run evals, manage simulations, generate reports) | Delete call imports, rename workspace, add/remove members, change workspace roles | +| **Workspace Admin** | Full access in that workspace, including delete, workspace settings, and member management | — | + +Roles are **cumulative**: Editor includes all Viewer permissions; Workspace Admin includes all Editor permissions. + +### What each role needs for common actions + +Use this table when planning access. “Org” = organization role; “Workspace” = role in the **active** workspace (from the switcher). + +| Action | Minimum org role | Minimum workspace role | +| ------ | ---------------- | ---------------------- | +| View call imports, agents, metrics | Reader | Viewer | +| Upload / import calls, edit rows | Writer | Editor | +| Delete call imports or batches | Writer | **Workspace Admin** | +| Create or edit metrics (workspace-scoped) | Writer | Editor | +| Run evaluations | Writer | Editor | +| Rename a workspace | Writer | **Workspace Admin** | +| Add/remove workspace members | Writer | **Workspace Admin** | +| Create a new workspace | Writer | *(creator becomes Workspace Admin automatically)* | +| Delete a workspace | Admin | *(org admin only)* | +| Manage organization users & invitations | Admin | *(not workspace-scoped)* | + +When a workspace check fails, the API returns a plain-language message such as *“This action requires at least the Editor role in the active workspace. Your current workspace role is Viewer.”* Delete operations require **Workspace Admin**, not Editor. + +### Capability domains (reference) + +Workspace permissions are implemented as **capabilities** grouped by product area. System roles are bundles of these capabilities; org admins can also define **custom workspace roles** in **IAM → Workspace Roles** by picking capabilities from this registry. + +| Domain | View | Create / edit / run | Delete / admin | +| ------ | ---- | ------------------- | -------------- | +| **Calls** (call imports) | View batches and rows | Import and update | Delete imports | +| **Metrics** | View definitions | Manage metrics | — | +| **Evaluations** | View runs and results | Run evaluations | — | +| **Simulation** | View agents, personas, scenarios | Manage simulation resources | — | +| **Reports** | View reports | Generate reports | — | +| **Workspace** | View member list | — | Rename workspace; add/remove members and roles | + +Custom roles are useful when a user needs a narrow slice of access (for example, view + run evals but not import calls). Assign them per workspace from **IAM → Workspace Members**. + +### Default access for new members + +When workspace RBAC is enabled or a new workspace is created: + +- New workspaces: the **creator** is added as **Workspace Admin**. +- Existing org members may be backfilled into workspaces with roles mapped from their org role: org admin → Workspace Admin, writer → Editor, reader → Viewer. + +Org admins should review **IAM → Workspace Members** after creating workspaces and remove or downgrade memberships that are too broad for your team model. + +### Managing workspace access + +1. Open **Identity & Access Management** in the sidebar. +2. Go to **Workspace Members**, select a workspace, and assign roles to org members. +3. Org admins can define custom roles under **Workspace Roles**. + +![IAM Workspace Members](/screenshots/iam_workspaces.png) + +The workspace dropdown shows your current role in the selected workspace (for example, **Viewer**). Use the members table to review who has access and change roles — if you hold **Workspace Admin** in that workspace and are an org Writer or Admin. + +Notes: + +- You can only manage members in a workspace if you are an **org Writer or Admin** **and** hold **Workspace Admin** (or org admin) in that workspace. +- **Workspace Admins cannot demote their own role**; another admin must change it. +- Users with org **Reader** can see member lists where allowed but cannot change memberships. + +--- + ## What is workspace-scoped Workspaces isolate the resources you interact with day to day, including: @@ -88,9 +206,16 @@ See [Metrics](/docs/products/metrics) for details on scope and categorization. Workspace management endpoints live under `/api/v1/workspaces`: -- `GET /workspaces` — list workspaces in your organization -- `POST /workspaces` — create a workspace (name, optional slug) -- `PATCH /workspaces/{id}` — rename a workspace -- `DELETE /workspaces/{id}` — delete a non-default workspace +- `GET /workspaces` — list workspaces you can access (includes your role name and capabilities per workspace) +- `POST /workspaces` — create a workspace (name, optional slug); requires org **writer** or **admin** +- `PATCH /workspaces/{id}` — rename a workspace; requires **Workspace Admin** in that workspace +- `DELETE /workspaces/{id}` — delete a non-default workspace; requires org **admin** + +Workspace membership and roles: + +- `GET /workspaces/{id}/members` — list members (requires `workspace.members.view`) +- `POST/PATCH/DELETE …/members` — manage membership (requires `workspace.members.manage`) +- `GET /workspace-roles` — list org workspace roles (for IAM UI) +- `GET /capabilities` — capability registry for custom role builder (authenticated) -All other scoped API calls should include `X-Workspace-Id` with the target workspace UUID. +All other scoped API calls should include `X-Workspace-Id` with the target workspace UUID. Routes enforce the minimum workspace capability for the HTTP method (view vs create/update vs delete). See [Access control](#access-control-organization-vs-workspace) above for how this maps to Viewer / Editor / Workspace Admin. diff --git a/docs-fumadocs/content/docs/intro.mdx b/docs-fumadocs/content/docs/intro.mdx index 9870a135..0d8321c6 100644 --- a/docs-fumadocs/content/docs/intro.mdx +++ b/docs-fumadocs/content/docs/intro.mdx @@ -48,7 +48,7 @@ This structure keeps testing reproducible while still reflecting real-world voic ## Key guides -- [Workspaces](/docs/getting-started/workspaces) — project isolation within your organization +- [Workspaces](/docs/getting-started/workspaces) — project isolation within your organization, plus workspace roles (Viewer / Editor / Workspace Admin) and how they interact with org roles - [Cloud Storage](/docs/getting-started/cloud-storage) — S3 and GCS configuration - [Metrics](/docs/products/metrics) — custom rubrics, surfaces, and evaluation scope - [Prompt Partials](/docs/products/prompt-partials) — versioned prompt templates diff --git a/docs-fumadocs/content/docs/meta.json b/docs-fumadocs/content/docs/meta.json index 4faa569f..61851900 100644 --- a/docs-fumadocs/content/docs/meta.json +++ b/docs-fumadocs/content/docs/meta.json @@ -7,7 +7,6 @@ "monitoring", "reference", "advanced", - "enterprise", "more" ] } diff --git a/docs-fumadocs/content/docs/products/call-imports.mdx b/docs-fumadocs/content/docs/products/call-imports.mdx index 614fe3df..54503aee 100644 --- a/docs-fumadocs/content/docs/products/call-imports.mdx +++ b/docs-fumadocs/content/docs/products/call-imports.mdx @@ -8,7 +8,7 @@ sidebar_position: 9 Call Imports lets you bulk-import production call recordings via CSV and run batch evaluations on them. -> **Enterprise feature** — requires `call_imports` in your `EFFICIENTAI_LICENSE`. Full documentation is available in the [Enterprise docs](/docs/enterprise/call-imports) (password required). +> **Enterprise feature** — requires `call_imports` in your `EFFICIENTAI_LICENSE`. ## At a glance @@ -16,4 +16,4 @@ Call Imports lets you bulk-import production call recordings via CSV and run bat - Map columns with reusable schemas - Run metrics and insights across imported production calls -Contact the EfficientAI team for a license and enterprise documentation access. +Contact the EfficientAI team for a license. diff --git a/docs-fumadocs/content/docs/products/prompt-optimization.mdx b/docs-fumadocs/content/docs/products/prompt-optimization.mdx index 65462fb4..7c9142f7 100644 --- a/docs-fumadocs/content/docs/products/prompt-optimization.mdx +++ b/docs-fumadocs/content/docs/products/prompt-optimization.mdx @@ -8,7 +8,7 @@ sidebar_position: 7 Prompt Optimization is an enterprise feature for iteratively improving agent prompts using evaluation feedback. -> **Enterprise feature** — requires `gepa_optimization` in your `EFFICIENTAI_LICENSE`. Full documentation is available in the [Enterprise docs](/docs/enterprise/prompt-optimization) (password required). +> **Enterprise feature** — requires `gepa_optimization` in your `EFFICIENTAI_LICENSE`. ## At a glance @@ -16,5 +16,5 @@ Prompt Optimization is an enterprise feature for iteratively improving agent pro - Accept winning candidates and optionally push to your voice provider - Control optimization depth with `max_metric_calls` and `minibatch_size` -Contact the EfficientAI team for a license and enterprise documentation access. +Contact the EfficientAI team for a license. diff --git a/docs-fumadocs/content/docs/products/voice-playground.mdx b/docs-fumadocs/content/docs/products/voice-playground.mdx index e17a73d7..a9235595 100644 --- a/docs-fumadocs/content/docs/products/voice-playground.mdx +++ b/docs-fumadocs/content/docs/products/voice-playground.mdx @@ -8,7 +8,7 @@ sidebar_position: 8 Voice Playground lets you A/B test TTS providers with real synthesis, blind tests, and automated evaluation. -> **Enterprise feature** — requires `voice_playground` in your `EFFICIENTAI_LICENSE`. Full documentation is available in the [Enterprise docs](/docs/enterprise/voice-playground) (password required). +> **Enterprise feature** — requires `voice_playground` in your `EFFICIENTAI_LICENSE`. ## At a glance @@ -16,4 +16,4 @@ Voice Playground lets you A/B test TTS providers with real synthesis, blind test - Run benchmark simulations and blind-test shares - Evaluate with metrics enabled for the Voice Playground surface -Contact the EfficientAI team for a license and enterprise documentation access. +Contact the EfficientAI team for a license. diff --git a/docs-fumadocs/content/feature-contributors.json b/docs-fumadocs/content/feature-contributors.json index e6ca37df..28ca6227 100644 --- a/docs-fumadocs/content/feature-contributors.json +++ b/docs-fumadocs/content/feature-contributors.json @@ -18,7 +18,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "advanced/database", @@ -35,7 +35,7 @@ "commits": 3 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "advanced/development", @@ -52,7 +52,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "enterprise/call-imports", @@ -133,18 +133,13 @@ "Tejas Narayan" ], "contributors": [ - { - "name": "aadhar-EAI", - "email": "aadhar@efficientai.cloud", - "commits": 1 - }, { "name": "Tejas Narayan", "email": "stejasnarayan@gmail.com", - "commits": 1 + "commits": 3 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "getting-started/cloud-storage", @@ -161,7 +156,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "getting-started/installation", @@ -178,7 +173,7 @@ "commits": 3 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "getting-started/integrations", @@ -195,7 +190,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "getting-started/voice-bundles", @@ -212,7 +207,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "getting-started/workspaces", @@ -227,9 +222,14 @@ "name": "aadhar-EAI", "email": "aadhar@efficientai.cloud", "commits": 1 + }, + { + "name": "Tejas Narayan", + "email": "stejasnarayan@gmail.com", + "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "intro", @@ -246,7 +246,7 @@ "commits": 3 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "monitoring/alerting", @@ -263,7 +263,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "monitoring/calls", @@ -285,7 +285,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "monitoring/cron-jobs", @@ -302,7 +302,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "more/pitch-ideas", @@ -316,7 +316,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "more/polls", @@ -330,7 +330,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "more/qna", @@ -344,7 +344,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "more/roadmap", @@ -358,7 +358,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/agents", @@ -375,7 +375,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/alerting", @@ -397,21 +397,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" - }, - { - "featureId": "products/call-imports", - "docPath": "docs-fumadocs/content/docs/products/call-imports.mdx", - "historyPath": "docs-fumadocs/content/docs/products/call-imports.mdx", - "owners": [], - "contributors": [ - { - "name": "Tejas Narayan", - "email": "stejasnarayan@gmail.com", - "commits": 1 - } - ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/evaluators", @@ -428,7 +414,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/metrics", @@ -445,7 +431,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/personas", @@ -462,7 +448,7 @@ "commits": 3 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/playground", @@ -479,7 +465,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/prompt-optimization", @@ -496,7 +482,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/prompt-partials", @@ -513,7 +499,7 @@ "commits": 1 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "products/scenarios", @@ -530,21 +516,7 @@ "commits": 3 } ], - "lastReviewed": "2026-06-13" - }, - { - "featureId": "products/voice-playground", - "docPath": "docs-fumadocs/content/docs/products/voice-playground.mdx", - "historyPath": "docs-fumadocs/content/docs/products/voice-playground.mdx", - "owners": [], - "contributors": [ - { - "name": "Tejas Narayan", - "email": "stejasnarayan@gmail.com", - "commits": 1 - } - ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "reference/cli-commands", @@ -561,7 +533,7 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" }, { "featureId": "reference/configuration", @@ -583,12 +555,12 @@ "commits": 2 } ], - "lastReviewed": "2026-06-13" + "lastReviewed": "2026-06-15" } ], "summary": { - "featureCount": 35, - "contributorCount": 39, + "featureCount": 28, + "contributorCount": 32, "skippedBots": 0 } } diff --git a/docs-fumadocs/lambda-edge/enterprise-auth/build.mjs b/docs-fumadocs/lambda-edge/enterprise-auth/build.mjs deleted file mode 100644 index f3601498..00000000 --- a/docs-fumadocs/lambda-edge/enterprise-auth/build.mjs +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); -const templatePath = path.join(dirname, 'index.template.js'); -const outputPath = path.join(dirname, 'dist', 'index.js'); - -function requireEnv(name) { - const value = process.env[name]; - if (!value) { - console.error(`Missing required environment variable: ${name}`); - process.exit(1); - } - return value; -} - -function main() { - const password = requireEnv('DOCS_ENTERPRISE_PASSWORD'); - const cookieSecret = process.env.DOCS_ENTERPRISE_COOKIE_SECRET || crypto.randomBytes(32).toString('hex'); - const passwordHash = crypto.createHash('sha256').update(password).digest('hex'); - - const template = fs.readFileSync(templatePath, 'utf8'); - const output = template - .replaceAll('{{PASSWORD_HASH}}', passwordHash) - .replaceAll('{{COOKIE_SECRET}}', cookieSecret); - - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, output, 'utf8'); - - console.log(`Built ${path.relative(process.cwd(), outputPath)}`); - if (!process.env.DOCS_ENTERPRISE_COOKIE_SECRET) { - console.log('Generated ephemeral DOCS_ENTERPRISE_COOKIE_SECRET for this build.'); - console.log('Set DOCS_ENTERPRISE_COOKIE_SECRET in your deploy environment to keep sessions stable across redeploys.'); - } -} - -main(); diff --git a/docs-fumadocs/lambda-edge/enterprise-auth/deploy.sh b/docs-fumadocs/lambda-edge/enterprise-auth/deploy.sh deleted file mode 100755 index c43473db..00000000 --- a/docs-fumadocs/lambda-edge/enterprise-auth/deploy.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DIST_DIR="${ROOT_DIR}/dist" -ZIP_PATH="${DIST_DIR}/enterprise-auth.zip" -FUNCTION_NAME="${DOCS_AUTH_LAMBDA_NAME:-efficientai-docs-enterprise-auth}" -AWS_REGION="${AWS_REGION:-us-east-1}" - -if [[ -z "${DOCS_ENTERPRISE_PASSWORD:-}" ]]; then - echo "Set DOCS_ENTERPRISE_PASSWORD before building/deploying." >&2 - exit 1 -fi - -node "${ROOT_DIR}/build.mjs" - -rm -f "${ZIP_PATH}" -(cd "${DIST_DIR}" && zip -q "${ZIP_PATH}" index.js) - -if [[ "${1:-}" == "--build-only" ]]; then - echo "Built ${ZIP_PATH}" - exit 0 -fi - -aws lambda update-function-code \ - --region "${AWS_REGION}" \ - --function-name "${FUNCTION_NAME}" \ - --zip-file "fileb://${ZIP_PATH}" - -aws lambda wait function-updated \ - --region "${AWS_REGION}" \ - --function-name "${FUNCTION_NAME}" - -NEW_VERSION="$(aws lambda publish-version \ - --region "${AWS_REGION}" \ - --function-name "${FUNCTION_NAME}" \ - --query 'Version' \ - --output text)" - -echo "Published Lambda version: ${NEW_VERSION}" -echo "Associate this version with your CloudFront /docs/enterprise/* cache behavior (viewer-request)." diff --git a/docs-fumadocs/lambda-edge/enterprise-auth/index.template.js b/docs-fumadocs/lambda-edge/enterprise-auth/index.template.js deleted file mode 100644 index 88fae928..00000000 --- a/docs-fumadocs/lambda-edge/enterprise-auth/index.template.js +++ /dev/null @@ -1,155 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); - -const PASSWORD_HASH = '{{PASSWORD_HASH}}'; -const COOKIE_SECRET = '{{COOKIE_SECRET}}'; -const SESSION_DAYS = 7; - -const COOKIE_NAME = 'docs_ent'; -const UNLOCK_PREFIX = '/docs/enterprise/unlock'; -const ENTERPRISE_PREFIX = '/docs/enterprise/'; - -function timingSafeEqual(a, b) { - if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) { - return false; - } - let result = 0; - for (let i = 0; i < a.length; i += 1) { - result |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return result === 0; -} - -function sha256(value) { - return crypto.createHash('sha256').update(value).digest('hex'); -} - -function signSession(expiry) { - return crypto.createHmac('sha256', COOKIE_SECRET).update(String(expiry)).digest('base64url'); -} - -function verifySession(value) { - if (!value) return false; - const separator = value.lastIndexOf('.'); - if (separator === -1) return false; - - const expiry = Number.parseInt(value.slice(0, separator), 10); - const signature = value.slice(separator + 1); - if (!Number.isFinite(expiry) || expiry < Date.now()) return false; - - const expected = signSession(expiry); - return timingSafeEqual(signature, expected); -} - -function parseCookies(headers) { - const cookieHeader = headers.cookie; - if (!cookieHeader) return {}; - - const cookies = {}; - for (const item of cookieHeader) { - const parts = item.value.split(';'); - for (const part of parts) { - const trimmed = part.trim(); - const separator = trimmed.indexOf('='); - if (separator === -1) continue; - const key = trimmed.slice(0, separator); - const value = trimmed.slice(separator + 1); - cookies[key] = value; - } - } - return cookies; -} - -function parseFormBody(body, isBase64) { - const raw = isBase64 ? Buffer.from(body, 'base64').toString('utf8') : body; - const params = new URLSearchParams(raw); - return { - password: params.get('password') || '', - returnTo: params.get('return') || '/docs/enterprise/overview/', - }; -} - -function normalizeUri(uri) { - return uri.endsWith('/') ? uri : `${uri}/`; -} - -function isUnlockPath(uri) { - return uri === '/docs/enterprise/unlock' || uri.startsWith('/docs/enterprise/unlock/'); -} - -function redirect(location, extraHeaders) { - const response = { - status: '302', - statusDescription: 'Found', - headers: { - location: [{ key: 'Location', value: location }], - 'cache-control': [{ key: 'Cache-Control', value: 'no-store' }], - }, - }; - - if (extraHeaders) { - Object.assign(response.headers, extraHeaders); - } - - return response; -} - -function passThrough(request) { - return request; -} - -exports.handler = async (event) => { - const request = event.Records[0].cf.request; - const uri = normalizeUri(request.uri); - - if (!uri.startsWith(ENTERPRISE_PREFIX)) { - return passThrough(request); - } - - if (isUnlockPath(uri)) { - if (request.method === 'POST' && request.body && request.body.data) { - const { password, returnTo } = parseFormBody( - request.body.data, - request.body.encoding === 'base64', - ); - const submittedHash = sha256(password); - - if (!timingSafeEqual(submittedHash, PASSWORD_HASH)) { - const errorTarget = `${UNLOCK_PREFIX}/?error=invalid&return=${encodeURIComponent(returnTo)}`; - return redirect(errorTarget); - } - - const expiry = Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000; - const cookieValue = `${expiry}.${signSession(expiry)}`; - const safeReturn = sanitizeReturnPath(returnTo); - - return redirect(safeReturn, { - 'set-cookie': [{ - key: 'Set-Cookie', - value: `${COOKIE_NAME}=${cookieValue}; Path=/docs/enterprise/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}`, - }], - }); - } - - return passThrough(request); - } - - const cookies = parseCookies(request.headers); - if (verifySession(cookies[COOKIE_NAME])) { - return passThrough(request); - } - - const returnTo = encodeURIComponent(uri); - return redirect(`${UNLOCK_PREFIX}/?return=${returnTo}`); -}; - -function sanitizeReturnPath(value) { - if (!value || !value.startsWith('/docs/enterprise/')) { - return '/docs/enterprise/overview/'; - } - if (isUnlockPath(value)) { - return '/docs/enterprise/overview/'; - } - return value.endsWith('/') ? value : `${value}/`; -} diff --git a/docs-fumadocs/package.json b/docs-fumadocs/package.json index c8eeccc5..d24c6180 100644 --- a/docs-fumadocs/package.json +++ b/docs-fumadocs/package.json @@ -9,13 +9,10 @@ "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", "postinstall": "fumadocs-mdx", "lint": "eslint", - "contributors:generate": "node scripts/generate-contributors.mjs", - "contributors:check": "node scripts/generate-contributors.mjs --check", "search:generate": "node scripts/generate-search-index.mjs", "validate:docs": "node scripts/validate-docs.mjs", "check:links": "node scripts/check-links.mjs", - "ci:check": "npm run contributors:check && npm run validate:docs && npm run check:links && npm run types:check && npm run build", - "lambda:edge:build": "node lambda-edge/enterprise-auth/build.mjs" + "ci:check": "npm run validate:docs && npm run check:links && npm run types:check && npm run build" }, "dependencies": { "fumadocs-core": "16.8.11", diff --git a/docs-fumadocs/public/screenshots/create_workspace.png b/docs-fumadocs/public/screenshots/create_workspace.png new file mode 100644 index 00000000..fae06b85 Binary files /dev/null and b/docs-fumadocs/public/screenshots/create_workspace.png differ diff --git a/docs-fumadocs/public/screenshots/iam_workspaces.png b/docs-fumadocs/public/screenshots/iam_workspaces.png new file mode 100644 index 00000000..43ecfb19 Binary files /dev/null and b/docs-fumadocs/public/screenshots/iam_workspaces.png differ diff --git a/docs-fumadocs/scripts/generate-contributors.mjs b/docs-fumadocs/scripts/generate-contributors.mjs deleted file mode 100644 index d74e7d0c..00000000 --- a/docs-fumadocs/scripts/generate-contributors.mjs +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node - -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -const REPO_ROOT = path.resolve(process.cwd(), '..'); -const DOCS_APP_ROOT = process.cwd(); -const CURRENT_DOCS_ROOT = 'docs-fumadocs/content/docs'; -const CURRENT_DOCS_ABS_ROOT = path.join(DOCS_APP_ROOT, 'content', 'docs'); -const OWNERS_PATH = path.join(DOCS_APP_ROOT, 'content', 'feature-owners.json'); -const OUTPUT_PATH = path.join(DOCS_APP_ROOT, 'content', 'feature-contributors.json'); -const MAX_CONTRIBUTORS = 5; -const MIN_COMMITS = 2; -const CHECK_MODE = process.argv.includes('--check'); -const BOT_PATTERN = /(\[bot\]|dependabot|github-actions|renovate)/i; - -function runGit(args) { - return execFileSync('git', args, { - cwd: REPO_ROOT, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -function discoverFeatureDocs(dirPath, prefix = '') { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const features = []; - - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - if (entry.isDirectory()) { - features.push(...discoverFeatureDocs(path.join(dirPath, entry.name), `${prefix}${entry.name}/`)); - continue; - } - - if (!entry.isFile() || !entry.name.endsWith('.mdx')) continue; - features.push(`${prefix}${entry.name.slice(0, -4)}`); - } - - return features.sort((a, b) => a.localeCompare(b)); -} - -function historyPathFor(featureId) { - const current = `${CURRENT_DOCS_ROOT}/${featureId}.mdx`; - return current; -} - -function isBotAuthor(name, email) { - return BOT_PATTERN.test(name) || BOT_PATTERN.test(email); -} - -function parseContributors(historyPath) { - let output = ''; - try { - output = runGit(['log', '--follow', '--format=%aN|%aE', '--', historyPath]); - } catch { - return []; - } - - const counts = new Map(); - let skippedBots = 0; - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - const [namePart, emailPart] = trimmed.split('|'); - const name = (namePart || '').trim(); - const email = (emailPart || '').trim().toLowerCase(); - if (!name || !email) continue; - if (isBotAuthor(name, email)) { - skippedBots += 1; - continue; - } - const key = email; - const existing = counts.get(key) || { name, email, commits: 0 }; - existing.commits += 1; - counts.set(key, existing); - } - - let contributors = Array.from(counts.values()).sort((a, b) => { - if (b.commits !== a.commits) return b.commits - a.commits; - return a.name.localeCompare(b.name); - }); - - const thresholdFiltered = contributors.filter((entry) => entry.commits >= MIN_COMMITS); - if (thresholdFiltered.length > 0) { - contributors = thresholdFiltered; - } else { - contributors = contributors.filter((entry) => entry.commits >= 1); - } - - return { - contributors: contributors.slice(0, MAX_CONTRIBUTORS), - skippedBots, - }; -} - -function readOwners() { - if (!fs.existsSync(OWNERS_PATH)) return {}; - return JSON.parse(fs.readFileSync(OWNERS_PATH, 'utf8')); -} - -function buildMetadata() { - const ownersByFeature = readOwners(); - const featureDocs = discoverFeatureDocs(CURRENT_DOCS_ABS_ROOT); - const now = new Date().toISOString().slice(0, 10); - let totalContributors = 0; - let totalSkippedBots = 0; - - const features = featureDocs.map((featureId) => { - const docPath = `${CURRENT_DOCS_ROOT}/${featureId}.mdx`; - const historyPath = historyPathFor(featureId); - const { contributors, skippedBots } = parseContributors(historyPath); - totalContributors += contributors.length; - totalSkippedBots += skippedBots; - - return { - featureId, - docPath, - historyPath, - owners: Array.isArray(ownersByFeature[featureId]) ? ownersByFeature[featureId] : [], - contributors, - lastReviewed: now, - }; - }); - - return { - source: 'git-history', - maxContributors: MAX_CONTRIBUTORS, - minCommits: MIN_COMMITS, - features, - summary: { - featureCount: featureDocs.length, - contributorCount: totalContributors, - skippedBots: totalSkippedBots, - }, - }; -} - -function normalizeForCheck(raw) { - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; - return { - ...parsed, - features: (parsed.features || []).map(({ lastReviewed, ...feature }) => feature), - }; -} - -function main() { - const metadata = buildMetadata(); - const nextData = `${JSON.stringify(metadata, null, 2)}\n`; - - if (CHECK_MODE) { - if (!fs.existsSync(OUTPUT_PATH)) { - console.error(`Missing generated file: ${OUTPUT_PATH}`); - process.exit(1); - } - const existing = fs.readFileSync(OUTPUT_PATH, 'utf8'); - const existingNormalized = JSON.stringify(normalizeForCheck(existing)); - const nextNormalized = JSON.stringify(normalizeForCheck(metadata)); - if (existingNormalized !== nextNormalized) { - console.error('feature-contributors.json is out of date. Run `npm run contributors:generate`.'); - process.exit(1); - } - return; - } - - fs.writeFileSync(OUTPUT_PATH, nextData, 'utf8'); - const parsed = JSON.parse(nextData); - console.log( - `Generated ${path.relative(DOCS_APP_ROOT, OUTPUT_PATH)} (${parsed.summary.featureCount} features, ${parsed.summary.contributorCount} contributors, ${parsed.summary.skippedBots} bot commits skipped)`, - ); -} - -main(); diff --git a/docs-fumadocs/scripts/generate-search-index.mjs b/docs-fumadocs/scripts/generate-search-index.mjs index 7fe5728c..f17f13cd 100644 --- a/docs-fumadocs/scripts/generate-search-index.mjs +++ b/docs-fumadocs/scripts/generate-search-index.mjs @@ -67,9 +67,7 @@ function toLabel(segment) { function buildRecords() { const files = walkDocs(DOCS_ROOT); - const records = files - .filter(({ relative }) => !relative.startsWith('enterprise/')) - .map(({ relative, absolute }) => { + const records = files.map(({ relative, absolute }) => { const raw = fs.readFileSync(absolute, 'utf8'); const { frontmatter, body } = stripFrontmatter(raw); const featureId = relative.replace(/\.mdx$/, ''); diff --git a/docs-fumadocs/scripts/validate-docs.mjs b/docs-fumadocs/scripts/validate-docs.mjs index f2b9f6d2..ebd1580a 100644 --- a/docs-fumadocs/scripts/validate-docs.mjs +++ b/docs-fumadocs/scripts/validate-docs.mjs @@ -5,23 +5,6 @@ import path from 'node:path'; const appRoot = process.cwd(); const docsRoot = path.join(appRoot, 'content', 'docs'); -const featureMetaPath = path.join(appRoot, 'content', 'feature-contributors.json'); -const featureDocs = new Set([ - 'products/agents', - 'products/personas', - 'products/scenarios', - 'products/evaluators', - 'products/metrics', - 'products/playground', - 'products/voice-playground', - 'products/call-imports', - 'products/alerting', - 'products/prompt-optimization', - 'products/prompt-partials', - 'monitoring/calls', - 'monitoring/alerting', - 'monitoring/cron-jobs', -]); function walkDocs(dir) { const results = []; @@ -64,7 +47,6 @@ const errors = []; const docs = walkDocs(docsRoot); for (const file of docs) { const rel = path.relative(docsRoot, file).replace(/\\/g, '/'); - const featureId = rel.replace(/\.mdx$/, ''); const content = fs.readFileSync(file, 'utf8'); const frontmatter = parseFrontmatter(content); if (!frontmatter) { @@ -74,25 +56,10 @@ for (const file of docs) { if (!frontmatter.get('title')) { errors.push(`${rel}: missing frontmatter title`); } - - // Feature pages now render contributors from a shared bottom-right widget. - // Keep validation focused on metadata presence rather than inline page markers. -} - -if (!fs.existsSync(featureMetaPath)) { - errors.push('content/feature-contributors.json does not exist'); -} else { - const featureMeta = JSON.parse(fs.readFileSync(featureMetaPath, 'utf8')); - const found = new Set((featureMeta.features || []).map((item) => item.featureId)); - for (const featureId of featureDocs) { - if (!found.has(featureId)) { - errors.push(`feature-contributors.json missing entry for ${featureId}`); - } - } } if (errors.length > 0) { fail(errors); } -console.log(`Validated ${docs.length} docs pages and contributor metadata.`); +console.log(`Validated ${docs.length} docs pages.`); diff --git a/docs/superpowers/specs/2026-06-11-workspace-rbac-design.md b/docs/superpowers/specs/2026-06-11-workspace-rbac-design.md new file mode 100644 index 00000000..e529cd3b --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-workspace-rbac-design.md @@ -0,0 +1,58 @@ +# Workspace RBAC: Capability-Based Access Control + +## Approved Design Decisions + +- **Default-closed**: org members only access workspaces they're explicitly granted; org admins implicitly access all workspaces. +- **Capability-based roles**: predefined system roles (Viewer, Editor, Workspace Admin) plus org-admin-definable custom roles composed from a fixed capability registry. +- **Membership management**: org admins + holders of `workspace.members.manage` in that workspace. +- **API keys stay org-wide** (bypass workspace RBAC); key scoping deferred. +- **Enforcement**: capability registry + role tables + per-route `require_capability()` dependencies. + +## Data Model + +### Capability registry (`app/core/auth/capabilities.py`) + +| Domain | Capabilities | +|---|---| +| Calls | `calls.view`, `calls.import`, `calls.delete` | +| Metrics | `metrics.view`, `metrics.manage` | +| Evaluations | `evals.view`, `evals.run` | +| Simulation | `sim.view`, `sim.manage` | +| Reports | `reports.view`, `reports.generate` | +| Workspace | `workspace.settings`, `workspace.members.view`, `workspace.members.manage` | + +### Tables + +- `workspace_roles`: `id`, `organization_id`, `name`, `description`, `capabilities` (JSON), `is_system`, timestamps; unique `(organization_id, name)` +- `workspace_members`: `id`, `workspace_id`, `user_id`, `role_id`, `added_by_user_id`, timestamps; unique `(workspace_id, user_id)` + +### System roles (seeded per org) + +- **Viewer** — all `*.view` capabilities +- **Editor** — Viewer + `calls.import`, `evals.run`, `metrics.manage`, `sim.manage`, `reports.generate` +- **Workspace Admin** — all capabilities + +### Backfill + +Every existing org member added to every org workspace: admin→Workspace Admin, writer→Editor, reader→Viewer. + +## Backend Enforcement + +- `WorkspaceContext` dependency resolves workspace + capability set +- Org admin → all capabilities; unbound API key → all; else membership lookup +- `require_capability(cap)` on workspace-scoped routes +- Org `ReaderReadOnlyMiddleware` unchanged + +## API + +- Workspace lifecycle: filtered list, create with auto-admin membership, settings/delete guards +- `workspace_iam.py`: members CRUD, workspace-roles CRUD, capabilities registry + +## Frontend + +- Filtered workspace switcher, members page, roles builder in IAM settings +- `useWorkspaceCapabilities()` hook for cosmetic UI gating + +## Rollout + +Backfill preserves existing access on upgrade; admins prune memberships via new UI. diff --git a/env.example b/env.example index 63ca0a52..cce2b551 100644 --- a/env.example +++ b/env.example @@ -45,8 +45,10 @@ AUTH_LOCAL_ALLOW_SIGNUP=true # External OIDC (requires an EFFICIENTAI_LICENSE with feature "oidc_sso") # Works with any OIDC-compliant IdP: Okta, Azure AD / Entra ID, # Google Workspace, AWS Cognito, Auth0, Ping, JumpCloud, OneLogin, etc. +# When external_oidc is listed in AUTH_PROVIDERS, both issuer and audience +# are REQUIRED — the application refuses to start if either is missing. # AUTH_OIDC_ISSUER=https://example.okta.com -# AUTH_OIDC_AUDIENCE=efficientai +# AUTH_OIDC_AUDIENCE=efficientai # REQUIRED with external_oidc (expected `aud` claim) # AUTH_OIDC_CLIENT_ID=0oa... # AUTH_OIDC_JWKS_URI=https://example.okta.com/oauth2/v1/keys # AUTH_OIDC_DEFAULT_ORG_NAME=Example Inc diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b05ec62..e590d2ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,7 +17,7 @@ "@vapi-ai/web": "^2.5.2", "@xyflow/react": "^12.3.5", "atoms-client-sdk": "^1.1.0", - "axios": "^1.6.2", + "axios": "^1.16.0", "clsx": "^2.1.0", "country-flag-icons": "^1.6.15", "dagre": "^0.8.5", @@ -25,7 +25,7 @@ "framer-motion": "^11.0.0", "livekit-client": "^2.17.0", "lucide-react": "^0.303.0", - "protobufjs": "^7.4.0", + "protobufjs": "^8.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", @@ -877,22 +877,20 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2484,22 +2482,20 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2676,17 +2672,16 @@ } }, "node_modules/@pipecat-ai/client-js": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@pipecat-ai/client-js/-/client-js-1.5.0.tgz", - "integrity": "sha512-WE8n5P98XoOyEX+k6DkcxBFaeoP82rDl3BEm4Zxf8tw1Umw5zFsv73qR6ztU4D5WDbYiscA3J+hGfe+g8PSUIw==", - "license": "BSD-2-Clause", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@pipecat-ai/client-js/-/client-js-1.11.0.tgz", + "integrity": "sha512-Au3mmyt+DQn49+gSaQOO+LoiOESxzr7GqSswEGAiuloOJIanqa/q9yOizD/Sl9wINr/qkcdDN2ejJOxhrh8KtQ==", "dependencies": { "@types/events": "^3.0.3", "bowser": "^2.11.0", "clone-deep": "^4.0.1", "events": "^3.3.0", "typed-emitter": "^2.1.0", - "uuid": "^10.0.0" + "uuid": "^11.1.1" } }, "node_modules/@pipecat-ai/websocket-transport": { @@ -2759,70 +2754,6 @@ "@protobuf-ts/runtime": "^2.11.1" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@react-aria/breadcrumbs": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.30.tgz", @@ -4173,10 +4104,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", - "license": "MIT", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "engines": { "node": ">=14.0.0" } @@ -4189,350 +4119,325 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz", - "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz", - "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz", - "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz", - "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz", - "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz", - "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz", - "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz", - "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz", - "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz", - "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz", - "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", "cpu": [ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz", - "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", "cpu": [ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz", - "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz", - "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz", - "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz", - "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz", - "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz", - "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz", - "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz", - "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz", - "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz", - "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz", - "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz", - "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz", - "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -4854,10 +4759,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" }, "node_modules/@types/estree-jsx": { "version": "1.0.5", @@ -4909,6 +4813,7 @@ "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5285,12 +5190,22 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5431,14 +5346,14 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/bail": { @@ -5488,11 +5403,10 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -6449,22 +6363,20 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6723,23 +6635,21 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -6920,22 +6830,20 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7106,6 +7014,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7537,10 +7457,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -8308,9 +8227,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -8318,7 +8237,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -8522,11 +8440,10 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, - "license": "MIT", "engines": { "node": ">=8.6" }, @@ -8555,9 +8472,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -8573,9 +8490,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8755,34 +8671,23 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.3.tgz", + "integrity": "sha512-alQyzT0j401LGBtwsqu6uprjR6pfNH1UJf9N6GBFMjIcd+HzTe0/HrjAbFCqun+zvnfLarrxAtMM2xvZ+kFZ5A==", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -8884,12 +8789,11 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", - "license": "MIT", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -8899,13 +8803,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", - "license": "MIT", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -9127,13 +9030,12 @@ } }, "node_modules/rollup": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", - "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", "dev": true, - "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -9143,31 +9045,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.2", - "@rollup/rollup-android-arm64": "4.55.2", - "@rollup/rollup-darwin-arm64": "4.55.2", - "@rollup/rollup-darwin-x64": "4.55.2", - "@rollup/rollup-freebsd-arm64": "4.55.2", - "@rollup/rollup-freebsd-x64": "4.55.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", - "@rollup/rollup-linux-arm-musleabihf": "4.55.2", - "@rollup/rollup-linux-arm64-gnu": "4.55.2", - "@rollup/rollup-linux-arm64-musl": "4.55.2", - "@rollup/rollup-linux-loong64-gnu": "4.55.2", - "@rollup/rollup-linux-loong64-musl": "4.55.2", - "@rollup/rollup-linux-ppc64-gnu": "4.55.2", - "@rollup/rollup-linux-ppc64-musl": "4.55.2", - "@rollup/rollup-linux-riscv64-gnu": "4.55.2", - "@rollup/rollup-linux-riscv64-musl": "4.55.2", - "@rollup/rollup-linux-s390x-gnu": "4.55.2", - "@rollup/rollup-linux-x64-gnu": "4.55.2", - "@rollup/rollup-linux-x64-musl": "4.55.2", - "@rollup/rollup-openbsd-x64": "4.55.2", - "@rollup/rollup-openharmony-arm64": "4.55.2", - "@rollup/rollup-win32-arm64-msvc": "4.55.2", - "@rollup/rollup-win32-ia32-msvc": "4.55.2", - "@rollup/rollup-win32-x64-gnu": "4.55.2", - "@rollup/rollup-win32-x64-msvc": "4.55.2", + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" } }, @@ -9553,11 +9455,10 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -9683,6 +9584,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -9875,16 +9777,15 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/vfile": { diff --git a/frontend/package.json b/frontend/package.json index c814701b..7dbf0926 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,7 @@ "@vapi-ai/web": "^2.5.2", "@xyflow/react": "^12.3.5", "atoms-client-sdk": "^1.1.0", - "axios": "^1.6.2", + "axios": "^1.16.0", "clsx": "^2.1.0", "country-flag-icons": "^1.6.15", "dagre": "^0.8.5", @@ -26,7 +26,7 @@ "framer-motion": "^11.0.0", "livekit-client": "^2.17.0", "lucide-react": "^0.303.0", - "protobufjs": "^7.4.0", + "protobufjs": "^8.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", diff --git a/frontend/public/fireworks.png b/frontend/public/fireworks.png new file mode 100644 index 00000000..f91b3802 Binary files /dev/null and b/frontend/public/fireworks.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a39e4072..be5947b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -163,6 +163,10 @@ function App() { } /> } /> } /> + } + /> } /> } /> } /> diff --git a/frontend/src/components/AIProviderModelPicker.tsx b/frontend/src/components/AIProviderModelPicker.tsx index c19ac2b0..37d6439d 100644 --- a/frontend/src/components/AIProviderModelPicker.tsx +++ b/frontend/src/components/AIProviderModelPicker.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' import { useQuery } from '@tanstack/react-query' import { Bot } from 'lucide-react' import { apiClient } from '../lib/api' +import LLMAdvancedOptionsPanel from './providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../config/llmGenerationParams' interface AIProviderRow { id: string @@ -37,15 +39,21 @@ export default function AIProviderModelPicker({ model, onProviderChange, onModelChange, + llm_config, + onLLMConfigChange, disabled = false, size = 'md', + showAdvancedOptions = true, }: { provider: string model: string onProviderChange: (next: string) => void onModelChange: (next: string) => void + llm_config?: LLMGenerationConfig | null + onLLMConfigChange?: (next: LLMGenerationConfig | null) => void disabled?: boolean size?: 'sm' | 'md' + showAdvancedOptions?: boolean }) { const { data: aiProviders = [] } = useQuery({ queryKey: ['ai-providers'], @@ -82,7 +90,8 @@ export default function AIProviderModelPicker({ : 'block text-xs font-medium text-gray-600 mb-1' return ( -
+
+
+
+ {showAdvancedOptions && provider && onLLMConfigChange && ( + + )}
) } diff --git a/frontend/src/components/CreateWorkspaceModal.tsx b/frontend/src/components/CreateWorkspaceModal.tsx new file mode 100644 index 00000000..2fa59e29 --- /dev/null +++ b/frontend/src/components/CreateWorkspaceModal.tsx @@ -0,0 +1,426 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Loader2, Plus, Trash2, Users, X } from 'lucide-react' +import { apiClient } from '../lib/api' +import type { OrganizationMember, Workspace, WorkspaceRole } from '../types/api' +import Button from './Button' +import { useToast } from '../hooks/useToast' +import { getApiErrorMessage } from '../lib/apiErrors' +import { useAuthStore } from '../store/authStore' + +export interface PendingWorkspaceMember { + user_id: string + role_id: string + user_email: string + role_name: string +} + +interface CreateWorkspaceModalProps { + open: boolean + onClose: () => void + onCreated: (workspace: Workspace) => void | Promise +} + +export default function CreateWorkspaceModal({ + open, + onClose, + onCreated, +}: CreateWorkspaceModalProps) { + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + const [pendingMembers, setPendingMembers] = useState([]) + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedRoleId, setSelectedRoleId] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const { showToast, ToastContainer } = useToast() + const currentUserId = useAuthStore((s) => s.user?.id ?? null) + + const { data: orgUsers = [] } = useQuery({ + queryKey: ['iam', 'users'], + queryFn: () => apiClient.listOrganizationUsers(), + enabled: open, + }) + + const { data: roles = [] } = useQuery({ + queryKey: ['workspace-roles'], + queryFn: () => apiClient.listWorkspaceRoles(), + enabled: open, + }) + + const pendingUserIds = useMemo( + () => new Set(pendingMembers.map((m) => m.user_id)), + [pendingMembers], + ) + + const availableUsers = useMemo( + () => + orgUsers.filter( + (u) => !pendingUserIds.has(u.user_id) && u.user_id !== currentUserId, + ), + [orgUsers, pendingUserIds, currentUserId], + ) + + const defaultRoleId = useMemo(() => { + const viewer = roles.find((r) => r.name === 'Viewer') + return viewer?.id ?? roles[0]?.id ?? '' + }, [roles]) + + const activeRoleId = selectedRoleId || defaultRoleId + + useEffect(() => { + if (open && defaultRoleId && !selectedRoleId) { + setSelectedRoleId(defaultRoleId) + } + }, [open, defaultRoleId, selectedRoleId]) + + useEffect(() => { + if (!open) return + const previous = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = previous + } + }, [open]) + + const resetForm = () => { + setName('') + setSlug('') + setPendingMembers([]) + setSelectedUserId('') + setSelectedRoleId('') + setError('') + } + + const handleClose = () => { + if (submitting) return + resetForm() + onClose() + } + + const handleAddMember = () => { + if (!selectedUserId || !activeRoleId) { + setError('Select a user and role to add.') + return + } + const user = orgUsers.find((u) => u.user_id === selectedUserId) + const role = roles.find((r) => r.id === activeRoleId) + if (!user || !role) return + + setPendingMembers((prev) => [ + ...prev, + { + user_id: user.user_id, + role_id: role.id, + user_email: user.user.email, + role_name: role.name, + }, + ]) + setSelectedUserId('') + setSelectedRoleId(defaultRoleId) + setError('') + } + + const handleAddAllMembers = () => { + const role = roles.find((r) => r.id === activeRoleId) + if (!role) { + setError('Select a role before adding members.') + return + } + if (availableUsers.length === 0) { + setError('All org members are already in the list.') + return + } + + setPendingMembers((prev) => [ + ...prev, + ...availableUsers.map((user: OrganizationMember) => ({ + user_id: user.user_id, + role_id: role.id, + user_email: user.user.email, + role_name: role.name, + })), + ]) + setSelectedUserId('') + setError('') + } + + const handleRemoveMember = (userId: string) => { + setPendingMembers((prev) => prev.filter((m) => m.user_id !== userId)) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const trimmedName = name.trim() + if (!trimmedName) { + setError('Workspace name is required.') + return + } + + setSubmitting(true) + setError('') + + try { + const created = await apiClient.createWorkspace({ + name: trimmedName, + ...(slug.trim() ? { slug: slug.trim() } : {}), + }) + + const failures: string[] = [] + for (const member of pendingMembers) { + try { + await apiClient.addWorkspaceMember(created.id, { + user_id: member.user_id, + role_id: member.role_id, + }) + } catch (memberErr: any) { + const detail = + memberErr?.response?.data?.detail || + `Could not add ${member.user_email}` + failures.push( + typeof detail === 'string' ? detail : `Could not add ${member.user_email}`, + ) + } + } + + resetForm() + await onCreated(created) + onClose() + + if (failures.length > 0) { + showToast( + `Workspace created, but some members could not be added: ${failures.join('; ')}`, + 'error', + ) + } else { + showToast('Workspace created', 'success') + } + } catch (err: unknown) { + const message = getApiErrorMessage(err, 'Could not create workspace.') + setError(message) + showToast(message, 'error') + } finally { + setSubmitting(false) + } + } + + if (!open) return null + + return createPortal( +
+ +
+
+
+
+

+ Create workspace +

+

+ Set a name and optionally invite org members. +

+
+ +
+ +
+
+
+
+ + setName(e.target.value)} + placeholder="e.g. Project Phoenix" + className="w-full min-w-0 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + autoFocus + disabled={submitting} + /> +
+ +
+ + + setSlug( + e.target.value.toLowerCase().replace(/[^a-z0-9-_]/g, '_'), + ) + } + placeholder="Derived from name if empty" + className="w-full min-w-0 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + disabled={submitting} + /> +
+
+ +
+
+ +

+ You are added automatically as Workspace Admin. +

+
+ +
+ + + + + + + +
+ + {pendingMembers.length === 0 ? ( +

+ No additional members yet. You can add them now or later from + Workspace Members settings. +

+ ) : ( +
    + {pendingMembers.map((member) => ( +
  • +
    +
    + {member.user_email} +
    +
    + {member.role_name} +
    +
    + +
  • + ))} +
+ )} +
+ + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/frontend/src/components/WorkspaceRolesSection.tsx b/frontend/src/components/WorkspaceRolesSection.tsx new file mode 100644 index 00000000..fbf329e2 --- /dev/null +++ b/frontend/src/components/WorkspaceRolesSection.tsx @@ -0,0 +1,482 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { ChevronDown, Pencil, Plus, Trash2, X } from 'lucide-react' +import { apiClient } from '../lib/api' +import { getApiErrorMessage } from '../lib/apiErrors' +import type { CapabilityDomain, CapabilityInfo, WorkspaceRole } from '../types/api' +import Button from './Button' +import { useToast } from '../hooks/useToast' + +function buildCapabilityLabelMap(domains: CapabilityDomain[]): Map { + const map = new Map() + for (const domain of domains) { + for (const cap of domain.capabilities) { + map.set(cap.key, cap.label) + } + } + return map +} + +export default function WorkspaceRolesSection() { + const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() + const [showCreate, setShowCreate] = useState(false) + const [expandedRoleIds, setExpandedRoleIds] = useState>(new Set()) + const [editingRoleId, setEditingRoleId] = useState(null) + + const { data: roles = [], isLoading: rolesLoading, error: rolesError } = useQuery({ + queryKey: ['workspace-roles'], + queryFn: () => apiClient.listWorkspaceRoles(), + }) + + const { data: domains = [] } = useQuery({ + queryKey: ['capabilities'], + queryFn: () => apiClient.listCapabilities(), + }) + + const capLabels = useMemo(() => buildCapabilityLabelMap(domains), [domains]) + + const createMutation = useMutation({ + mutationFn: (payload: { name: string; description?: string; capabilities: string[] }) => + apiClient.createWorkspaceRole(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) + setShowCreate(false) + showToast('Role created', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to create role'), 'error') + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ + roleId, + payload, + }: { + roleId: string + payload: { name?: string; description?: string | null; capabilities?: string[] } + }) => apiClient.updateWorkspaceRole(roleId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) + setEditingRoleId(null) + showToast('Role updated', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to update role'), 'error') + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (roleId: string) => apiClient.deleteWorkspaceRole(roleId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) + setEditingRoleId(null) + showToast('Role deleted', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to delete role'), 'error') + }, + }) + + const systemRoles = useMemo( + () => roles.filter((r: WorkspaceRole) => r.is_system), + [roles], + ) + const customRoles = useMemo( + () => roles.filter((r: WorkspaceRole) => !r.is_system), + [roles], + ) + + const toggleExpanded = (roleId: string) => { + setExpandedRoleIds((prev) => { + const next = new Set(prev) + if (next.has(roleId)) next.delete(roleId) + else next.add(roleId) + return next + }) + } + + const startEdit = (role: WorkspaceRole) => { + setEditingRoleId(role.id) + setExpandedRoleIds((prev) => new Set(prev).add(role.id)) + setShowCreate(false) + } + + return ( +
+ +
+
+

Workspace Roles

+

+ System roles are predefined. Org admins can create custom roles from capabilities. +

+
+ +
+ + {rolesError && ( +
+ {getApiErrorMessage(rolesError, 'Failed to load workspace roles')} +
+ )} + + {showCreate && ( + setShowCreate(false)} + onSubmit={(values) => createMutation.mutate(values)} + /> + )} + + {rolesLoading ? ( +
Loading roles…
+ ) : ( + <> + + deleteMutation.mutate(id)} + onCancelEdit={() => setEditingRoleId(null)} + onSaveEdit={(roleId, values) => updateMutation.mutate({ roleId, payload: values })} + isSaving={updateMutation.isPending} + /> + + )} +
+ ) +} + +function RoleList({ + title, + emptyMessage, + roles, + domains, + capLabels, + expandedRoleIds, + editingRoleId, + onToggleExpand, + onEdit, + onDelete, + onCancelEdit, + onSaveEdit, + isSaving, +}: { + title: string + emptyMessage: string + roles: WorkspaceRole[] + domains: CapabilityDomain[] + capLabels: Map + expandedRoleIds: Set + editingRoleId: string | null + onToggleExpand: (roleId: string) => void + onEdit?: (role: WorkspaceRole) => void + onDelete?: (id: string) => void + onCancelEdit?: () => void + onSaveEdit?: ( + roleId: string, + values: { name: string; description?: string; capabilities: string[] }, + ) => void + isSaving?: boolean +}) { + return ( +
+

{title}

+ {!roles.length ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {roles.map((role) => { + const expanded = expandedRoleIds.has(role.id) + const isEditing = editingRoleId === role.id + + return ( +
+
+ + +
+ {onEdit && !isEditing && ( + + )} + {onDelete && !isEditing && ( + + )} +
+
+ + {expanded && !isEditing && ( + + )} + + {expanded && isEditing && onCancelEdit && onSaveEdit && ( +
+ onSaveEdit(role.id, values)} + /> +
+ )} +
+ ) + })} +
+ )} +
+ ) +} + +function CapabilityDetails({ + role, + domains, + capLabels, +}: { + role: WorkspaceRole + domains: CapabilityDomain[] + capLabels: Map +}) { + const assigned = new Set(role.capabilities) + + return ( +
+ {domains.map((domain) => { + const domainCaps = domain.capabilities.filter((cap) => assigned.has(cap.key)) + if (!domainCaps.length) return null + + return ( +
+
+ {domain.label} +
+
+ {domainCaps.map((cap) => ( + + {capLabels.get(cap.key) ?? cap.label} + + ))} +
+
+ ) + })} + {role.capabilities.length === 0 && ( +
No capabilities assigned.
+ )} +
+ ) +} + +function RoleForm({ + title, + initialName = '', + initialDescription = '', + initialCapabilities = [], + domains, + submitLabel, + isPending, + onSubmit, + onCancel, +}: { + title: string + initialName?: string + initialDescription?: string + initialCapabilities?: string[] + domains: CapabilityDomain[] + submitLabel: string + isPending: boolean + onSubmit: (values: { name: string; description?: string; capabilities: string[] }) => void + onCancel: () => void +}) { + const [name, setName] = useState(initialName) + const [description, setDescription] = useState(initialDescription) + const [selectedCaps, setSelectedCaps] = useState>( + () => new Set(initialCapabilities), + ) + + const toggleCap = (cap: string) => { + setSelectedCaps((prev) => { + const next = new Set(prev) + if (next.has(cap)) next.delete(cap) + else next.add(cap) + return next + }) + } + + return ( +
+
+

{title}

+ +
+ setName(e.target.value)} + placeholder="Role name" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white" + /> + setDescription(e.target.value)} + placeholder="Description (optional)" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white" + /> + +
+ + +
+
+ ) +} + +function CapabilityPicker({ + domains, + selected, + onToggle, +}: { + domains: CapabilityDomain[] + selected: Set + onToggle: (cap: string) => void +}) { + return ( +
+ {domains.map((domain) => ( +
+
+ {domain.label} +
+
+ {domain.capabilities.map((cap: CapabilityInfo) => ( + + ))} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/WorkspaceSwitcher.tsx b/frontend/src/components/WorkspaceSwitcher.tsx index f68217d3..e242c6cb 100644 --- a/frontend/src/components/WorkspaceSwitcher.tsx +++ b/frontend/src/components/WorkspaceSwitcher.tsx @@ -1,32 +1,20 @@ import { useEffect, useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Check, ChevronDown, FolderKanban, Loader2, Plus, X } from 'lucide-react' +import { Check, ChevronDown, FolderKanban, Plus } from 'lucide-react' import { apiClient } from '../lib/api' import type { Workspace } from '../types/api' +import { useCanWrite } from '../hooks/useRole' +import { useWorkspaceStore } from '../store/workspaceStore' +import CreateWorkspaceModal from './CreateWorkspaceModal' -const ACTIVE_WORKSPACE_KEY = 'activeWorkspaceId' - -/** - * Workspace switcher dropdown. - * - * Lives in the sidebar so the active workspace context is always visible. - * Persists the selection in `localStorage` under `activeWorkspaceId`; the - * shared axios client picks that up and forwards it as `X-Workspace-Id` on - * every request, which is how the backend's `get_workspace_id` dependency - * scopes listings. - * - * On any change (initial bootstrap into Default, or user-selected switch) - * we blow away every react-query cache so views refetch against the new - * workspace - no stale rows leaking between projects. - */ export default function WorkspaceSwitcher() { const queryClient = useQueryClient() + const canWrite = useCanWrite() + const activeId = useWorkspaceStore((s) => s.activeWorkspaceId) + const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace) + const setActiveCapabilities = useWorkspaceStore((s) => s.setActiveCapabilities) const [open, setOpen] = useState(false) - const [showCreate, setShowCreate] = useState(false) - const [newName, setNewName] = useState('') - const [newSlug, setNewSlug] = useState('') - const [creating, setCreating] = useState(false) - const [error, setError] = useState('') + const [showCreateModal, setShowCreateModal] = useState(false) const { data: workspaces = [], @@ -38,215 +26,157 @@ export default function WorkspaceSwitcher() { staleTime: 60_000, }) - const [activeId, setActiveId] = useState(() => - typeof window !== 'undefined' - ? localStorage.getItem(ACTIVE_WORKSPACE_KEY) - : null, - ) - - // Bootstrap: if we don't have a stored workspace, or the stored one - // doesn't belong to this org (e.g. after an org switch), fall back to - // the org's Default workspace. Migration 033 guarantees one exists. useEffect(() => { if (!workspaces.length) return - const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY) + const stored = activeId const isValid = stored && workspaces.some((w) => w.id === stored) - if (isValid) { - if (stored !== activeId) setActiveId(stored) - return - } const fallback = - workspaces.find((w) => w.is_default) ?? workspaces[0] + (isValid ? workspaces.find((w) => w.id === stored) : null) ?? + workspaces.find((w) => w.is_default) ?? + workspaces[0] + if (!fallback) return - localStorage.setItem(ACTIVE_WORKSPACE_KEY, fallback.id) - setActiveId(fallback.id) - // Refetch workspace-scoped views now that the header will start being - // sent. Use a short delay so the localStorage write is visible to the - // axios request interceptor by the time queries refire. - queryClient.invalidateQueries() - }, [workspaces, activeId, queryClient]) + + if (!isValid) { + switchWorkspace(fallback.id, fallback.capabilities ?? []) + queryClient.invalidateQueries() + return + } + + const current = workspaces.find((w) => w.id === stored) + if (!current) return + + const nextCaps = current.capabilities ?? [] + const { activeCapabilities } = useWorkspaceStore.getState() + const capsChanged = + nextCaps.length !== activeCapabilities.length || + nextCaps.some((cap, i) => cap !== activeCapabilities[i]) + + if (capsChanged) { + setActiveCapabilities(nextCaps) + } + }, [workspaces, activeId, queryClient, switchWorkspace, setActiveCapabilities]) const activeWorkspace = useMemo( () => workspaces.find((w) => w.id === activeId) ?? null, [workspaces, activeId], ) - const handleSelect = async (workspaceId: string) => { - if (workspaceId === activeId) { + const handleSelect = async (workspace: Workspace) => { + if (workspace.id === activeId) { setOpen(false) return } - localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId) - setActiveId(workspaceId) + switchWorkspace(workspace.id, workspace.capabilities ?? []) setOpen(false) - // Invalidate all queries so the UI refetches against the new - // workspace's data. Same pattern as OrgSwitcher. await queryClient.invalidateQueries() } - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault() - const trimmedName = newName.trim() - const trimmedSlug = newSlug.trim() - if (!trimmedName) { - setError('Name is required') - return - } - setError('') - setCreating(true) - try { - const created = await apiClient.createWorkspace({ - name: trimmedName, - // Slug is optional - the backend derives it from the name - // when omitted. - ...(trimmedSlug ? { slug: trimmedSlug } : {}), - }) - await refetch() - setShowCreate(false) - setNewName('') - setNewSlug('') - await handleSelect(created.id) - } catch (err: any) { - setError( - err?.response?.data?.detail || 'Could not create workspace', - ) - } finally { - setCreating(false) - } + const handleWorkspaceCreated = async (created: Workspace) => { + await refetch() + setOpen(false) + await handleSelect(created) + } + + const openCreateModal = () => { + setOpen(false) + setShowCreateModal(true) } return ( -
- - - {open && ( - <> -
{ - setOpen(false) - setShowCreate(false) - setError('') - }} - /> -
-
- Workspaces - -
+ <> +
+ - {showCreate && ( -
- setNewName(e.target.value)} - placeholder="Workspace name" - className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary-500" - autoFocus - /> - - setNewSlug( - e.target.value - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-'), - ) - } - placeholder="slug (optional)" - className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary-500" - /> - {error && ( -
{error}
- )} - -
- )} - -
- {workspaces.length === 0 && !isLoading && ( -
- No workspaces yet. -
- )} - {workspaces.map((ws) => { - const isCurrent = ws.id === activeId - return ( + {open && ( + <> +
setOpen(false)} + /> +
+
+ Workspaces + {canWrite && ( - ) - })} + )} +
+ +
+ {workspaces.length === 0 && !isLoading && ( +
+ No workspaces available. + {canWrite && ( + + )} +
+ )} + {workspaces.map((ws) => { + const isCurrent = ws.id === activeId + return ( + + ) + })} +
-
- - )} -
+ + )} +
+ + setShowCreateModal(false)} + onCreated={handleWorkspaceCreated} + /> + ) } diff --git a/frontend/src/components/iam/WorkspaceMembersSection.tsx b/frontend/src/components/iam/WorkspaceMembersSection.tsx new file mode 100644 index 00000000..3a1edbc9 --- /dev/null +++ b/frontend/src/components/iam/WorkspaceMembersSection.tsx @@ -0,0 +1,405 @@ +import { useEffect, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ChevronDown, FolderKanban, Trash2, UserPlus, Users } from 'lucide-react' +import { apiClient } from '../../lib/api' +import { getApiErrorMessage } from '../../lib/apiErrors' +import { useCanWrite, useIsReader } from '../../hooks/useRole' +import { useToast } from '../../hooks/useToast' +import { useAuthStore } from '../../store/authStore' +import { useWorkspaceStore } from '../../store/workspaceStore' +import type { WorkspaceRole } from '../../types/api' +import Button from '../Button' + +const SELF_DEMOTE_MESSAGE = + 'You cannot demote your own Workspace Admin role. Ask another admin to change your role.' + +function workspaceCaps(caps: string[] | undefined) { + const list = caps ?? [] + return { + canViewMembers: list.includes('workspace.members.view'), + canManageMembers: list.includes('workspace.members.manage'), + } +} + +function isWorkspaceAdminRole(role: WorkspaceRole | undefined): boolean { + if (!role) return false + const caps = role.capabilities ?? [] + return ( + caps.includes('workspace.settings') && + caps.includes('workspace.members.manage') + ) +} + +export default function WorkspaceMembersSection() { + const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() + const canWrite = useCanWrite() + const isReader = useIsReader() + const currentUserId = useAuthStore((s) => s.user?.id ?? null) + const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null) + const [showAdd, setShowAdd] = useState(false) + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedRoleId, setSelectedRoleId] = useState('') + + const { data: workspaces = [], isLoading: workspacesLoading } = useQuery({ + queryKey: ['workspaces'], + queryFn: () => apiClient.listWorkspaces(), + }) + + useEffect(() => { + if (!workspaces.length) return + const stillValid = + selectedWorkspaceId && workspaces.some((w) => w.id === selectedWorkspaceId) + if (stillValid) return + + const fallback = + workspaces.find((w) => w.id === activeWorkspaceId) ?? workspaces[0] + setSelectedWorkspaceId(fallback.id) + }, [workspaces, activeWorkspaceId, selectedWorkspaceId]) + + const selectedWorkspace = useMemo( + () => workspaces.find((w) => w.id === selectedWorkspaceId) ?? null, + [workspaces, selectedWorkspaceId], + ) + + const { canViewMembers, canManageMembers: wsCanManageMembers } = workspaceCaps( + selectedWorkspace?.capabilities, + ) + const canManageMembers = wsCanManageMembers && canWrite + + const { + data: members = [], + isLoading: membersLoading, + error: membersError, + } = useQuery({ + queryKey: ['workspace-members', selectedWorkspaceId], + queryFn: () => apiClient.listWorkspaceMembers(selectedWorkspaceId!), + enabled: Boolean(selectedWorkspaceId) && canViewMembers, + }) + + useEffect(() => { + if (!membersError) return + showToast(getApiErrorMessage(membersError, 'Failed to load members'), 'error') + }, [membersError, showToast]) + + const { data: orgUsers = [] } = useQuery({ + queryKey: ['iam', 'users'], + queryFn: () => apiClient.listOrganizationUsers(), + enabled: canManageMembers, + }) + + const { data: roles = [] } = useQuery({ + queryKey: ['workspace-roles'], + queryFn: () => apiClient.listWorkspaceRoles(), + enabled: canViewMembers, + }) + + const addMutation = useMutation({ + mutationFn: () => + apiClient.addWorkspaceMember(selectedWorkspaceId!, { + user_id: selectedUserId, + role_id: selectedRoleId, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + setShowAdd(false) + setSelectedUserId('') + setSelectedRoleId('') + showToast('Member added', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to add member'), 'error') + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => + apiClient.updateWorkspaceMember(selectedWorkspaceId!, userId, { + role_id: roleId, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + showToast('Role updated', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to update role'), 'error') + }, + }) + + const removeMutation = useMutation({ + mutationFn: (userId: string) => + apiClient.removeWorkspaceMember(selectedWorkspaceId!, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + showToast('Member removed', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to remove member'), 'error') + }, + }) + + const handleRoleChange = ( + member: { user_id: string; role_id: string }, + newRoleId: string, + ) => { + if (newRoleId === member.role_id) return + + if (!canWrite) { + showToast( + "Your account has the 'reader' role and cannot create, update, or delete resources.", + 'error', + ) + return + } + + const oldRole = roles.find((r) => r.id === member.role_id) + const newRole = roles.find((r) => r.id === newRoleId) + if ( + member.user_id === currentUserId && + isWorkspaceAdminRole(oldRole) && + !isWorkspaceAdminRole(newRole) + ) { + showToast(SELF_DEMOTE_MESSAGE, 'error') + return + } + + updateMutation.mutate({ userId: member.user_id, roleId: newRoleId }) + } + + const handleRemoveMember = (userId: string) => { + if (!canWrite) { + showToast( + "Your account has the 'reader' role and cannot create, update, or delete resources.", + 'error', + ) + return + } + removeMutation.mutate(userId) + } + + const memberUserIds = new Set(members.map((m) => m.user_id)) + const availableUsers = orgUsers.filter((u) => !memberUserIds.has(u.user_id)) + + const showReaderNote = + isReader && + wsCanManageMembers && + selectedWorkspace?.role_name === 'Workspace Admin' + + return ( +
+ +
+
+

+ + Workspace Members +

+

+ Choose a workspace to view members and assign workspace roles. +

+
+ {canManageMembers && ( + + )} +
+ +
+
+ +
+ + + +
+ {selectedWorkspace && ( +

+ Slug: {selectedWorkspace.slug} + {selectedWorkspace.role_name && ( + <> + {' '} + · Your role:{' '} + + {selectedWorkspace.role_name} + + + )} +

+ )} + {showReaderNote && ( +

+ Your organization role is Reader, so member management is read-only + even though you hold the Workspace Admin role in this workspace. +

+ )} +
+ + {!selectedWorkspaceId ? ( +
+ {workspacesLoading ? 'Loading workspaces…' : 'Select a workspace.'} +
+ ) : !canViewMembers ? ( +
+ You do not have permission to view members for this workspace. +
+ ) : ( + <> + {showAdd && canManageMembers && ( +
+ + + +
+ )} + + {membersLoading ? ( +
+ Loading members… +
+ ) : ( +
+ + + + + + {canManageMembers && + + + {members.length === 0 ? ( + + + + ) : ( + members.map((member) => { + const memberRole = roles.find((r) => r.id === member.role_id) + const isSelfAdmin = + member.user_id === currentUserId && + isWorkspaceAdminRole(memberRole) + + return ( + + + + {canManageMembers && ( + + )} + + ) + }) + )} + +
+ User + + Role + } +
+ No members in this workspace yet. +
+
+ {member.user_email} +
+ {member.user_name && ( +
+ {member.user_name} +
+ )} +
+ {canManageMembers && !isSelfAdmin ? ( + + ) : ( + member.role_name + )} + + +
+
+ )} + + )} +
+
+ ) +} diff --git a/frontend/src/components/providers/LLMAdvancedOptionsPanel.tsx b/frontend/src/components/providers/LLMAdvancedOptionsPanel.tsx new file mode 100644 index 00000000..02c51985 --- /dev/null +++ b/frontend/src/components/providers/LLMAdvancedOptionsPanel.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { ChevronDown, ChevronUp } from 'lucide-react' +import { + getVisibleLLMParams, + isLLMGenerationConfigEmpty, + normalizeLLMConfig, + summarizeLLMConfig, + type LLMGenerationConfig, + type LLMGenerationParamKey, +} from '../../config/llmGenerationParams' + +interface LLMAdvancedOptionsPanelProps { + provider: string | null | undefined + value: LLMGenerationConfig | null | undefined + onChange: (next: LLMGenerationConfig | null) => void + disabled?: boolean + /** When true, show Gemini thinking note. */ + showGeminiNote?: boolean + className?: string +} + +export default function LLMAdvancedOptionsPanel({ + provider, + value, + onChange, + disabled = false, + showGeminiNote = true, + className, +}: LLMAdvancedOptionsPanelProps) { + const [showAdvanced, setShowAdvanced] = useState(false) + const params = getVisibleLLMParams(provider) + const summary = summarizeLLMConfig(value) + const providerKey = (provider || '').toLowerCase() + + const handleFieldChange = (key: LLMGenerationParamKey, raw: string) => { + const next: LLMGenerationConfig = { ...(value || {}) } + if (raw === '' || raw === undefined) { + delete next[key] + } else { + const meta = params.find((p) => p.key === key) + next[key] = meta?.integer ? parseInt(raw, 10) : parseFloat(raw) + } + onChange(normalizeLLMConfig(next)) + } + + const handleReset = () => { + onChange(null) + } + + if (!provider) { + return null + } + + return ( +
+ + {showAdvanced && ( +
+ {showGeminiNote && providerKey === 'google' && ( +

+ Gemini thinking level is managed automatically for structured evaluation tasks. +

+ )} +
+ {params.map((param) => ( +
+ + handleFieldChange(param.key, e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white disabled:bg-gray-50" + /> + {param.helpText && ( +

{param.helpText}

+ )} +
+ ))} +
+ {!isLLMGenerationConfigEmpty(value) && ( + + )} +
+ )} +
+ ) +} + +export type { LLMGenerationConfig } diff --git a/frontend/src/components/providers/ProviderModelPicker.tsx b/frontend/src/components/providers/ProviderModelPicker.tsx index d07dc12d..c2f26032 100644 --- a/frontend/src/components/providers/ProviderModelPicker.tsx +++ b/frontend/src/components/providers/ProviderModelPicker.tsx @@ -29,12 +29,15 @@ import { Bot, AudioLines } from 'lucide-react' import { apiClient } from '../../lib/api' import type { Integration } from '../../types/api' +import LLMAdvancedOptionsPanel from './LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../config/llmGenerationParams' const PROVIDER_LABELS: Record = { openai: 'OpenAI', anthropic: 'Anthropic', openrouter: 'OpenRouter', xai: 'xAI', + fireworks: 'Fireworks AI', google: 'Google', cohere: 'Cohere', mistral: 'Mistral', @@ -53,6 +56,7 @@ export interface ProviderModelValue { provider: string | null model: string | null credential_id?: string | null + llm_config?: LLMGenerationConfig | null } interface AIProviderRow { @@ -106,6 +110,8 @@ export interface ProviderModelPickerProps { * without a code change. */ audioCapableOnly?: boolean + /** When false, hide the LLM advanced options panel (LLM kind only). */ + showAdvancedOptions?: boolean } // Per-provider substring fingerprints for "this chat model accepts audio @@ -141,6 +147,7 @@ export default function ProviderModelPicker({ allowCredentialPick = false, disabled = false, audioCapableOnly = false, + showAdvancedOptions = true, }: ProviderModelPickerProps) { const { data: aiProviders = [] } = useQuery({ queryKey: ['ai-providers'], @@ -353,6 +360,14 @@ export default function ProviderModelPicker({
)} + {kind === 'llm' && showAdvancedOptions && value.provider && ( + onChange({ ...value, llm_config })} + /> + )}
) } diff --git a/frontend/src/components/shared/AIGeneratePanel.tsx b/frontend/src/components/shared/AIGeneratePanel.tsx index 739a1503..8a1a1812 100644 --- a/frontend/src/components/shared/AIGeneratePanel.tsx +++ b/frontend/src/components/shared/AIGeneratePanel.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect, useRef } from 'react' -import { useQuery, useMutation } from '@tanstack/react-query' +import { useState, useRef, useEffect } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' import { Sparkles, Loader2, Bot, ChevronDown } from 'lucide-react' import { apiClient } from '../../lib/api' import { getProviderLabel, getProviderLogo } from '../../config/providers' import { ModelProvider } from '../../types/api' +import LLMAdvancedOptionsPanel from '../providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../config/llmGenerationParams' interface AIProvider { id: string @@ -29,6 +31,7 @@ interface AIGeneratePanelProps { format_style?: string provider?: string model?: string + llm_config?: LLMGenerationConfig | null }) => Promise<{ content: string }> title?: string placeholder?: string @@ -48,6 +51,7 @@ export default function AIGeneratePanel({ const [format, setFormat] = useState('structured') const [provider, setProvider] = useState('') const [model, setModel] = useState('') + const [llmConfig, setLlmConfig] = useState(null) const [showProviderDropdown, setShowProviderDropdown] = useState(false) const providerDropdownRef = useRef(null) @@ -100,6 +104,7 @@ export default function AIGeneratePanel({ format_style: showToneAndFormat ? format : undefined, ...(provider ? { provider } : {}), ...(model ? { model } : {}), + ...(llmConfig ? { llm_config: llmConfig } : {}), }) } @@ -242,6 +247,15 @@ export default function AIGeneratePanel({
+ + {provider && ( + + )}
- - + + -
-
- -
- - -
- - {/* + + + +
+ + +
+ + {/* High-level dataset segregation lives at the top of the page so users can scope all filtering/searching that follows to a specific dataset. We intentionally render this above the main card to make it visually @@ -289,11 +293,11 @@ export default function CallImports() { )} )} - -

- {total} {activeTab === 'audio' ? 'manual upload' : 'dataset import'} - {total === 1 ? '' : 's'} -

+ +

+ {total} {activeTab === 'audio' ? 'manual upload' : 'dataset import'} + {total === 1 ? '' : 's'} +

{isLoading ? ( @@ -303,30 +307,30 @@ export default function CallImports() { ) : items.length === 0 ? (
- -

- {statusFilter - ? 'No imports match this filter.' - : activeTab === 'audio' - ? 'No manual audio uploads yet.' - : 'No dataset uploads yet.'} -

- {!statusFilter && ( - - )} + +

+ {statusFilter + ? 'No imports match this filter.' + : activeTab === 'audio' + ? 'No manual audio uploads yet.' + : 'No dataset uploads yet.'} +

+ {!statusFilter && ( + + )}
) : (
@@ -372,12 +376,12 @@ export default function CallImports() {
- {item.source_format === 'audio' ? ( - - - Manual upload - - ) : item.provider || ( + {item.source_format === 'audio' ? ( + + + Manual upload + + ) : item.provider || ( @@ -488,14 +492,14 @@ export default function CallImports() { )} - - setShowUpload(false)} /> - setShowAudioUpload(false)} - /> - - setShowUpload(false)} /> + setShowAudioUpload(false)} + /> + + { diff --git a/frontend/src/pages/callImports/Schemas.tsx b/frontend/src/pages/callImports/Schemas.tsx index 6cb1b8ef..d503ca1d 100644 --- a/frontend/src/pages/callImports/Schemas.tsx +++ b/frontend/src/pages/callImports/Schemas.tsx @@ -13,6 +13,8 @@ import { X, } from 'lucide-react' import { apiClient } from '../../lib/api' +import { getApiErrorMessage } from '../../lib/apiErrors' +import { useToast } from '../../hooks/useToast' import type { CallImportSchema, CallImportSchemaParameter, @@ -625,6 +627,7 @@ function DeleteSchemaModal({ export default function CallImportSchemasPage() { const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() const [editorOpen, setEditorOpen] = useState(false) const [editingSchema, setEditingSchema] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) @@ -650,10 +653,10 @@ export default function CallImportSchemasPage() { setPendingDelete(null) setDeleteError(null) }, - onError: (err: any) => { - setDeleteError( - err?.response?.data?.detail || err?.message || 'Failed to delete schema.', - ) + onError: (err: unknown) => { + const message = getApiErrorMessage(err, 'Failed to delete schema.') + setDeleteError(message) + showToast(message, 'error') }, }) @@ -665,6 +668,7 @@ export default function CallImportSchemasPage() { return (
+
{ + showToast(getApiErrorMessage(err, 'Failed to delete tag.'), 'error') + }, }) return (
+
(null) const [llmPickerTouched, setLlmPickerTouched] = useState(false) const [agentPickerTouched, setAgentPickerTouched] = useState(false) const [error, setError] = useState(null) @@ -293,6 +295,8 @@ export default function MetricPromptImprovementsPanel({ setLlmPickerTouched(true) setPickerModel(next) }} + llm_config={pickerLlmConfig} + onLLMConfigChange={setPickerLlmConfig} disabled={generateMutation.isPending} size="sm" /> diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index ab22db81..bec11b6a 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -24,6 +24,7 @@ const AI_INTEGRATION_PROVIDERS: ModelProvider[] = [ ModelProvider.ANTHROPIC, ModelProvider.GOOGLE, ModelProvider.XAI, + ModelProvider.FIREWORKS, ModelProvider.COHERE, ModelProvider.MISTRAL, ModelProvider.META, diff --git a/frontend/src/pages/configurations/VoiceBundles.tsx b/frontend/src/pages/configurations/VoiceBundles.tsx index 6b80ec5b..54f8360e 100644 --- a/frontend/src/pages/configurations/VoiceBundles.tsx +++ b/frontend/src/pages/configurations/VoiceBundles.tsx @@ -8,6 +8,12 @@ import Button from '../../components/Button' import { useToast } from '../../hooks/useToast' import { getProviderLabel, getProviderLogo, mapIntegrationToModelProvider } from '../../config/providers' import WalkthroughToggleButton from '../../components/walkthrough/WalkthroughToggleButton' +import LLMAdvancedOptionsPanel from '../../components/providers/LLMAdvancedOptionsPanel' +import { + isLLMGenerationConfigEmpty, + summarizeLLMConfig, + type LLMGenerationConfig, +} from '../../config/llmGenerationParams' export default function VoiceBundles() { const queryClient = useQueryClient() @@ -26,8 +32,7 @@ export default function VoiceBundles() { stt_credential_id: null, llm_provider: ModelProvider.OPENAI, llm_model: 'gpt-4', - llm_temperature: 0.7, - llm_max_tokens: null, + llm_config: { temperature: 0.7 }, llm_credential_id: null, tts_provider: ModelProvider.OPENAI, tts_model: 'tts-1', @@ -239,6 +244,17 @@ export default function VoiceBundles() { }, }) + const bundleLLMConfig = (bundle: VoiceBundle): LLMGenerationConfig | null => { + const config: LLMGenerationConfig = { ...(bundle.llm_config || {}) } + if (config.temperature == null && bundle.llm_temperature != null) { + config.temperature = bundle.llm_temperature + } + if (config.max_tokens == null && bundle.llm_max_tokens != null) { + config.max_tokens = bundle.llm_max_tokens + } + return isLLMGenerationConfigEmpty(config) ? null : config + } + const resetForm = () => { const defaultSttProvider = getDefaultProvider(null, 'stt') const defaultLlmProvider = getDefaultProvider(null, 'llm') @@ -257,8 +273,7 @@ export default function VoiceBundles() { stt_credential_id: null, llm_provider: defaultLlmProvider, llm_model: defaultLlmModel, - llm_temperature: 0.7, - llm_max_tokens: null, + llm_config: { temperature: 0.7 }, llm_credential_id: null, tts_provider: defaultTtsProvider, tts_model: defaultTtsModel, @@ -286,8 +301,7 @@ export default function VoiceBundles() { stt_credential_id: bundle.stt_credential_id || null, llm_provider: bundle.llm_provider ? getDefaultProvider(bundle.llm_provider as ModelProvider, 'llm') : null, llm_model: bundle.llm_model || null, - llm_temperature: bundle.llm_temperature || 0.7, - llm_max_tokens: bundle.llm_max_tokens || null, + llm_config: bundleLLMConfig(bundle), llm_credential_id: bundle.llm_credential_id || null, tts_provider: bundle.tts_provider ? getDefaultProvider(bundle.tts_provider as ModelProvider, 'tts') : null, tts_model: bundle.tts_model || null, @@ -509,10 +523,10 @@ export default function VoiceBundles() { {getProviderLabel(bundle.llm_provider as ModelProvider)} {bundle.llm_model} - {bundle.llm_temperature && ( + {summarizeLLMConfig(bundleLLMConfig(bundle)) && ( <> - Temp: {bundle.llm_temperature} + {summarizeLLMConfig(bundleLLMConfig(bundle))} )}
@@ -1104,33 +1118,18 @@ function VoiceBundleModal({ )}
-
- - setFormData({ ...formData, llm_temperature: parseFloat(e.target.value) || 0.7 })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - /> -
-
- - setFormData({ ...formData, llm_max_tokens: e.target.value ? parseInt(e.target.value) : null })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="Leave empty for default" +
+ + setFormData({ + ...formData, + llm_config, + llm_temperature: null, + llm_max_tokens: null, + }) + } />
{renderCredentialPicker( diff --git a/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx b/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx index a5c76d71..2530434d 100644 --- a/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx +++ b/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx @@ -8,6 +8,8 @@ import Button from '../../../components/Button' import { ArrowLeft, Edit, Save, X, Phone, Globe, Trash2, AlertCircle, Brain, ChevronDown } from 'lucide-react' import { useToast } from '../../../hooks/useToast' import { getProviderLabel, getProviderLogo } from '../../../config/providers' +import LLMAdvancedOptionsPanel from '../../../components/providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../../config/llmGenerationParams' interface Evaluator { id: string @@ -20,6 +22,7 @@ interface Evaluator { metric_ids?: string[] | null llm_provider?: string | null llm_model?: string | null + llm_config?: LLMGenerationConfig | null tags?: string[] created_at: string updated_at: string @@ -279,6 +282,7 @@ export default function EvaluatorDetail() { } if (editData.llm_provider) data.llm_provider = editData.llm_provider if (editData.llm_model) data.llm_model = editData.llm_model + if (editData.llm_config !== undefined) data.llm_config = editData.llm_config updateMutation.mutate({ evalId: evaluator.id, data }) } @@ -879,6 +883,15 @@ export default function EvaluatorDetail() { )}
+
+ + setEditData({ ...editData, llm_config }) + } + /> +
)}
diff --git a/frontend/src/pages/iam/IAM.tsx b/frontend/src/pages/iam/IAM.tsx index 5c6e955c..5001d2f5 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -1,16 +1,46 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient } from '../../lib/api' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { Role, Invitation, OrganizationMember, InvitationCreate } from '../../types/api' import { Users, Mail, UserPlus, Shield, ShieldCheck, ShieldAlert, X, Trash2, KeyRound, Eye, EyeOff, Building2, Copy, Check } from 'lucide-react' import Button from '../../components/Button' import { useToast } from '../../hooks/useToast' +import { getApiErrorMessage } from '../../lib/apiErrors' import { useIsAdmin } from '../../hooks/useRole' +import WorkspaceRolesSection from '../../components/WorkspaceRolesSection' +import WorkspaceMembersSection from '../../components/iam/WorkspaceMembersSection' + +type IamTab = 'organization' | 'workspace-members' | 'workspace-roles' + +const IAM_TABS: { id: IamTab; label: string; icon: typeof Building2; adminOnly?: boolean }[] = [ + { id: 'organization', label: 'Organization', icon: Building2 }, + { id: 'workspace-members', label: 'Workspace Members', icon: Users }, + { id: 'workspace-roles', label: 'Workspace Roles', icon: Shield, adminOnly: true }, +] export default function IAM() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() const isAdmin = useIsAdmin() + const [searchParams, setSearchParams] = useSearchParams() + const tabParam = searchParams.get('tab') + const activeTab: IamTab = + tabParam === 'workspace-members' || tabParam === 'workspace-roles' + ? tabParam + : 'organization' + + const visibleTabs = IAM_TABS.filter((t) => !t.adminOnly || isAdmin) + + useEffect(() => { + if (activeTab === 'workspace-roles' && !isAdmin) { + setSearchParams({ tab: 'organization' }, { replace: true }) + } + }, [activeTab, isAdmin, setSearchParams]) + + const setActiveTab = (tab: IamTab) => { + setSearchParams(tab === 'organization' ? {} : { tab }) + } const [showInviteModal, setShowInviteModal] = useState(false) const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState(Role.READER) @@ -67,6 +97,10 @@ export default function IAM() { setShowInviteModal(false) setInviteEmail('') setInviteRole(Role.READER) + showToast('Invitation sent', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to send invitation'), 'error') }, }) @@ -237,12 +271,17 @@ export default function IAM() {

Identity & Access Management

- {isAdmin - ? 'Manage users and their permissions in your organization' - : 'View users and pending invitations in your organization'} + {activeTab === 'organization' && + (isAdmin + ? 'Manage organization users, invitations, and settings' + : 'View users and pending invitations in your organization')} + {activeTab === 'workspace-members' && + 'Manage workspace access and assign roles per workspace'} + {activeTab === 'workspace-roles' && + 'Configure workspace roles and their capabilities'}

- {isAdmin && ( + {isAdmin && activeTab === 'organization' && (
+
+ +
+ + {activeTab === 'organization' && ( + <> {isAdmin && (
@@ -512,6 +573,16 @@ export default function IAM() { )}
+ + )} + + {activeTab === 'workspace-members' && } + + {activeTab === 'workspace-roles' && isAdmin && ( +
+ +
+ )} {/* Invite Modal */} {showInviteModal && ( diff --git a/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx b/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx index 42f95f54..5af6d921 100644 --- a/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx +++ b/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx @@ -8,6 +8,8 @@ import { useToast } from '../../../../hooks/useToast' import { AIProvider } from '../../../../types/api' import { useVoicePlayground } from '../context' import { DEFAULT_SAMPLE_TEXTS } from '../types' +import LLMAdvancedOptionsPanel from '../../../../components/providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../../../config/llmGenerationParams' interface PromptPartial { id: string @@ -58,6 +60,7 @@ export default function SampleTextsPanel() { const [selectedLlmProvider, setSelectedLlmProvider] = useState('') const [selectedLlmModel, setSelectedLlmModel] = useState('') + const [sampleLlmConfig, setSampleLlmConfig] = useState(null) const { showToast, ToastContainer } = useToast() const { data: aiProviders = [] } = useQuery({ @@ -528,6 +531,14 @@ export default function SampleTextsPanel() { + {selectedLlmProvider && ( + + )} +
+ {selectedAIProvider && ( +
+ +
+ )}
@@ -1285,6 +1297,15 @@ export default function Scenarios() { ))}
+ {selectedAIProvider && ( +
+ +
+ )}