fix: fix org scoped access for resources

This commit is contained in:
Abhishek Kumar 2026-07-09 16:39:34 +05:30
parent f3bcf24370
commit e4b53f78e9
47 changed files with 2667 additions and 400 deletions

View file

@ -101,7 +101,10 @@ class OrganizationClient(BaseDBClient):
select(
exists().where(
(organization_users_association.c.user_id == user_id)
& (organization_users_association.c.organization_id == organization_id)
& (
organization_users_association.c.organization_id
== organization_id
)
)
)
)

View file

@ -13,13 +13,10 @@ from api.db.models import (
OrganizationConfigurationModel,
OrganizationModel,
OrganizationUsageCycleModel,
UserConfigurationModel,
UserModel,
WorkflowModel,
WorkflowRunModel,
)
from api.enums import OrganizationConfigurationKey, UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.enums import OrganizationConfigurationKey
from api.utils.recording_artifacts import get_recording_storage_key
@ -139,9 +136,8 @@ class OrganizationUsageClient(BaseDBClient):
query = (
select(WorkflowRunModel)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.join(UserModel, WorkflowModel.user_id == UserModel.id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowModel.organization_id == organization_id,
WorkflowRunModel.usage_info.isnot(None),
)
.order_by(WorkflowRunModel.created_at.desc())
@ -277,9 +273,8 @@ class OrganizationUsageClient(BaseDBClient):
WorkflowRunModel.public_access_token,
)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.join(UserModel, WorkflowModel.user_id == UserModel.id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowModel.organization_id == organization_id,
WorkflowRunModel.usage_info.isnot(None),
)
.order_by(WorkflowRunModel.created_at.desc())
@ -316,7 +311,6 @@ class OrganizationUsageClient(BaseDBClient):
start_date: datetime,
end_date: datetime,
price_per_second_usd: float,
user_id: Optional[int] = None,
) -> dict:
"""Get daily usage breakdown for an organization with pricing."""
@ -344,22 +338,6 @@ class OrganizationUsageClient(BaseDBClient):
if pref_obj and pref_obj.value:
user_timezone = pref_obj.value.get("timezone") or user_timezone
if user_id:
config_result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key
== UserConfigurationKey.MODEL_CONFIGURATION.value,
)
)
config_obj = config_result.scalar_one_or_none()
if config_obj and config_obj.configuration:
effective_config = EffectiveAIModelConfiguration.model_validate(
config_obj.configuration
)
if effective_config.timezone and user_timezone == "UTC":
user_timezone = effective_config.timezone
# Validate timezone string
try:
# Test if timezone is valid
@ -382,9 +360,8 @@ class OrganizationUsageClient(BaseDBClient):
func.count(WorkflowRunModel.id).label("call_count"),
)
.join(WorkflowModel, WorkflowModel.id == WorkflowRunModel.workflow_id)
.join(UserModel, UserModel.id == WorkflowModel.user_id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowModel.organization_id == organization_id,
WorkflowRunModel.created_at >= start_date,
WorkflowRunModel.created_at <= end_date,
WorkflowRunModel.is_completed == True,

View file

@ -606,11 +606,9 @@ class WorkflowClient(BaseDBClient):
"""Get workflows by IDs for a specific organization"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
.join(WorkflowModel.user)
.where(
select(WorkflowModel).where(
WorkflowModel.id.in_(workflow_ids),
WorkflowModel.user.has(selected_organization_id=organization_id),
WorkflowModel.organization_id == organization_id,
)
)
return result.scalars().all()

View file

@ -40,14 +40,14 @@ class WorkflowRunClient(BaseDBClient):
workflow_query = (
select(WorkflowModel)
.options(joinedload(WorkflowModel.user))
.where(
WorkflowModel.id == workflow_id, WorkflowModel.user_id == user_id
)
.where(WorkflowModel.id == workflow_id)
)
if organization_id is not None:
workflow_query = workflow_query.where(
WorkflowModel.organization_id == organization_id
)
elif user_id is not None:
workflow_query = workflow_query.where(WorkflowModel.user_id == user_id)
workflow = await session.execute(workflow_query)
workflow = workflow.scalars().first()

View file

@ -65,6 +65,7 @@ async def agent_stream_websocket(
user_id=workflow.user_id,
call_type=CallType.INBOUND,
initial_context=initial_context,
organization_id=workflow.organization_id,
)
set_current_run_id(workflow_run.id)
@ -72,6 +73,7 @@ async def agent_stream_websocket(
quota_result = await authorize_workflow_run_start(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:

View file

@ -351,9 +351,12 @@ async def create_campaign(
) -> CampaignResponse:
"""Create a new campaign"""
# Verify workflow exists and belongs to organization
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
if not workflow_name:
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
workflow_name = workflow.name
# Validate source data (phone_number column and format)
sync_service = get_sync_service(request.source_type)
@ -364,9 +367,6 @@ async def create_campaign(
raise HTTPException(status_code=400, detail=validation_result.error.message)
# Validate template variables against source data columns
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if workflow:
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.workflow_graph import WorkflowGraph
@ -512,7 +512,9 @@ async def get_campaign(
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -552,6 +554,7 @@ async def start_campaign(
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=user.selected_organization_id,
actor_user=user,
)
if not quota_result.has_quota:
@ -565,7 +568,9 @@ async def start_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -599,7 +604,9 @@ async def pause_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -669,7 +676,9 @@ async def update_campaign(
# Re-fetch to return updated data
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -838,7 +847,9 @@ async def redial_campaign(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
workflow_name = await db_client.get_workflow_name(child.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
child.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(child.id)
cfg_name = await _get_telephony_configuration_name(
child.telephony_configuration_id, user.selected_organization_id
@ -877,6 +888,7 @@ async def resume_campaign(
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=user.selected_organization_id,
actor_user=user,
)
if not quota_result.has_quota:
@ -890,7 +902,9 @@ async def resume_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(

View file

@ -375,9 +375,8 @@ async def search_chunks(
)
from api.services.gen_ai import build_embedding_service
# Try to get user's embeddings configuration
# Try to get the organization's embeddings configuration
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
effective_config = resolved_config.effective

View file

@ -243,7 +243,6 @@ async def _model_configuration_v2_response(
configuration: OrganizationAIModelConfigurationV2 | None = None,
) -> OrganizationAIModelConfigurationResponse:
resolved = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
raw_configuration = (

View file

@ -512,7 +512,6 @@ async def get_daily_usage_breakdown(
start_date,
end_date,
org.price_per_second_usd,
user_id=user.id,
)
return breakdown

View file

@ -264,6 +264,7 @@ async def _execute_resolved_target(
# the MPS correlation id before the provider starts the call.
quota_result = await authorize_workflow_run_start(
workflow_id=target.workflow.id,
organization_id=target.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:

View file

@ -309,6 +309,7 @@ async def initialize_embed_session(
workflow_id=embed_token.workflow_id,
mode=WorkflowRunMode.SMALLWEBRTC.value,
user_id=embed_token.created_by, # Use token creator as run owner
organization_id=embed_token.organization_id,
initial_context={
**(init_request.context_variables or {}),
"provider": WorkflowRunMode.SMALLWEBRTC.value,

View file

@ -182,6 +182,7 @@ async def initiate_call(
# the MPS correlation id before initiating the call.
quota_result = await authorize_workflow_run_start(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)
@ -421,6 +422,7 @@ async def _validate_inbound_request(
async def _create_inbound_workflow_run(
workflow_id: int,
user_id: int,
organization_id: int,
provider: str,
normalized_data,
telephony_configuration_id: int,
@ -456,6 +458,7 @@ async def _create_inbound_workflow_run(
"raw_webhook_data": normalized_data.raw_data,
},
},
organization_id=organization_id,
)
logger.info(
@ -562,6 +565,20 @@ async def _handle_telephony_websocket(
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")
return
if workflow_run.workflow_id != workflow.id:
logger.error(
f"Workflow run {workflow_run_id} belongs to workflow "
f"{workflow_run.workflow_id}, not {workflow.id}"
)
await websocket.close(code=4400, reason="workflow_run_workflow_mismatch")
return
if workflow.user_id != user_id:
logger.error(
f"Telephony websocket user mismatch for workflow {workflow.id}: "
f"got {user_id}, expected {workflow.user_id}"
)
await websocket.close(code=4400, reason="workflow_user_mismatch")
return
# Check workflow run state - only allow 'initialized' state
if workflow_run.state != WorkflowRunState.INITIALIZED.value:
@ -770,6 +787,7 @@ async def handle_inbound_run(request: Request):
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
user_id,
config.organization_id,
provider_class.PROVIDER_NAME,
normalized_data,
telephony_configuration_id=telephony_configuration_id,
@ -777,6 +795,7 @@ async def handle_inbound_run(request: Request):
)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=config.organization_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:
@ -905,6 +924,7 @@ async def handle_inbound_telephony(
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
workflow_context["user_id"],
workflow_context["organization_id"],
workflow_context["provider"],
normalized_data,
telephony_configuration_id=workflow_context["telephony_configuration_id"],
@ -912,6 +932,7 @@ async def handle_inbound_telephony(
)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=workflow_context["organization_id"],
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:

View file

@ -16,7 +16,9 @@ from api.schemas.workflow_configurations import (
)
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
get_resolved_ai_model_configuration,
upsert_organization_ai_model_configuration_v2,
)
from api.services.configuration.check_validity import (
APIKeyStatusResponse,
@ -110,7 +112,6 @@ async def get_user_configurations(
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
masked_config = mask_user_config(resolved_config.effective)
@ -139,7 +140,11 @@ async def update_user_configurations(
request: UserConfigurationRequestResponseSchema,
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
existing_config = await db_client.get_user_configurations(user.id)
existing_config = (
await get_resolved_ai_model_configuration(
organization_id=user.selected_organization_id,
)
).effective
incoming_dict = request.model_dump(exclude_none=True)
@ -152,6 +157,9 @@ async def update_user_configurations(
}
if incoming_dict:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Merge via helper
try:
user_configurations = merge_user_configurations(
@ -175,8 +183,16 @@ async def update_user_configurations(
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])
user_configurations = await db_client.update_user_configuration(
user.id, user_configurations
try:
organization_configuration = convert_legacy_ai_model_configuration_to_v2(
user_configurations
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
await upsert_organization_ai_model_configuration_v2(
user.selected_organization_id,
organization_configuration,
)
else:
user_configurations = existing_config
@ -235,14 +251,13 @@ async def validate_user_configurations(
user: UserModel = Depends(get_user),
) -> APIKeyStatusResponse:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
configurations = resolved_config.effective
if (
configurations.last_validated_at
and configurations.last_validated_at
not configurations.last_validated_at
or configurations.last_validated_at
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
):
validator = UserConfigurationValidator()
@ -252,7 +267,6 @@ async def validate_user_configurations(
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
await db_client.update_user_configuration_last_validated_at(user.id)
return status
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])

View file

@ -321,6 +321,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
):
"""Handle WebSocket connection for signaling."""
await websocket.accept()
@ -337,6 +338,7 @@ class SignalingManager:
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
)
except WebSocketDisconnect:
@ -378,6 +380,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
):
"""Handle incoming WebSocket messages."""
@ -386,7 +389,13 @@ class SignalingManager:
if msg_type == "offer":
await self._handle_offer(
ws, payload, workflow_id, workflow_run_id, user, connection_key
ws,
payload,
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
)
elif msg_type == "ice-candidate":
await self._handle_ice_candidate(payload, connection_key)
@ -400,6 +409,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
):
"""Handle offer message and create answer with ICE trickling."""
@ -420,14 +430,13 @@ class SignalingManager:
# Set run context for logging and tracing. org_id must be set before
# pc.initialize() so that aiortc's internal tasks inherit it.
set_current_run_id(workflow_run_id)
org_id = await db_client.get_workflow_organization_id(workflow_id)
if org_id:
set_current_org_id(org_id)
set_current_org_id(organization_id)
# Check Dograh quota before initiating the call (apply per-workflow
# model_overrides so we evaluate the keys this workflow will use).
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)
@ -518,6 +527,7 @@ class SignalingManager:
user.id,
call_context_vars,
user_provider_id=str(user.provider_id),
organization_id=organization_id,
)
)
@ -630,13 +640,31 @@ async def signaling_websocket(
user: UserModel = Depends(get_user_ws),
):
"""WebSocket endpoint for WebRTC signaling with ICE trickling."""
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=user.selected_organization_id
)
if not workflow_run:
logger.warning(f"workflow run {workflow_run_id} not found for user {user.id}")
logger.warning(
f"workflow run {workflow_run_id} not found for org "
f"{user.selected_organization_id}"
)
raise HTTPException(status_code=400, detail="Bad workflow_run_id")
if workflow_run.workflow_id != workflow_id:
logger.warning(
f"workflow run {workflow_run_id} belongs to workflow "
f"{workflow_run.workflow_id}, not {workflow_id}"
)
raise HTTPException(status_code=400, detail="workflow_run_workflow_mismatch")
await signaling_manager.handle_websocket(
websocket, workflow_id, workflow_run_id, user
websocket,
workflow_id,
workflow_run_id,
user,
user.selected_organization_id,
)
@ -670,6 +698,17 @@ async def public_signaling_websocket(
await websocket.close(code=1008, reason="Invalid embed token")
return
workflow_run = await db_client.get_workflow_run(
embed_session.workflow_run_id,
organization_id=embed_token.organization_id,
)
if not workflow_run:
await websocket.close(code=1008, reason="Invalid workflow run")
return
if workflow_run.workflow_id != embed_token.workflow_id:
await websocket.close(code=1008, reason="workflow_run_workflow_mismatch")
return
# Enforce the embed token's allowed-domain policy on the public signaling
# path, mirroring the HTTP embed endpoints (issue #330). Without this a
# leaked or replayed session token could attach from an arbitrary origin.
@ -693,5 +732,9 @@ async def public_signaling_websocket(
# Handle the WebSocket connection using the existing signaling manager
await signaling_manager.handle_websocket(
websocket, embed_token.workflow_id, embed_session.workflow_run_id, user
websocket,
embed_token.workflow_id,
embed_session.workflow_run_id,
user,
embed_token.organization_id,
)

View file

@ -1080,7 +1080,6 @@ async def update_workflow(
)
if existing_v2_override_config is None:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
v2_override = merge_ai_model_configuration_v2_secrets(
@ -1123,7 +1122,6 @@ async def update_workflow(
existing_configs,
)
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
effective_config = resolved_config.effective

View file

@ -103,6 +103,7 @@ async def _ensure_text_chat_quota(
) -> None:
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=user.selected_organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)

View file

@ -303,6 +303,7 @@ class CampaignCallDispatcher:
initial_context=initial_context,
campaign_id=campaign.id,
queued_run_id=queued_run.id, # Link to queued run for retry tracking
organization_id=campaign.organization_id,
)
# Store slot_id mapping in Redis for cleanup later
@ -342,6 +343,7 @@ class CampaignCallDispatcher:
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=campaign.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:

View file

@ -55,9 +55,9 @@ class WorkflowAIModelConfigurationMigrationResult:
async def get_resolved_ai_model_configuration(
*,
user_id: int | None,
organization_id: int | None,
) -> ResolvedAIModelConfiguration:
"""Resolve the effective model configuration for an organization."""
organization_configuration = await get_organization_ai_model_configuration_v2(
organization_id
)
@ -68,22 +68,14 @@ async def get_resolved_ai_model_configuration(
organization_configuration=organization_configuration,
)
if user_id is None:
return ResolvedAIModelConfiguration(
effective=EffectiveAIModelConfiguration(),
source="empty",
)
legacy = await db_client.get_user_configurations(user_id)
return ResolvedAIModelConfiguration(
effective=legacy,
source="legacy_user_v1" if _has_model_services(legacy) else "empty",
effective=EffectiveAIModelConfiguration(),
source="empty",
)
async def get_effective_ai_model_configuration_for_workflow(
*,
user_id: int | None,
organization_id: int | None,
workflow_configurations: dict | None,
) -> EffectiveAIModelConfiguration:
@ -97,7 +89,6 @@ async def get_effective_ai_model_configuration_for_workflow(
)
resolved_config = await get_resolved_ai_model_configuration(
user_id=user_id,
organization_id=organization_id,
)
return resolve_effective_config(
@ -145,19 +136,12 @@ async def migrate_workflow_model_configurations_to_v2(
fallback_user_config: EffectiveAIModelConfiguration,
) -> WorkflowAIModelConfigurationMigrationResult:
workflows = await _list_workflows_for_model_configuration_migration(organization_id)
owner_configs: dict[int, EffectiveAIModelConfiguration] = {}
workflow_updates: list[tuple[int, dict]] = []
definition_updates: list[tuple[int, dict]] = []
migrated_workflow_ids: set[int] = set()
for workflow in workflows:
base_config = fallback_user_config
if workflow.user_id is not None:
if workflow.user_id not in owner_configs:
owner_configs[
workflow.user_id
] = await db_client.get_user_configurations(workflow.user_id)
base_config = owner_configs[workflow.user_id]
workflow_configs, workflow_changed = (
migrate_workflow_configuration_model_override_to_v2(
@ -420,19 +404,6 @@ def _mask_secret_value(value):
return mask_key(value)
def _has_model_services(configuration: EffectiveAIModelConfiguration) -> bool:
return any(
service is not None
for service in (
configuration.llm,
configuration.tts,
configuration.stt,
configuration.embeddings,
configuration.realtime,
)
)
def _convert_any_dograh_legacy_configuration(
configuration: EffectiveAIModelConfiguration,
dograh_key: str,

View file

@ -31,7 +31,6 @@ async def get_organization_context(user: UserModel) -> OrganizationContextRespon
)
resolved = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=organization_id,
)
managed_service_version = resolved.effective.managed_service_version

View file

@ -233,7 +233,9 @@ class InMemoryLogsBuffer:
if self._user_speech_start_timestamp:
payload_with_timestamps["timestamp"] = self._user_speech_start_timestamp
if self._user_speech_end_timestamp:
payload_with_timestamps["end_timestamp"] = self._user_speech_end_timestamp
payload_with_timestamps["end_timestamp"] = (
self._user_speech_end_timestamp
)
elif event_type == RealtimeFeedbackType.BOT_TEXT.value:
bot_interval_is_active = self._bot_speech_end_timestamp is None
if bot_interval_is_active and self._bot_speech_start_timestamp:

View file

@ -53,10 +53,10 @@ from pipecat.frames.frames import (
TranscriptionFrame,
TTSSpeakFrame,
TTSTextFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
UserMuteStartedFrame,
UserMuteStoppedFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)

View file

@ -323,7 +323,7 @@ async def _run_pipeline_telephony_impl(
"telephony_configuration_id"
)
# Resolve effective user config here so the transport can tune its
# Resolve effective org config here so the transport can tune its
# bot-stopped-speaking fallback based on is_realtime; pass the resolved
# values into _run_pipeline so it doesn't fetch them again.
from api.services.configuration.ai_model_configuration import (
@ -334,7 +334,6 @@ async def _run_pipeline_telephony_impl(
(workflow_run.definition.workflow_configurations or {}) if workflow_run else {}
)
user_config = await get_effective_ai_model_configuration_for_workflow(
user_id=user_id,
organization_id=workflow.organization_id if workflow else None,
workflow_configurations=run_configs,
)
@ -379,6 +378,7 @@ async def run_pipeline_smallwebrtc(
user_id: int,
call_context_vars: dict = {},
user_provider_id: str | None = None,
organization_id: int | None = None,
) -> None:
"""Run pipeline for WebRTC connections."""
# Register before any async setup so deploy drains see calls that are still
@ -392,6 +392,7 @@ async def run_pipeline_smallwebrtc(
user_id,
call_context_vars=call_context_vars,
user_provider_id=user_provider_id,
organization_id=organization_id,
)
finally:
unregister_active_call(workflow_run_id)
@ -404,6 +405,7 @@ async def _run_pipeline_smallwebrtc_impl(
user_id: int,
call_context_vars: dict = {},
user_provider_id: str | None = None,
organization_id: int | None = None,
) -> None:
"""Run pipeline for WebRTC connections"""
logger.debug(
@ -411,8 +413,14 @@ async def _run_pipeline_smallwebrtc_impl(
)
set_current_run_id(workflow_run_id)
# Get workflow to extract all pipeline configurations
workflow = await db_client.get_workflow(workflow_id, user_id)
workflow_scope = (
{"organization_id": organization_id}
if organization_id is not None
else {"user_id": user_id}
)
# Get workflow to extract all pipeline configurations.
workflow = await db_client.get_workflow(workflow_id, **workflow_scope)
# Set org context early so tasks created by the transport inherit it
if workflow:
@ -428,19 +436,26 @@ async def _run_pipeline_smallwebrtc_impl(
# Create audio configuration for WebRTC
audio_config = create_audio_config(WorkflowRunMode.SMALLWEBRTC.value)
# Resolve workflow_run + effective user_config here so the transport can
# Resolve workflow_run + effective org config here so the transport can
# tune its bot-stopped-speaking fallback based on is_realtime. _run_pipeline
# reuses these via kwargs so we don't fetch twice.
from api.services.configuration.ai_model_configuration import (
get_effective_ai_model_configuration_for_workflow,
)
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
workflow_run = await db_client.get_workflow_run(workflow_run_id, **workflow_scope)
if not workflow_run:
raise HTTPException(status_code=404, detail="Workflow run not found")
if workflow_run.workflow_id != workflow_id:
raise HTTPException(
status_code=400,
detail="workflow_run_workflow_mismatch",
)
run_configs = (
(workflow_run.definition.workflow_configurations or {}) if workflow_run else {}
)
user_config = await get_effective_ai_model_configuration_for_workflow(
user_id=user_id,
organization_id=workflow.organization_id if workflow else None,
workflow_configurations=run_configs,
)
@ -463,6 +478,7 @@ async def _run_pipeline_smallwebrtc_impl(
user_provider_id=user_provider_id,
workflow_run=workflow_run,
resolved_user_config=user_config,
organization_id=organization_id,
)
@ -476,6 +492,7 @@ async def _run_pipeline(
user_provider_id: str | None = None,
workflow_run=None,
resolved_user_config=None,
organization_id: int | None = None,
) -> None:
"""Run the pipeline with active-call drain accounting."""
register_active_call(workflow_run_id)
@ -490,6 +507,7 @@ async def _run_pipeline(
user_provider_id=user_provider_id,
workflow_run=workflow_run,
resolved_user_config=resolved_user_config,
organization_id=organization_id,
)
finally:
unregister_active_call(workflow_run_id)
@ -505,6 +523,7 @@ async def _run_pipeline_impl(
user_provider_id: str | None = None,
workflow_run=None,
resolved_user_config=None,
organization_id: int | None = None,
) -> None:
"""
Run the pipeline with the given transport and configuration
@ -515,11 +534,26 @@ async def _run_pipeline_impl(
workflow_run_id: The ID of the workflow run
user_id: The ID of the user
workflow_run: Pre-fetched workflow run row. Fetched here if None.
resolved_user_config: User configuration with model_overrides already
applied. Fetched and resolved here if None.
resolved_user_config: Organization model configuration with workflow
model_overrides already applied. Fetched and resolved here if None.
"""
workflow_scope = (
{"organization_id": organization_id}
if organization_id is not None
else {"user_id": user_id}
)
if workflow_run is None:
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
workflow_run = await db_client.get_workflow_run(
workflow_run_id, **workflow_scope
)
if not workflow_run:
raise HTTPException(status_code=404, detail="Workflow run not found")
if workflow_run.workflow_id != workflow_id:
raise HTTPException(
status_code=400,
detail="workflow_run_workflow_mismatch",
)
# If the workflow run is already completed, we don't need to run it again
if workflow_run.is_completed:
@ -532,7 +566,7 @@ async def _run_pipeline_impl(
merged_call_context_vars = {**merged_call_context_vars, **call_context_vars}
# Get workflow for metadata (name, organization_id, call_disposition_codes)
workflow = await db_client.get_workflow(workflow_id, user_id)
workflow = await db_client.get_workflow(workflow_id, **workflow_scope)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
@ -564,7 +598,7 @@ async def _run_pipeline_impl(
term.strip() for term in dictionary.split(",") if term.strip()
]
# Resolve model overrides from the version onto global user config (skip
# Resolve model overrides from the version onto global org config (skip
# when the caller already resolved it).
if resolved_user_config is None:
from api.services.configuration.ai_model_configuration import (
@ -572,7 +606,6 @@ async def _run_pipeline_impl(
)
user_config = await get_effective_ai_model_configuration_for_workflow(
user_id=user_id,
organization_id=workflow.organization_id,
workflow_configurations=run_configs,
)

View file

@ -337,6 +337,7 @@ async def _authorize_oss_managed_v2_correlation(
async def authorize_workflow_run_start(
*,
workflow_id: int,
organization_id: int,
workflow_run_id: int | None = None,
actor_user: UserModel | None = None,
) -> QuotaCheckResult:
@ -344,30 +345,75 @@ async def authorize_workflow_run_start(
The workflow organization is the billing subject for hosted deployments.
OSS deployments are billed per service key instead. The workflow owner is
used only to resolve the effective model configuration.
used only as billing metadata.
"""
if organization_id is None:
logger.warning(
"Workflow start authorization denied: missing organization scope for workflow {}",
workflow_id,
)
return QuotaCheckResult(
has_quota=False,
error_code="workflow_not_found",
error_message="Workflow not found",
)
try:
workflow = await db_client.get_workflow_by_id(workflow_id)
if not workflow:
workflow = await db_client.get_workflow(
workflow_id,
organization_id=organization_id,
)
except Exception as e:
logger.error(
"Workflow start authorization denied: failed to load workflow {} for org {}: {}",
workflow_id,
organization_id,
e,
)
return QuotaCheckResult(
has_quota=False,
error_code="workflow_not_found",
error_message="Workflow not found",
)
if not workflow:
logger.warning(
"Workflow start authorization denied: workflow {} not found for org {}",
workflow_id,
organization_id,
)
return QuotaCheckResult(
has_quota=False,
error_code="workflow_not_found",
error_message="Workflow not found",
)
try:
actor_id = getattr(actor_user, "id", None) if actor_user is not None else None
if actor_user is not None and actor_id is None:
logger.warning(
"Workflow start authorization denied: actor is missing id for workflow {} org {}",
workflow_id,
organization_id,
)
return QuotaCheckResult(
has_quota=False,
error_code="workflow_not_found",
error_message="Workflow not found",
)
actor_id = getattr(actor_user, "id", None)
if actor_id is not None and workflow.organization_id is not None:
if actor_id is not None:
try:
is_member = await db_client.is_user_member_of_organization(
user_id=actor_id,
organization_id=workflow.organization_id,
organization_id=organization_id,
)
except Exception as e:
logger.error(
"Workflow start authorization denied: failed to validate actor {} membership for workflow {} org {}: {}",
actor_id,
workflow_id,
workflow.organization_id,
organization_id,
e,
)
return QuotaCheckResult(
@ -380,7 +426,7 @@ async def authorize_workflow_run_start(
"Workflow start authorization denied: actor {} is not a member of workflow {} org {}",
actor_id,
workflow_id,
workflow.organization_id,
organization_id,
)
return QuotaCheckResult(
has_quota=False,
@ -397,15 +443,14 @@ async def authorize_workflow_run_start(
)
user_config = await get_effective_ai_model_configuration_for_workflow(
user_id=workflow_owner.id,
organization_id=workflow.organization_id,
organization_id=organization_id,
workflow_configurations=workflow.workflow_configurations,
)
if DEPLOYMENT_MODE != "oss":
return await _authorize_hosted_workflow_run_start(
workflow_owner=workflow_owner,
organization_id=workflow.organization_id,
organization_id=organization_id,
workflow_id=workflow.id,
workflow_run_id=workflow_run_id,
user_config=user_config,

View file

@ -591,6 +591,7 @@ class ARIConnection:
gathered_context={
"call_id": call_id,
},
organization_id=self.organization_id,
)
logger.info(
@ -603,6 +604,7 @@ class ARIConnection:
# store the MPS correlation id before the pipeline starts.
quota_result = await authorize_workflow_run_start(
workflow_id=inbound_workflow_id,
organization_id=self.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:

View file

@ -12,7 +12,7 @@ async def resolve_llm_config(
"""Resolve the LLM provider, model, API key, and extra kwargs for QA analysis.
If the QA node has its own LLM configuration (qa_use_workflow_llm=False),
use those settings directly. Otherwise, fall back to the user's configured LLM.
use those settings directly. Otherwise, fall back to the workflow/org LLM.
Returns:
(provider, model, api_key, service_kwargs) tuple service_kwargs can be
@ -30,7 +30,7 @@ async def resolve_llm_config(
kwargs,
)
# Fall back to user's configured LLM
# Fall back to the workflow/org configured LLM
provider, model, api_key, kwargs = await resolve_user_llm_config(workflow_run)
if qa_data.qa_model and qa_data.qa_model != "default":
@ -42,17 +42,13 @@ async def resolve_llm_config(
async def resolve_user_llm_config(
workflow_run: WorkflowRunModel,
) -> tuple[str, str, str, dict]:
"""Resolve the user's configured LLM (from EffectiveAIModelConfiguration).
"""Resolve the workflow/org configured LLM.
Returns:
(provider, model, api_key, service_kwargs) tuple
"""
user_id = None
if workflow_run.workflow and workflow_run.workflow.user:
user_id = workflow_run.workflow.user.id
llm_config: dict = {}
if user_id:
if workflow_run.workflow:
from api.services.configuration.ai_model_configuration import (
get_effective_ai_model_configuration_for_workflow,
)
@ -68,7 +64,6 @@ async def resolve_user_llm_config(
)
user_configuration = await get_effective_ai_model_configuration_for_workflow(
user_id=user_id,
organization_id=workflow_run.workflow.organization_id
if workflow_run.workflow
else None,

View file

@ -458,7 +458,6 @@ async def execute_text_chat_pending_turn(
)
user_config = await get_effective_ai_model_configuration_for_workflow(
user_id=workflow_run.workflow.user.id,
organization_id=workflow.organization_id,
workflow_configurations=run_configs,
)

View file

@ -143,14 +143,13 @@ async def process_knowledge_base_document(
embeddings_base_url = None
embeddings_endpoint = None
embeddings_api_version = None
if retrieval_mode == "chunked" and document.created_by:
if retrieval_mode == "chunked":
from api.services.configuration.ai_model_configuration import (
apply_managed_embeddings_base_url,
get_resolved_ai_model_configuration,
)
resolved_config = await get_resolved_ai_model_configuration(
user_id=document.created_by,
organization_id=document.organization_id,
)
effective_config = resolved_config.effective

View file

@ -94,6 +94,7 @@ def test_trigger_route_executes_as_workflow_owner():
assert response.status_code == 200
quota_mock.assert_awaited_once_with(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=501,
)
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)

View file

@ -25,6 +25,7 @@ _ACTIVE_TOKEN = SimpleNamespace(
expires_at=None,
allowed_domains=[],
workflow_id=1,
organization_id=11,
created_by=7,
usage_limit=None,
usage_count=0,
@ -37,6 +38,7 @@ _RESTRICTED_TOKEN = SimpleNamespace(
expires_at=None,
allowed_domains=["allowed.example.com"],
workflow_id=2,
organization_id=11,
created_by=7,
usage_limit=None,
usage_count=0,
@ -49,6 +51,7 @@ _LOCALHOST_TOKEN = SimpleNamespace(
expires_at=None,
allowed_domains=["localhost:3000", "localhost:3020"],
workflow_id=3,
organization_id=11,
created_by=7,
usage_limit=None,
usage_count=0,

View file

@ -9,6 +9,8 @@ from api.services.configuration.registry import ServiceProviders
from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY
from api.services.quota_service import QuotaCheckResult
_UNSET = object()
def _dograh_config(
api_key: str = "mps_sk_12345678",
@ -58,11 +60,17 @@ def _actor():
)
def _patch_workflow_context(monkeypatch, *, workflow=None, owner=None):
def _patch_workflow_context(monkeypatch, *, workflow=_UNSET, owner=None):
workflow_value = _workflow() if workflow is _UNSET else workflow
monkeypatch.setattr(
quota_service.db_client,
"get_workflow",
AsyncMock(return_value=workflow_value),
)
monkeypatch.setattr(
quota_service.db_client,
"get_workflow_by_id",
AsyncMock(return_value=workflow or _workflow()),
AsyncMock(side_effect=AssertionError("quota must not use unscoped workflow")),
)
monkeypatch.setattr(
quota_service.db_client,
@ -103,11 +111,14 @@ async def test_authorize_workflow_run_uses_workflow_org_for_hosted_v2(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is True
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=42)
get_config.assert_awaited_once_with(
user_id=123,
organization_id=42,
workflow_configurations={"model_overrides": {}},
)
@ -156,7 +167,10 @@ async def test_authorize_workflow_run_v2_insufficient_credits_prompts_billing(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is False
assert result.error_code == "insufficient_credits"
@ -189,11 +203,14 @@ async def test_authorize_workflow_run_oss_exhausted_key_blocks_run(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is False
assert result.error_code == "quota_exceeded"
assert "founders@dograh.com" in result.error_message
assert "app.dograh.com" in result.error_message
assert "/billing" not in result.error_message
check_usage.assert_awaited_once_with(api_key)
@ -247,6 +264,7 @@ async def test_authorize_workflow_run_managed_v2_stores_hosted_correlation(
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -314,6 +332,7 @@ async def test_authorize_workflow_run_service_token_from_wrong_org_prompts_new_t
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -383,6 +402,7 @@ async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org(
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -411,6 +431,7 @@ async def test_authorize_workflow_run_rejects_actor_not_a_member(monkeypatch):
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
)
@ -430,6 +451,7 @@ async def test_authorize_workflow_run_membership_lookup_error_fails_closed(monke
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=42),
)
@ -464,6 +486,7 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
# but is_user_member_of_organization returns True so the run should be allowed.
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
)
@ -471,16 +494,9 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
@pytest.mark.asyncio
async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkeypatch):
"""Personal/legacy workflows (organization_id=None) bypass membership check."""
personal_workflow = SimpleNamespace(
id=7,
user_id=123,
organization_id=None,
workflow_configurations={"model_overrides": {}},
)
async def test_authorize_workflow_run_requires_organization_scope(monkeypatch):
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
_patch_workflow_context(monkeypatch, workflow=personal_workflow)
_patch_workflow_context(monkeypatch)
is_member_mock = AsyncMock()
monkeypatch.setattr(
quota_service.db_client,
@ -500,8 +516,35 @@ async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkey
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=None,
actor_user=SimpleNamespace(id=456),
)
assert result.has_quota is True
assert result.has_quota is False
assert result.error_code == "workflow_not_found"
quota_service.db_client.get_workflow.assert_not_awaited()
is_member_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_authorize_workflow_run_rejects_workflow_outside_org(monkeypatch):
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
_patch_workflow_context(monkeypatch, workflow=None)
is_member_mock = AsyncMock()
monkeypatch.setattr(
quota_service.db_client,
"is_user_member_of_organization",
is_member_mock,
)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=99,
actor_user=SimpleNamespace(id=456),
)
assert result.has_quota is False
assert result.error_code == "workflow_not_found"
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=99)
is_member_mock.assert_not_awaited()
quota_service.db_client.get_user_by_id.assert_not_awaited()

View file

@ -1,8 +1,8 @@
from api.services.pipecat.realtime_feedback_events import (
build_bot_text_event,
build_function_call_end_event,
build_user_transcription_event,
build_node_transition_event,
build_user_transcription_event,
realtime_feedback_event_sort_key,
stamp_realtime_feedback_event,
)

View file

@ -1,5 +1,5 @@
from types import SimpleNamespace
import re
from types import SimpleNamespace
import pytest
from pipecat.frames.frames import (

View file

@ -92,6 +92,7 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
assert response.status_code == 200
quota_mock.assert_awaited_once_with(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=501,
actor_user=ANY,
)
@ -230,7 +231,7 @@ async def test_smallwebrtc_run_reaching_telephony_websocket_closes_without_runni
initial_context={},
gathered_context={},
)
workflow = SimpleNamespace(id=33, organization_id=11)
workflow = SimpleNamespace(id=33, organization_id=11, user_id=99)
provider_lookup = AsyncMock()
with (

View file

@ -5,8 +5,12 @@ from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.llm_context import LLMSpecificMessage
from api.db.models import OrganizationModel, UserModel
from api.db.models import OrganizationModel, UserModel, organization_users_association
from api.enums import OrganizationConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
)
from api.services.workflow.text_chat_runner import (
_deserialize_text_chat_checkpoint_messages,
_serialize_text_chat_checkpoint_messages,
@ -84,10 +88,23 @@ async def _create_user_and_workflow(
)
async_session.add(user)
await async_session.flush()
await async_session.execute(
organization_users_association.insert().values(
user_id=user.id,
organization_id=org.id,
)
)
await db_session.update_user_configuration(
user_id=user.id,
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
user_configuration = EffectiveAIModelConfiguration.model_validate(
USER_CONFIGURATION
)
await db_session.upsert_configuration(
org.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
mode="json",
exclude_none=True,
),
)
workflow = await db_session.create_workflow(
@ -1079,10 +1096,23 @@ async def test_text_chat_session_creation_requires_selected_org_scope(
)
async_session.add(user)
await async_session.flush()
await async_session.execute(
organization_users_association.insert().values(
user_id=user.id,
organization_id=org_a.id,
)
)
await db_session.update_user_configuration(
user_id=user.id,
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
user_configuration = EffectiveAIModelConfiguration.model_validate(
USER_CONFIGURATION
)
await db_session.upsert_configuration(
org_a.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
mode="json",
exclude_none=True,
),
)
workflow = await db_session.create_workflow(

View file

@ -42,7 +42,9 @@ def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
@ -69,7 +71,9 @@ def test_create_xai_tts_service_converts_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -91,7 +95,9 @@ def test_create_xai_tts_service_falls_back_to_english_for_unknown_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -113,7 +119,9 @@ def test_create_xai_tts_service_preserves_auto_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -127,25 +135,20 @@ def test_xai_is_registered_for_key_validation():
def test_xai_key_validation_accepts_valid_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 200
assert validator._check_xai_api_key("xai", "xai-valid-key") is True
# Validates against the TTS-scoped voices endpoint, not /v1/models.
called_url = mock_get.call_args.args[0]
assert called_url == "https://api.x.ai/v1/tts/voices"
assert (
mock_get.call_args.kwargs["headers"]["Authorization"]
== "Bearer xai-valid-key"
mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer xai-valid-key"
)
def test_xai_key_validation_rejects_bad_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 401
with pytest.raises(ValueError):
validator._check_xai_api_key("xai", "bad-key")
@ -153,8 +156,6 @@ def test_xai_key_validation_rejects_bad_key():
def test_xai_key_validation_allows_scoped_key_without_voice_list_access():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 403
assert validator._check_xai_api_key("xai", "tts-scoped-key") is True

View file

@ -3,14 +3,20 @@ from typing import List
from pipecat.utils.enums import RealtimeFeedbackType
def _format_timestamp_range(payload: dict, event: dict, include_end_timestamps: bool) -> str:
def _format_timestamp_range(
payload: dict, event: dict, include_end_timestamps: bool
) -> str:
start_timestamp = payload.get("timestamp") or event.get("timestamp", "")
if not include_end_timestamps:
return start_timestamp
end_timestamp = payload.get("end_timestamp")
if end_timestamp:
return f"{start_timestamp} -> {end_timestamp}" if start_timestamp else end_timestamp
return (
f"{start_timestamp} -> {end_timestamp}"
if start_timestamp
else end_timestamp
)
return start_timestamp

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.qHm1SwtOq5
# timestamp: 2026-07-07T12:20:41+00:00
# filename: dograh-openapi-XXXXXX.json.CHS3ih7PBx
# timestamp: 2026-07-09T11:09:01+00:00
from __future__ import annotations

2001
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@
"lint": "next lint",
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*' --ignore-pattern 'next-env.d.ts'",
"generate-client": "openapi-ts",
"test": "vitest run",
"test:display-options": "node scripts/test-display-options.mts",
"lint:lead-flow": "bash ../../user_onboarding/scripts/check_lead_flow.sh"
},
@ -62,17 +63,21 @@
"@eslint/eslintrc": "^3",
"@hey-api/openapi-ts": "^0.99.0",
"@tailwindcss/postcss": "^4",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/source-map-support": "^0.5.10",
"@vitejs/plugin-react": "^6.0.3",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.3.3",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"jsdom": "^29.1.1",
"source-map-support": "^0.5.21",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.10"
}
}

View file

@ -1,9 +1,14 @@
import { Loader2 } from "lucide-react";
export default function SpinLoader(){
return(
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-15 h-15 animate-spin" />
</div>
)
interface SpinLoaderProps {
label?: string;
}
export default function SpinLoader({ label }: SpinLoaderProps) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin text-foreground" />
{label && <span>{label}</span>}
</div>
);
}

View file

@ -1,6 +1,5 @@
"use client";
import type { Team } from "@stackframe/stack";
import {
AlertTriangle,
ArrowUpCircle,
@ -25,9 +24,10 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React, { useRef } from "react";
import React from "react";
import { BrandLogo } from "@/components/BrandLogo";
import { SidebarTeamSwitcher } from "@/components/layout/SidebarTeamSwitcher";
import ThemeToggle from "@/components/ThemeSwitcher";
import { Button } from "@/components/ui/button";
import {
@ -153,18 +153,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [
},
];
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({
default: mod.SelectedTeamSwitcher,
}))
);
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const { state, isMobile, setOpenMobile } = useSidebar();
const { provider, getSelectedTeam, logout, user } = useAuth();
const { provider, logout, user } = useAuth();
const { config } = useAppConfig();
const { openHireExpert } = useLeadForms();
const {
@ -176,16 +169,6 @@ export function AppSidebar() {
vonageMissingSignatureSecretCount > 0;
const isCollapsed = !isMobile && state === "collapsed";
// Get selected team for Stack auth (cast to Team type from Stack)
// Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes,
// preventing unnecessary PATCH calls to Stack Auth on every route navigation.
const selectedTeamRef = useRef<Team | null>(null);
const rawSelectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null;
if (rawSelectedTeam?.id !== selectedTeamRef.current?.id) {
selectedTeamRef.current = rawSelectedTeam;
}
const selectedTeam = selectedTeamRef.current;
// Version info from app config context
const versionInfo = config ? { ui: config.uiVersion, api: config.apiVersion } : null;
@ -397,18 +380,7 @@ export function AppSidebar() {
{provider === "stack" && (
<div className={cn("mt-3 notranslate", isCollapsed && "hidden")} translate="no">
<React.Suspense
fallback={
<div className="h-9 w-full animate-pulse rounded bg-muted" />
}
>
<StackTeamSwitcher
selectedTeam={selectedTeam || undefined}
onChange={() => {
router.refresh();
}}
/>
</React.Suspense>
<SidebarTeamSwitcher />
</div>
)}
</SidebarHeader>

View file

@ -0,0 +1,75 @@
"use client";
import type { CurrentUser, Team } from "@stackframe/stack";
import React, { useState } from "react";
import SpinLoader from "@/components/SpinLoader";
import { useAuth } from "@/lib/auth";
import { reloadApp } from "@/lib/browserReload";
// Lazy load Stack's SelectedTeamSwitcher, but own the selected-team write here.
// The stock component re-applies the selectedTeam prop asynchronously, which
// can pin the sidebar to the previous team while the user switch is in flight.
const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({
default: mod.SelectedTeamSwitcher,
}))
);
export function SidebarTeamSwitcher() {
const { provider, user } = useAuth();
// The !user guard is load-bearing (Sentry JAVASCRIPT-NEXTJS-2Z): Stack's
// TeamSwitcher calls user?.useTeams() — a hook behind optional chaining — so
// if useUser() flips to null mid-session (token expiry, sign-out from
// another tab) its re-render throws React #300 "Rendered fewer hooks than
// expected". Unmounting it here re-renders the ancestor first, removing the
// switcher before it can re-render with a null user.
if (provider !== "stack" || !user) {
return null;
}
return <SidebarTeamSwitcherContent user={user as CurrentUser} />;
}
function SidebarTeamSwitcherContent({ user }: { user: CurrentUser }) {
const [isSwitching, setIsSwitching] = useState(false);
const handleChange = async (team: Team | null) => {
setIsSwitching(true);
try {
await user.setSelectedTeam(team);
reloadApp();
} catch (error) {
setIsSwitching(false);
throw error;
}
};
return (
<div className="relative">
<React.Suspense
fallback={<div className="h-9 w-full animate-pulse rounded bg-muted" />}
>
<StackTeamSwitcher
selectedTeam={user.selectedTeam || undefined}
noUpdateSelectedTeam
onChange={(team) => {
void handleChange(team);
}}
triggerClassName="w-full"
/>
</React.Suspense>
{isSwitching && (
<div
className="fixed inset-0 z-[100] flex min-h-screen items-center justify-center bg-background/90 backdrop-blur-sm"
role="status"
aria-live="polite"
>
<SpinLoader label="Switching teams..." />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,270 @@
/**
* Regression tests for Sentry issue JAVASCRIPT-NEXTJS-2Z:
* "Rendered fewer hooks than expected" (React error #300).
*
* Stack's TeamSwitcher Inner component calls `user?.useTeams()` a hook behind
* optional chaining. When Stack's useUser() flips from a user object to null
* mid-session (token expiry, sign-out synced from another tab), the re-render
* calls fewer hooks than the previous one and React throws.
*
* These tests fake useUser()/useStackApp() but render the REAL
* SelectedTeamSwitcher component code from @stackframe/stack, so the first test
* pins the upstream bug: if it starts failing after a @stackframe/stack
* upgrade, the bug is fixed upstream and the guard in SidebarTeamSwitcher can
* be removed.
*
* Mocking note: vi.mock("@stackframe/stack") fully replaces the package entry.
* The real component is imported below via its dist file path its internal
* `import ... from "../index.js"` resolves to the same entry module and
* therefore receives the mocked hooks.
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthUser } from "@/lib/auth";
import {
AuthContext,
type AuthContextType,
} from "@/lib/auth/providers/AuthProvider";
import { SelectedTeamSwitcher as RealSelectedTeamSwitcher } from "../../../../node_modules/@stackframe/stack/dist/esm/components/selected-team-switcher.js";
import { TranslationContext } from "../../../../node_modules/@stackframe/stack/dist/esm/providers/translation-provider-client.js";
import { SidebarTeamSwitcher } from "../SidebarTeamSwitcher";
const reloadAppMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/browserReload", () => ({
reloadApp: reloadAppMock,
}));
// Controls what the faked Stack useUser() returns; tests flip this to null to
// simulate the session dying (token expiry / sign-out from another tab).
const stackState: { user: ReturnType<typeof makeStackUser> | null } = {
user: null,
};
const TEAM_ONE = { id: "team-1", displayName: "Team One", profileImageUrl: null };
const TEAM_TWO = { id: "team-2", displayName: "Team Two", profileImageUrl: null };
const makeTeams = () => [TEAM_ONE, TEAM_TWO];
type MockTeam = typeof TEAM_ONE | typeof TEAM_TWO;
function makeStackUser() {
return {
id: "user-1",
selectedTeam: TEAM_ONE as MockTeam | null,
// The real CurrentUser.useTeams() consumes React hooks internally (Stack's
// async cache). Mirror that so the hook count matches real behavior — the
// crash only reproduces if useTeams actually registers hooks. Use useMemo
// (the same hook kind as the next hook in TeamSwitcher's Inner) so the
// user→null transition completes the render with leftover hooks and React
// throws the production error (#300) instead of a hook-slot TypeError.
useTeams: () => React.useMemo(() => makeTeams(), []),
setSelectedTeam: vi.fn(async (team: MockTeam | null) => {
if (stackState.user) {
stackState.user.selectedTeam = team;
}
}),
};
}
const mockStackApp = {
useNavigate: () => () => {},
useProject: () => ({ config: { clientTeamCreationEnabled: false } }),
urls: { accountSettings: "/account-settings" },
};
// Full replacement of the package entry. The factory only creates closures, so
// it is safe to execute during import hoisting; the consts above are read at
// render time. The raw upstream test imports the real SelectedTeamSwitcher by
// dist path; this package export is a small controlled stand-in for
// SidebarTeamSwitcher's lazy import.
vi.mock("@stackframe/stack", () => ({
useStackApp: () => mockStackApp,
useUser: () => stackState.user,
SelectedTeamSwitcher: (props: {
selectedTeam?: MockTeam | null;
onChange?: (team: MockTeam) => void;
triggerClassName?: string;
}) => (
<button
aria-controls="team-options"
aria-expanded="false"
className={props.triggerClassName}
role="combobox"
onClick={() => props.onChange?.(TEAM_TWO)}
>
{props.selectedTeam?.displayName ?? "Select team"}
</button>
),
}));
class CatchBoundary extends React.Component<
{ onError: (error: Error) => void; children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: Error) {
this.props.onError(error);
}
render() {
return this.state.failed ? (
<div data-testid="crashed" />
) : (
this.props.children
);
}
}
const translationValue = {
quetzalKeys: new Map<string, string>(),
quetzalLocale: new Map<string, string>(),
};
function withProviders(
children: React.ReactNode,
authValue?: AuthContextType
) {
const inner = authValue ? (
<AuthContext.Provider value={authValue}>{children}</AuthContext.Provider>
) : (
children
);
return (
<TranslationContext.Provider value={translationValue}>
{inner}
</TranslationContext.Provider>
);
}
function makeAuthValue(user: AuthUser | null): AuthContextType {
return {
user,
isAuthenticated: !!user,
loading: false,
getAccessToken: async () => "token",
redirectToLogin: () => {},
logout: async () => {},
provider: "stack",
getSelectedTeam: () => null,
};
}
describe("SidebarTeamSwitcher", () => {
beforeEach(() => {
stackState.user = makeStackUser();
reloadAppMock.mockClear();
// React logs boundary-caught render errors; keep test output readable.
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("pins the upstream Stack bug: raw SelectedTeamSwitcher crashes with React #300 when the user flips to null", async () => {
const errors: Error[] = [];
const { rerender } = render(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<RealSelectedTeamSwitcher />
</CatchBoundary>
)
);
expect(await screen.findByRole("combobox")).toBeTruthy();
stackState.user = null;
rerender(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<RealSelectedTeamSwitcher />
</CatchBoundary>
)
);
expect(
errors.some((e) => /Rendered fewer hooks than expected/.test(e.message)),
`expected React #300, got: ${errors.map((e) => e.message).join("; ") || "no error"}`
).toBe(true);
});
it("renders the switcher for an authenticated stack user", async () => {
render(
withProviders(
<SidebarTeamSwitcher />,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
expect((await screen.findByRole("combobox")).textContent).toBe("Team One");
});
it("blocks the app and reloads after Stack persists the selected team", async () => {
let finishSwitch!: () => void;
stackState.user!.setSelectedTeam = vi.fn(async (team: MockTeam | null) => {
await new Promise<void>((resolve) => {
finishSwitch = () => {
if (stackState.user) {
stackState.user.selectedTeam = team;
}
resolve();
};
});
});
render(
withProviders(
<SidebarTeamSwitcher />,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
const switcher = await screen.findByRole("combobox");
fireEvent.click(switcher);
expect((await screen.findByRole("status")).textContent).toContain("Switching teams...");
expect(reloadAppMock).not.toHaveBeenCalled();
expect(stackState.user?.setSelectedTeam).toHaveBeenCalledWith(TEAM_TWO);
await act(async () => {
finishSwitch();
});
await waitFor(() => {
expect(reloadAppMock).toHaveBeenCalled();
});
});
it("unmounts the switcher instead of crashing when the session dies (user flips to null)", async () => {
const errors: Error[] = [];
const { rerender } = render(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<SidebarTeamSwitcher />
</CatchBoundary>,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
expect(await screen.findByRole("combobox")).toBeTruthy();
// In production both changes arrive in the same update: Stack's internal
// store empties, which updates useUser() consumers AND our AuthContext
// (StackAuthContextProvider derives its value from the same store).
stackState.user = null;
rerender(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<SidebarTeamSwitcher />
</CatchBoundary>,
makeAuthValue(null)
)
);
expect(errors.map((e) => e.message)).toEqual([]);
expect(screen.queryByRole("combobox")).toBeNull();
});
});

View file

@ -1,9 +1,8 @@
'use client';
import { StackClientApp, StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
import { type CurrentUser, StackClientApp, StackProvider, StackTheme } from '@stackframe/stack';
import React, { useMemo, useRef } from 'react';
import type { AuthUser } from '../types';
import { AuthContext } from './AuthProvider';
// Create a singleton StackClientApp instance to prevent multiple initializations
@ -35,17 +34,46 @@ interface StackProviderWrapperProps {
publishableClientKey: string;
}
// Simple context provider that uses Stack's useUser directly
function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
const stackUser = useStackUser();
function StackAuthContextProvider({
children,
app,
}: {
children: React.ReactNode;
app: StackClientApp<true, string>;
}) {
const [stackUser, setStackUser] = React.useState<CurrentUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
let cancelled = false;
setIsLoading(true);
app.getUser()
.then((user) => {
if (!cancelled) {
setStackUser(user);
}
})
.catch(() => {
if (!cancelled) {
setStackUser(null);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [app]);
// Store user in ref for callbacks to access latest value without creating new callbacks
const userRef = useRef(stackUser);
userRef.current = stackUser;
// Derive loading state: loading if we don't have a user yet
const isLoading = stackUser === null;
// Stable callbacks that use ref to access current user
const getAccessToken = React.useCallback(async () => {
const user = userRef.current;
@ -95,12 +123,12 @@ function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
}
}, []);
// IMPORTANT: Use primitive values (userId, isLoading) in deps, NOT stackUser object
// Stack's useUser() returns a new object reference on every render, which would cause infinite re-renders
// Use primitive values in deps so user object reference churn does not
// invalidate the context value unless the effective auth state changes.
const userId = stackUser?.id;
const contextValue = useMemo(() => ({
user: userRef.current as AuthUser,
user: userRef.current,
isAuthenticated: !!userId,
loading: isLoading,
getAccessToken,
@ -130,7 +158,7 @@ export function StackProviderWrapper({ children, projectId, publishableClientKey
return (
<StackProvider app={stackClientApp} translationOverrides={translationOverrides}>
<StackTheme>
<StackAuthContextProvider>
<StackAuthContextProvider app={stackClientApp}>
{children}
</StackAuthContextProvider>
</StackTheme>

View file

@ -0,0 +1,5 @@
"use client";
export function reloadApp() {
window.location.reload();
}

28
ui/vitest.config.mts Normal file
View file

@ -0,0 +1,28 @@
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'jsdom',
// Expose afterEach etc. globally so @testing-library/react auto-registers
// its DOM cleanup between tests.
globals: true,
include: ['src/**/*.test.{ts,tsx}'],
server: {
deps: {
// Inline @stackframe so vi.mock('@stackframe/stack') also intercepts the
// package's internal imports of its own index (e.g. team-switcher.js
// importing useUser from '../index.js').
inline: [/@stackframe/],
},
},
},
});