fix: fix org scoped access for resources (#517)

* fix: fix org scoped access for resources

* Fix auth and config validation regressions

* fix: track org config validation timestamp

* fix: backfill org model configuration v2 from legacy user rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align config tests with org-level v2 resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: helm example values tweaks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-07-09 23:04:33 +05:30 committed by GitHub
parent 041c31a613
commit fb4038a969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3531 additions and 517 deletions

View file

@ -0,0 +1,215 @@
"""backfill org model configuration v2 from legacy user rows
Revision ID: 00b0201ad918
Revises: c52dc3cccfb0
Create Date: 2026-07-09 12:00:00.000000
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "00b0201ad918"
down_revision: Union[str, None] = "c52dc3cccfb0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Organizations created before MODEL_CONFIGURATION_V2 only have per-user legacy
# MODEL_CONFIGURATION rows, and the application no longer falls back to those.
# This migration converts one member's legacy row per organization into an
# org-level v2 row. The conversion below is a frozen copy of
# convert_legacy_ai_model_configuration_to_v2 (api/services/configuration/
# ai_model_configuration.py at this revision) operating on raw JSON, so the
# migration never imports application code. API keys are deliberately carried
# over without validation: a dead key fails at call time exactly as it did
# before this migration.
_SPEED_MIN = 0.5
_SPEED_MAX = 2.0
_DEFAULT_VOICE = "default"
_DEFAULT_LANGUAGE = "multi"
# One candidate row per (organization, member with a legacy config row).
# Members are ordered the way the hosted migration script picked its
# representative user: users whose selected organization is this org first,
# then earliest created. The first member whose row converts wins.
_CANDIDATE_ROWS_SQL = """
SELECT ou.organization_id,
uc.configuration,
uc.last_validated_at
FROM organization_users ou
JOIN users u ON u.id = ou.user_id
JOIN user_configurations uc
ON uc.user_id = u.id AND uc.key = 'MODEL_CONFIGURATION'
WHERE NOT EXISTS (
SELECT 1
FROM organization_configurations oc
WHERE oc.organization_id = ou.organization_id
AND oc.key = 'MODEL_CONFIGURATION_V2'
)
ORDER BY ou.organization_id,
(u.selected_organization_id IS NOT DISTINCT FROM ou.organization_id) DESC,
u.created_at ASC NULLS LAST,
u.id ASC
"""
_INSERT_SQL = """
INSERT INTO organization_configurations
(organization_id, key, value, created_at, updated_at, last_validated_at)
VALUES
(:organization_id, 'MODEL_CONFIGURATION_V2', CAST(:value AS json),
now(), now(), :last_validated_at)
ON CONFLICT ON CONSTRAINT _organization_key_uc DO NOTHING
"""
def _section(configuration: dict, name: str) -> dict | None:
value = configuration.get(name)
return value if isinstance(value, dict) else None
def _single_api_key(service: dict) -> str | None:
key = service.get("api_key")
if isinstance(key, str) and key:
return key
if isinstance(key, list) and len(key) == 1 and isinstance(key[0], str) and key[0]:
return key[0]
return None
def _first_dograh_api_key(configuration: dict) -> str | None:
for name in ("llm", "tts", "stt", "embeddings", "realtime"):
service = _section(configuration, name)
if service is None or service.get("provider") != "dograh":
continue
key = _single_api_key(service)
if key:
return key
return None
def _has_dograh_provider(*services: dict | None) -> bool:
return any(
service is not None and service.get("provider") == "dograh"
for service in services
)
def _sanitized_speed(tts: dict | None) -> float:
speed = (tts or {}).get("speed", 1.0)
try:
speed = float(speed)
except (TypeError, ValueError):
return 1.0
if not _SPEED_MIN <= speed <= _SPEED_MAX:
return 1.0
return speed
def convert_legacy_configuration_to_v2(configuration: dict) -> dict | None:
"""Convert a legacy MODEL_CONFIGURATION JSON value to a v2 value.
Returns None when the legacy row is too incomplete to have produced a
working pipeline (such rows failed at pipeline startup before v2 too).
"""
llm = _section(configuration, "llm")
tts = _section(configuration, "tts")
stt = _section(configuration, "stt")
embeddings = _section(configuration, "embeddings")
realtime = _section(configuration, "realtime")
dograh_key = _first_dograh_api_key(configuration)
if dograh_key:
return {
"version": 2,
"mode": "dograh",
"dograh": {
"api_key": dograh_key,
"voice": (tts or {}).get("voice") or _DEFAULT_VOICE,
"speed": _sanitized_speed(tts),
"language": (stt or {}).get("language") or _DEFAULT_LANGUAGE,
},
}
if configuration.get("is_realtime"):
# BYOK schemas reject dograh providers; a dograh provider without a
# single resolvable key cannot be represented in v2.
if realtime is None or llm is None or _has_dograh_provider(llm, embeddings):
return None
section: dict = {"realtime": realtime, "llm": llm}
if embeddings is not None:
section["embeddings"] = embeddings
return {
"version": 2,
"mode": "byok",
"byok": {"mode": "realtime", "realtime": section},
}
if llm is None or tts is None or stt is None:
return None
if _has_dograh_provider(llm, tts, stt, embeddings):
return None
section = {"llm": llm, "tts": tts, "stt": stt}
if embeddings is not None:
section["embeddings"] = embeddings
return {
"version": 2,
"mode": "byok",
"byok": {"mode": "pipeline", "pipeline": section},
}
def upgrade() -> None:
connection = op.get_bind()
rows = connection.execute(sa.text(_CANDIDATE_ROWS_SQL)).mappings().all()
backfilled: set[int] = set()
seen: set[int] = set()
for row in rows:
organization_id = row["organization_id"]
seen.add(organization_id)
if organization_id in backfilled:
continue
configuration = row["configuration"]
if isinstance(configuration, str):
try:
configuration = json.loads(configuration)
except ValueError:
continue
if not isinstance(configuration, dict):
continue
try:
v2_value = convert_legacy_configuration_to_v2(configuration)
except Exception:
v2_value = None
if v2_value is None:
continue
connection.execute(
sa.text(_INSERT_SQL),
{
"organization_id": organization_id,
"value": json.dumps(v2_value),
"last_validated_at": row["last_validated_at"],
},
)
backfilled.add(organization_id)
skipped = sorted(seen - backfilled)
print(
f"Backfilled MODEL_CONFIGURATION_V2 for {len(backfilled)} organization(s); "
f"skipped {len(skipped)} with no convertible legacy configuration"
+ (f": {skipped}" if skipped else "")
)
def downgrade() -> None:
# Backfilled rows are indistinguishable from rows written by the
# application; leaving them in place is safe for older code.
pass

View file

@ -0,0 +1,29 @@
"""add last_validated_at on org config
Revision ID: c52dc3cccfb0
Revises: b7e3c9a1d2f4
Create Date: 2026-07-09 19:18:29.550267
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c52dc3cccfb0"
down_revision: Union[str, None] = "b7e3c9a1d2f4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"organization_configurations",
sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("organization_configurations", "last_validated_at")

View file

@ -205,11 +205,8 @@ class OrganizationConfigurationModel(Base):
key = Column(String, nullable=False)
value = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
last_validated_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
organization = relationship("OrganizationModel", back_populates="configurations")

View file

@ -118,7 +118,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

@ -1,3 +1,4 @@
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.future import select
@ -33,10 +34,15 @@ class OrganizationConfigurationClient(BaseDBClient):
return result.scalars().all()
async def upsert_configuration(
self, organization_id: int, key: str, value: Any
self,
organization_id: int,
key: str,
value: Any,
last_validated_at: datetime | None = None,
) -> OrganizationConfigurationModel:
"""Create or update a configuration for an organization."""
async with self.async_session() as session:
now = datetime.now(UTC)
# First try to get existing configuration
result = await session.execute(
select(OrganizationConfigurationModel).where(
@ -49,12 +55,16 @@ class OrganizationConfigurationClient(BaseDBClient):
if config:
# Update existing configuration
config.value = value
config.updated_at = now
config.last_validated_at = last_validated_at
else:
# Create new configuration
config = OrganizationConfigurationModel(
organization_id=organization_id,
key=key,
value=value,
updated_at=now,
last_validated_at=last_validated_at,
)
session.add(config)
@ -66,6 +76,30 @@ class OrganizationConfigurationClient(BaseDBClient):
await session.refresh(config)
return config
async def mark_configuration_validated(
self, organization_id: int, key: str
) -> Optional[OrganizationConfigurationModel]:
"""Update the validation timestamp for an existing organization configuration."""
async with self.async_session() as session:
result = await session.execute(
select(OrganizationConfigurationModel).where(
OrganizationConfigurationModel.organization_id == organization_id,
OrganizationConfigurationModel.key == key,
)
)
config = result.scalars().first()
if not config:
return None
config.last_validated_at = datetime.now(UTC)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(config)
return config
async def delete_configuration(self, organization_id: int, key: str) -> bool:
"""Delete a configuration for an organization."""
async with self.async_session() as session:

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

@ -92,6 +92,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

@ -285,6 +285,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,11 +309,11 @@ 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,
},
organization_id=embed_token.organization_id,
)
except Exception as e:
logger.error(f"Failed to create workflow run: {e}")

View file

@ -223,6 +223,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,
)
@ -450,11 +451,11 @@ 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,
from_phone_number_id: Optional[int] = None,
organization_id: int | None = None,
) -> int:
"""Create workflow run for inbound call and return run ID"""
call_id = normalized_data.call_id
@ -593,6 +594,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:
@ -814,16 +829,17 @@ 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,
from_phone_number_id=phone_row.id,
organization_id=config.organization_id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
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:
@ -977,18 +993,19 @@ async def handle_inbound_telephony(
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
workflow_context["user_id"],
organization_id,
workflow_context["provider"],
normalized_data,
telephony_configuration_id=workflow_context[
"telephony_configuration_id"
],
from_phone_number_id=workflow_context.get("from_phone_number_id"),
organization_id=organization_id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:

View file

@ -16,7 +16,10 @@ 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,
update_organization_ai_model_configuration_last_validated_at,
upsert_organization_ai_model_configuration_v2,
)
from api.services.configuration.check_validity import (
APIKeyStatusResponse,
@ -105,12 +108,29 @@ class UserConfigurationRequestResponseSchema(BaseModel):
organization_pricing: dict[str, Union[float, str, bool]] | None = None
def _is_validation_cache_stale(
last_validated_at: datetime | None,
validity_ttl_seconds: int,
) -> bool:
if last_validated_at is None:
return True
has_timezone = (
last_validated_at.tzinfo is not None
and last_validated_at.utcoffset() is not None
)
if has_timezone:
now = datetime.now(last_validated_at.tzinfo)
else:
now = datetime.now()
return last_validated_at < now - timedelta(seconds=validity_ttl_seconds)
@router.get("/configurations/user")
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 +159,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 +176,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 +202,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,15 +270,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
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
if _is_validation_cache_stale(
configurations.last_validated_at,
validity_ttl_seconds,
):
validator = UserConfigurationValidator()
try:
@ -252,7 +285,13 @@ 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)
if (
resolved_config.source == "organization_v2"
and user.selected_organization_id is not None
):
await update_organization_ai_model_configuration_last_validated_at(
user.selected_organization_id
)
return status
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])

View file

@ -326,6 +326,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
enforce_call_concurrency: bool = False,
call_concurrency_source: str = "webrtc",
):
@ -344,6 +345,7 @@ class SignalingManager:
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
enforce_call_concurrency,
call_concurrency_source,
@ -387,6 +389,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
enforce_call_concurrency: bool,
call_concurrency_source: str = "webrtc",
@ -402,6 +405,7 @@ class SignalingManager:
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
enforce_call_concurrency,
call_concurrency_source,
@ -418,6 +422,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
enforce_call_concurrency: bool,
call_concurrency_source: str = "webrtc",
@ -440,14 +445,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,
)
@ -497,21 +501,9 @@ class SignalingManager:
pipeline_started = False
pc = None
if enforce_call_concurrency:
if org_id is None:
await ws.send_json(
{
"type": "error",
"payload": {
"error_type": "organization_not_found",
"message": "Workflow organization not found",
},
}
)
return
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
org_id,
organization_id,
source=call_concurrency_source,
timeout=0,
)
@ -592,6 +584,7 @@ class SignalingManager:
user.id,
call_context_vars,
user_provider_id=str(user.provider_id),
organization_id=organization_id,
)
)
pipeline_started = True
@ -716,26 +709,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 workflow_run:
logger.warning(f"workflow run {workflow_run_id} not found for user {user.id}")
raise HTTPException(status_code=400, detail="Bad workflow_run_id")
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# The URL's workflow_id drives org resolution, quota, and concurrency
# accounting downstream — reject a workflow_id that doesn't own this run
# so a caller can't charge their call to an unrelated workflow/org.
if workflow_run.workflow_id != workflow_id:
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} does not belong to workflow "
f"{workflow_id} (belongs to {workflow_run.workflow_id})"
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,
user.selected_organization_id,
enforce_call_concurrency=True,
call_concurrency_source="webrtc",
)
@ -771,6 +769,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.
@ -798,6 +807,7 @@ async def public_signaling_websocket(
embed_token.workflow_id,
embed_session.workflow_run_id,
user,
embed_token.organization_id,
enforce_call_concurrency=True,
call_concurrency_source="public_embed",
)

View file

@ -1090,7 +1090,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(
@ -1133,7 +1132,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

@ -288,6 +288,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,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
slot_bound = True
@ -325,6 +326,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

@ -2,6 +2,7 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from loguru import logger
@ -11,7 +12,11 @@ from sqlalchemy.orm import selectinload
from api.constants import MPS_API_URL
from api.db import db_client
from api.db.models import WorkflowDefinitionModel, WorkflowModel
from api.db.models import (
OrganizationConfigurationModel,
WorkflowDefinitionModel,
WorkflowModel,
)
from api.enums import OrganizationConfigurationKey
from api.schemas.ai_model_configuration import (
DOGRAH_DEFAULT_LANGUAGE,
@ -55,35 +60,36 @@ class WorkflowAIModelConfigurationMigrationResult:
async def get_resolved_ai_model_configuration(
*,
user_id: int | None,
organization_id: int | None,
) -> ResolvedAIModelConfiguration:
organization_configuration = await get_organization_ai_model_configuration_v2(
organization_id
"""Resolve the effective model configuration for an organization."""
organization_configuration_row = (
await _get_organization_ai_model_configuration_v2_row(organization_id)
)
organization_configuration = _parse_organization_ai_model_configuration_v2(
organization_configuration_row,
organization_id,
)
if organization_configuration is not None:
effective = compile_ai_model_configuration_v2(organization_configuration)
if organization_configuration_row is not None:
effective.last_validated_at = (
organization_configuration_row.last_validated_at
)
return ResolvedAIModelConfiguration(
effective=compile_ai_model_configuration_v2(organization_configuration),
effective=effective,
source="organization_v2",
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 +103,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(
@ -109,12 +114,34 @@ async def get_effective_ai_model_configuration_for_workflow(
async def get_organization_ai_model_configuration_v2(
organization_id: int | None,
) -> OrganizationAIModelConfigurationV2 | None:
if organization_id is None:
return None
row = await db_client.get_configuration(
row = await _get_organization_ai_model_configuration_v2_row(organization_id)
return _parse_organization_ai_model_configuration_v2(row, organization_id)
async def update_organization_ai_model_configuration_last_validated_at(
organization_id: int,
) -> None:
await db_client.mark_configuration_validated(
organization_id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
)
async def _get_organization_ai_model_configuration_v2_row(
organization_id: int | None,
) -> OrganizationConfigurationModel | None:
if organization_id is None:
return None
return await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
)
def _parse_organization_ai_model_configuration_v2(
row: OrganizationConfigurationModel | None,
organization_id: int | None,
) -> OrganizationAIModelConfigurationV2 | None:
if row is None or not row.value:
return None
try:
@ -135,6 +162,7 @@ async def upsert_organization_ai_model_configuration_v2(
organization_id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
configuration.model_dump(mode="json", exclude_none=True),
last_validated_at=datetime.now(UTC),
)
return configuration
@ -145,19 +173,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 +441,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

@ -329,7 +329,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 (
@ -340,7 +340,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,
)
@ -385,6 +384,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
@ -398,6 +398,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:
try:
@ -413,6 +414,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(
@ -420,8 +422,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:
@ -437,19 +445,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,
)
@ -472,6 +487,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,
)
@ -485,6 +501,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_worker_active_call(workflow_run_id)
@ -499,6 +516,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:
try:
@ -517,6 +535,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
@ -527,11 +546,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:
@ -544,7 +578,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")
@ -576,7 +610,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 (
@ -584,7 +618,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

@ -625,6 +625,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

@ -195,7 +195,11 @@ async def create_workflow_run_rows(
Returns:
Tuple of (workflow_run, user, workflow).
"""
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,
)
org = OrganizationModel(provider_id=f"test-org-{provider_id_suffix}")
async_session.add(org)
@ -208,9 +212,16 @@ async def create_workflow_run_rows(
async_session.add(user)
await async_session.flush()
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(

View file

@ -15,9 +15,11 @@ from api.services.configuration.ai_model_configuration import (
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY,
check_for_masked_keys_in_ai_model_configuration_v2,
convert_legacy_ai_model_configuration_to_v2,
get_resolved_ai_model_configuration,
mask_ai_model_configuration_v2,
merge_ai_model_configuration_v2_secrets,
migrate_workflow_configuration_model_override_to_v2,
upsert_organization_ai_model_configuration_v2,
)
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.masking import mask_key
@ -148,6 +150,83 @@ async def test_byok_realtime_validator_does_not_require_stt_or_tts():
}
@pytest.mark.asyncio
async def test_resolved_org_v2_uses_last_validated_at_as_validation_cache(
monkeypatch,
):
from datetime import UTC, datetime
from api.services.configuration import ai_model_configuration
last_validated_at = datetime.now(UTC)
config = OrganizationAIModelConfigurationV2(
mode="dograh",
dograh=DograhManagedAIModelConfiguration(api_key="mps-secret"),
)
row = SimpleNamespace(
value=config.model_dump(mode="json", exclude_none=True),
last_validated_at=last_validated_at,
)
monkeypatch.setattr(
ai_model_configuration.db_client,
"get_configuration",
AsyncMock(return_value=row),
)
resolved = await get_resolved_ai_model_configuration(organization_id=42)
assert resolved.source == "organization_v2"
assert resolved.effective.last_validated_at == last_validated_at
@pytest.mark.asyncio
async def test_upsert_org_v2_marks_configuration_validated(monkeypatch):
from api.services.configuration import ai_model_configuration
config = OrganizationAIModelConfigurationV2(
mode="dograh",
dograh=DograhManagedAIModelConfiguration(api_key="mps-secret"),
)
upsert = AsyncMock()
monkeypatch.setattr(
ai_model_configuration.db_client,
"upsert_configuration",
upsert,
)
result = await upsert_organization_ai_model_configuration_v2(42, config)
assert result is config
upsert.assert_awaited_once()
args = upsert.await_args.args
kwargs = upsert.await_args.kwargs
assert args == (
42,
"MODEL_CONFIGURATION_V2",
config.model_dump(mode="json", exclude_none=True),
)
assert kwargs["last_validated_at"].tzinfo is not None
def test_org_config_validation_timestamp_update_does_not_touch_updated_at():
from datetime import UTC, datetime
from sqlalchemy import update
from sqlalchemy.dialects import postgresql
from api.db.models import OrganizationConfigurationModel
stmt = (
update(OrganizationConfigurationModel)
.where(OrganizationConfigurationModel.id == 1)
.values(last_validated_at=datetime.now(UTC))
)
compiled = str(stmt.compile(dialect=postgresql.dialect()))
assert "last_validated_at" in compiled
assert "updated_at" not in compiled
@pytest.mark.asyncio
async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime():
effective = EffectiveAIModelConfiguration(

View file

@ -0,0 +1,148 @@
"""Tests for the 00b0201ad918 backfill migration's frozen conversion.
The migration writes MODEL_CONFIGURATION_V2 JSON directly, so its output must
keep parsing with OrganizationAIModelConfigurationV2 these tests fail if the
schema drifts away from what the migration produces.
"""
import importlib.util
from pathlib import Path
from api.schemas.ai_model_configuration import (
OrganizationAIModelConfigurationV2,
compile_ai_model_configuration_v2,
)
_MIGRATION_PATH = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "00b0201ad918_backfill_org_model_configuration_v2.py"
)
_spec = importlib.util.spec_from_file_location(
"backfill_org_model_configuration_v2", _MIGRATION_PATH
)
migration = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(migration)
def _parse_and_compile(value: dict) -> None:
compile_ai_model_configuration_v2(
OrganizationAIModelConfigurationV2.model_validate(value)
)
def test_dograh_legacy_converts_to_managed_mode():
value = migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "dograh", "api_key": "mps-key", "model": "default"},
"tts": {
"provider": "dograh",
"api_key": "mps-key",
"voice": "aura",
"speed": 1.2,
},
"stt": {"provider": "dograh", "api_key": "mps-key", "language": "en"},
}
)
assert value["mode"] == "dograh"
assert value["dograh"] == {
"api_key": "mps-key",
"voice": "aura",
"speed": 1.2,
"language": "en",
}
_parse_and_compile(value)
def test_dograh_mode_wins_over_byok_sections_and_sanitizes_speed():
value = migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "dograh", "api_key": ["mps-key"]},
"tts": {"provider": "elevenlabs", "api_key": "el-key", "speed": 9.0},
"stt": {"provider": "deepgram", "api_key": "dg-key"},
}
)
assert value["mode"] == "dograh"
assert value["dograh"]["api_key"] == "mps-key"
assert value["dograh"]["speed"] == 1.0
_parse_and_compile(value)
def test_byok_pipeline_legacy_converts_and_validates():
value = migration.convert_legacy_configuration_to_v2(
{
"llm": {
"provider": "openai",
"api_key": "sk-test",
"model": "gpt-4.1-mini",
"temperature": 0.5,
},
"tts": {
"provider": "elevenlabs",
"api_key": "el-key",
"model": "eleven_flash_v2_5",
"voice": "voice-id",
},
"stt": {
"provider": "deepgram",
"api_key": "dg-key",
"model": "nova-2-phonecall",
},
}
)
assert value["mode"] == "byok"
assert value["byok"]["mode"] == "pipeline"
assert "embeddings" not in value["byok"]["pipeline"]
_parse_and_compile(value)
def test_realtime_legacy_converts_to_byok_realtime():
value = migration.convert_legacy_configuration_to_v2(
{
"is_realtime": True,
"realtime": {
"provider": "google_realtime",
"api_key": "google-key",
"model": "gemini-3.1-flash-live-preview",
"voice": "Puck",
"language": "en",
},
"llm": {
"provider": "google",
"api_key": "google-key",
"model": "gemini-2.5-flash",
},
}
)
assert value["mode"] == "byok"
assert value["byok"]["mode"] == "realtime"
_parse_and_compile(value)
def test_incomplete_pipeline_legacy_is_skipped():
assert (
migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "openai", "api_key": "sk-test"},
"tts": {"provider": "elevenlabs", "api_key": "el-key"},
}
)
is None
)
assert migration.convert_legacy_configuration_to_v2({}) is None
def test_dograh_provider_without_single_key_cannot_become_byok():
# Multiple dograh keys can't map to managed mode, and BYOK rejects the
# dograh provider — the org must be skipped rather than written broken.
assert (
migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "dograh", "api_key": ["key-a", "key-b"]},
"tts": {"provider": "elevenlabs", "api_key": "el-key"},
"stt": {"provider": "deepgram", "api_key": "dg-key"},
}
)
is None
)

View file

@ -1,3 +1,4 @@
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@ -7,20 +8,25 @@ from fastapi.testclient import TestClient
from api.routes.user import router
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
ResolvedAIModelConfiguration,
)
from api.services.configuration.masking import mask_key
from api.services.configuration.registry import (
GoogleLLMService,
DeepgramSTTConfiguration,
GoogleVertexLLMConfiguration,
OpenAILLMService,
OpenAITTSService,
)
def _make_test_app(selected_organization_id=None):
def _make_test_app(selected_organization_id=11):
app = FastAPI()
app.include_router(router)
mock_user = MagicMock()
mock_user.id = 1
mock_user.provider_id = "provider-1"
mock_user.is_superuser = False
mock_user.selected_organization_id = selected_organization_id
@ -38,28 +44,63 @@ def _existing_openai_config():
provider="openai",
api_key=REAL_KEY,
model="gpt-4.1",
)
),
tts=OpenAITTSService(
provider="openai",
api_key=REAL_KEY,
model="gpt-4o-mini-tts",
voice="alloy",
),
stt=DeepgramSTTConfiguration(
provider="deepgram",
api_key=REAL_KEY,
model="nova-3-general",
),
)
@contextmanager
def _patch_config_update(existing_config=None):
existing_config = existing_config or _existing_openai_config()
preferences = SimpleNamespace(test_phone_number=None, timezone=None)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
patch(
"api.routes.user.get_resolved_ai_model_configuration",
new=AsyncMock(
return_value=ResolvedAIModelConfiguration(
effective=existing_config,
source="organization_v2",
)
),
),
patch(
"api.routes.user.upsert_organization_ai_model_configuration_v2",
new=AsyncMock(),
) as upsert_config,
patch(
"api.routes.user.get_organization_preferences",
new=AsyncMock(return_value=preferences),
),
):
mock_db.get_organization_by_id = AsyncMock(return_value=None)
mock_validator.return_value.validate = AsyncMock()
yield SimpleNamespace(
db=mock_db,
validator=mock_validator,
upsert_config=upsert_config,
)
class TestMaskedKeyRejection:
def test_rejects_masked_api_key_on_provider_change(self):
"""Changing provider with a masked API key should return 400."""
app = _make_test_app()
client = TestClient(app)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(
return_value=_existing_openai_config()
)
mock_db.update_user_configuration = AsyncMock(
side_effect=lambda uid, cfg: cfg
)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update():
response = client.put(
"/user/configurations/user",
json={
@ -79,18 +120,7 @@ class TestMaskedKeyRejection:
app = _make_test_app()
client = TestClient(app)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(
return_value=_existing_openai_config()
)
mock_db.update_user_configuration = AsyncMock(
side_effect=lambda uid, cfg: cfg
)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update():
response = client.put(
"/user/configurations/user",
json={
@ -111,24 +141,7 @@ class TestMaskedKeyRejection:
client = TestClient(app)
new_key = "AIzaSyNewRealKey12345678"
updated = EffectiveAIModelConfiguration(
llm=GoogleLLMService(
provider="google",
api_key=new_key,
model="gemini-2.5-flash",
)
)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(
return_value=_existing_openai_config()
)
mock_db.update_user_configuration = AsyncMock(return_value=updated)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update() as patched:
response = client.put(
"/user/configurations/user",
json={
@ -141,21 +154,14 @@ class TestMaskedKeyRejection:
)
assert response.status_code == 200
patched.upsert_config.assert_awaited_once()
def test_allows_same_provider_with_masked_key(self):
"""Same provider with masked key should succeed (merge resolves it)."""
app = _make_test_app()
client = TestClient(app)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
existing = _existing_openai_config()
mock_db.get_user_configurations = AsyncMock(return_value=existing)
mock_db.update_user_configuration = AsyncMock(return_value=existing)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update() as patched:
response = client.put(
"/user/configurations/user",
json={
@ -170,6 +176,7 @@ class TestMaskedKeyRejection:
# Merge resolves the masked key back to the real one,
# so check_for_masked_keys should NOT raise.
assert response.status_code == 200
patched.upsert_config.assert_awaited_once()
def test_allows_same_provider_with_masked_vertex_credentials(self):
"""Same provider with masked credentials should succeed."""
@ -186,17 +193,21 @@ class TestMaskedKeyRejection:
project_id="demo-project",
location="us-east4",
credentials=real_credentials,
)
),
tts=OpenAITTSService(
provider="openai",
api_key=REAL_KEY,
model="gpt-4o-mini-tts",
voice="alloy",
),
stt=DeepgramSTTConfiguration(
provider="deepgram",
api_key=REAL_KEY,
model="nova-3-general",
),
)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(return_value=existing)
mock_db.update_user_configuration = AsyncMock(return_value=existing)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update(existing) as patched:
response = client.put(
"/user/configurations/user",
json={
@ -211,6 +222,7 @@ class TestMaskedKeyRejection:
)
assert response.status_code == 200
patched.upsert_config.assert_awaited_once()
def test_preference_only_update_does_not_validate_or_save_model_config(self):
"""Saving a test phone number through the legacy endpoint must not touch models."""
@ -229,6 +241,15 @@ class TestMaskedKeyRejection:
"api.routes.user.upsert_organization_preferences",
new=AsyncMock(return_value=preferences),
) as upsert_preferences,
patch(
"api.routes.user.get_resolved_ai_model_configuration",
new=AsyncMock(
return_value=ResolvedAIModelConfiguration(
effective=_existing_openai_config(),
source="organization_v2",
)
),
),
):
existing = _existing_openai_config()
mock_db.get_user_configurations = AsyncMock(return_value=existing)

View file

@ -102,6 +102,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_concurrency.acquire_org_slot.assert_awaited_once_with(

View file

@ -26,7 +26,12 @@ def _embed_session():
def _embed_token(allowed_domains):
return SimpleNamespace(allowed_domains=allowed_domains, created_by=7, workflow_id=3)
return SimpleNamespace(
allowed_domains=allowed_domains,
created_by=7,
workflow_id=3,
organization_id=11,
)
def _patch_deps():
@ -35,6 +40,7 @@ def _patch_deps():
mgr = patch("api.routes.webrtc_signaling.signaling_manager").start()
db.get_embed_session_by_token = AsyncMock(return_value=_embed_session())
db.get_embed_token_by_id = AsyncMock(return_value=_embed_token(["example.com"]))
db.get_workflow_run = AsyncMock(return_value=SimpleNamespace(workflow_id=3))
db.get_user_by_id = AsyncMock(return_value=SimpleNamespace(id=7))
mgr.handle_websocket = AsyncMock()
return db, mgr

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

@ -101,6 +101,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,
)
@ -366,7 +367,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

@ -0,0 +1,99 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from api.routes import user as user_routes
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.configuration.ai_model_configuration import (
ResolvedAIModelConfiguration,
)
@pytest.mark.asyncio
async def test_validate_user_configurations_marks_stale_org_v2_config_validated(
monkeypatch,
):
stale_config = EffectiveAIModelConfiguration(
last_validated_at=datetime.now(UTC) - timedelta(seconds=120)
)
resolved = ResolvedAIModelConfiguration(
effective=stale_config,
source="organization_v2",
)
validate = AsyncMock(return_value={"status": [{"model": "all", "message": "ok"}]})
touch_validation_cache = AsyncMock()
class FakeValidator:
def __init__(self):
self.validate = validate
monkeypatch.setattr(
user_routes,
"get_resolved_ai_model_configuration",
AsyncMock(return_value=resolved),
)
monkeypatch.setattr(user_routes, "UserConfigurationValidator", FakeValidator)
monkeypatch.setattr(
user_routes,
"update_organization_ai_model_configuration_last_validated_at",
touch_validation_cache,
)
response = await user_routes.validate_user_configurations(
validity_ttl_seconds=60,
user=SimpleNamespace(
provider_id="provider-123",
selected_organization_id=42,
),
)
assert response == {"status": [{"model": "all", "message": "ok"}]}
validate.assert_awaited_once_with(
stale_config,
organization_id=42,
created_by="provider-123",
)
touch_validation_cache.assert_awaited_once_with(42)
@pytest.mark.asyncio
async def test_validate_user_configurations_uses_fresh_org_v2_validation_cache(
monkeypatch,
):
fresh_config = EffectiveAIModelConfiguration(last_validated_at=datetime.now(UTC))
resolved = ResolvedAIModelConfiguration(
effective=fresh_config,
source="organization_v2",
)
validate = AsyncMock()
touch_validation_cache = AsyncMock()
class FakeValidator:
def __init__(self):
self.validate = validate
monkeypatch.setattr(
user_routes,
"get_resolved_ai_model_configuration",
AsyncMock(return_value=resolved),
)
monkeypatch.setattr(user_routes, "UserConfigurationValidator", FakeValidator)
monkeypatch.setattr(
user_routes,
"update_organization_ai_model_configuration_last_validated_at",
touch_validation_cache,
)
response = await user_routes.validate_user_configurations(
validity_ttl_seconds=60,
user=SimpleNamespace(
provider_id="provider-123",
selected_organization_id=42,
),
)
assert response == {"status": []}
validate.assert_not_awaited()
touch_validation_cache.assert_not_awaited()

View file

@ -61,6 +61,7 @@ async def test_public_embed_offer_rejects_when_org_concurrency_limit_reached():
workflow_id=33,
workflow_run_id=501,
user=user,
organization_id=11,
connection_key="conn-1",
enforce_call_concurrency=True,
call_concurrency_source="public_embed",
@ -107,6 +108,7 @@ async def test_public_embed_renegotiation_does_not_acquire_another_slot():
workflow_id=33,
workflow_run_id=501,
user=user,
organization_id=11,
connection_key=connection_key,
enforce_call_concurrency=True,
call_concurrency_source="public_embed",
@ -126,7 +128,7 @@ async def test_signaling_websocket_rejects_run_not_owned_by_workflow():
from api.routes.webrtc_signaling import signaling_websocket
ws = _FakeWebSocket()
user = SimpleNamespace(id=7)
user = SimpleNamespace(id=7, selected_organization_id=11)
with (
patch("api.routes.webrtc_signaling.db_client") as mock_db,
@ -151,7 +153,7 @@ async def test_signaling_websocket_accepts_matching_workflow_and_run():
from api.routes.webrtc_signaling import signaling_websocket
ws = _FakeWebSocket()
user = SimpleNamespace(id=7)
user = SimpleNamespace(id=7, selected_organization_id=11)
with (
patch("api.routes.webrtc_signaling.db_client") as mock_db,

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

View file

@ -54,7 +54,7 @@ serviceAccount:
eks.amazonaws.com/role-arn: "" # set via --set
web:
replicaCount: 3
replicaCount: 2
autoscaling:
web:

View file

@ -15,17 +15,17 @@ exposure:
config:
environment: production
logLevel: INFO
logLevel: DEBUG
web:
replicaCount: 1
replicaCount: 2
resources:
requests:
cpu: 100m
memory: 256Mi
memory: 1Gi
limits:
cpu: "1"
memory: 1Gi
memory: 1.5Gi
pdb:
enabled: false
@ -33,7 +33,7 @@ workers:
replicaCount: 1
resources:
requests:
cpu: 50m
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
@ -46,7 +46,9 @@ ui:
autoscaling:
web:
enabled: false
enabled: true
minReplicas: 2
maxReplicas: 12
postgresql:
persistence:

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.c3Lmi0soSL
# timestamp: 2026-07-09T12:55:26+00:00
# filename: dograh-openapi-XXXXXX.json.XNTNNgR8o4
# timestamp: 2026-07-09T13:05:30+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,78 @@
"use client";
import type { CurrentUser, Team } from "@stackframe/stack";
import React, { useState } from "react";
import { toast } from "sonner";
import SpinLoader from "@/components/SpinLoader";
import { useAuth } from "@/lib/auth";
import { reloadApp } from "@/lib/browserReload";
import logger from "@/lib/logger";
// 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) {
logger.error("Failed to switch Stack team", error);
toast.error("Could not switch teams. Please try again.");
setIsSwitching(false);
}
};
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,302 @@
/**
* 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());
const toastErrorMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/browserReload", () => ({
reloadApp: reloadAppMock,
}));
vi.mock("sonner", () => ({
toast: {
error: toastErrorMock,
},
}));
// 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();
toastErrorMock.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("shows feedback and does not reload when Stack fails to persist the selected team", async () => {
stackState.user!.setSelectedTeam = vi.fn(async () => {
throw new Error("Stack write failed");
});
render(
withProviders(
<SidebarTeamSwitcher />,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
const switcher = await screen.findByRole("combobox");
fireEvent.click(switcher);
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
"Could not switch teams. Please try again."
);
});
expect(screen.queryByRole("status")).toBeNull();
expect(reloadAppMock).not.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 { 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,19 @@ 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 = app.useUser();
// 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,21 +96,17 @@ 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
const userId = stackUser?.id;
const contextValue = useMemo(() => ({
user: userRef.current as AuthUser,
isAuthenticated: !!userId,
loading: isLoading,
user: stackUser,
isAuthenticated: !!stackUser,
loading: false,
getAccessToken,
redirectToLogin,
logout,
provider: 'stack' as const,
getSelectedTeam,
listPermissions,
}), [userId, isLoading, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
}), [stackUser, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
return (
<AuthContext.Provider value={contextValue}>
@ -130,7 +127,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,81 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAuth } from "../AuthProvider";
import { StackProviderWrapper } from "../StackProviderWrapper";
type MockStackUser = {
id: string;
selectedTeam: null;
getAuthJson: () => Promise<{ accessToken: string }>;
};
const stackState = vi.hoisted(() => ({
user: null as MockStackUser | null,
}));
vi.mock("@stackframe/stack", () => {
class MockStackClientApp {
useUser = vi.fn(() => stackState.user);
constructor(
public options: {
projectId: string;
publishableClientKey: string;
}
) {}
}
return {
StackClientApp: MockStackClientApp,
StackProvider: ({ children }: { children: React.ReactNode }) => children,
StackTheme: ({ children }: { children: React.ReactNode }) => children,
};
});
function AuthProbe() {
const auth = useAuth();
return (
<div data-testid="auth-state">
{auth.loading ? "loading" : auth.user?.id ?? "signed-out"}
</div>
);
}
function makeUser(): MockStackUser {
return {
id: "stack-user-1",
selectedTeam: null,
getAuthJson: async () => ({ accessToken: "token" }),
};
}
describe("StackProviderWrapper", () => {
beforeEach(() => {
stackState.user = makeUser();
});
it("updates AuthContext when Stack's reactive user becomes null", () => {
const props = {
projectId: "00000000-0000-4000-8000-000000000000",
publishableClientKey: "publishable-client-key",
};
const { rerender } = render(
<StackProviderWrapper {...props}>
<AuthProbe />
</StackProviderWrapper>
);
expect(screen.getByTestId("auth-state").textContent).toBe("stack-user-1");
stackState.user = null;
rerender(
<StackProviderWrapper {...props}>
<AuthProbe />
</StackProviderWrapper>
);
expect(screen.getByTestId("auth-state").textContent).toBe("signed-out");
});
});

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/],
},
},
},
});