From fb4038a969cb11c3061729130e952838b5a736d8 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Thu, 9 Jul 2026 23:04:33 +0530 Subject: [PATCH] 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 * test: align config tests with org-level v2 resolution Co-Authored-By: Claude Fable 5 * chore: helm example values tweaks Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- ...918_backfill_org_model_configuration_v2.py | 215 ++ ...fb0_add_last_validated_at_on_org_config.py | 29 + api/db/models.py | 7 +- api/db/organization_client.py | 5 +- api/db/organization_configuration_client.py | 36 +- api/db/organization_usage_client.py | 31 +- api/db/workflow_client.py | 6 +- api/db/workflow_run_client.py | 6 +- api/routes/agent_stream.py | 1 + api/routes/campaign.py | 36 +- api/routes/knowledge_base.py | 3 +- api/routes/organization.py | 1 - api/routes/organization_usage.py | 1 - api/routes/public_agent.py | 1 + api/routes/public_embed.py | 2 +- api/routes/telephony.py | 23 +- api/routes/user.py | 59 +- api/routes/webrtc_signaling.py | 62 +- api/routes/workflow.py | 2 - api/routes/workflow_text_chat.py | 1 + .../campaign/campaign_call_dispatcher.py | 2 + .../configuration/ai_model_configuration.py | 86 +- api/services/organization_context.py | 1 - api/services/pipecat/in_memory_buffers.py | 4 +- .../pipecat/realtime_feedback_observer.py | 4 +- api/services/pipecat/run_pipeline.py | 59 +- api/services/quota_service.py | 67 +- api/services/telephony/ari_manager.py | 1 + api/services/workflow/qa/llm_config.py | 13 +- api/services/workflow/text_chat_runner.py | 1 - api/tasks/knowledge_base_processing.py | 3 +- .../integrations/_run_pipeline_helpers.py | 17 +- api/tests/test_ai_model_configuration_v2.py | 79 + ...ll_org_model_configuration_v2_migration.py | 148 ++ api/tests/test_masked_key_rejection.py | 147 +- api/tests/test_public_agent_routes.py | 1 + api/tests/test_public_signaling_origin.py | 8 +- api/tests/test_quota_service.py | 77 +- api/tests/test_realtime_feedback_events.py | 2 +- api/tests/test_realtime_feedback_observer.py | 2 +- api/tests/test_telephony_routes.py | 3 +- .../test_user_configuration_validation.py | 99 + .../test_webrtc_signaling_concurrency.py | 6 +- api/tests/test_workflow_text_chat.py | 44 +- api/tests/test_xai_tts_service_factory.py | 31 +- api/utils/transcript.py | 10 +- deploy/helm/dograh/examples/values-aws.yaml | 2 +- .../dograh/examples/values-single-node.yaml | 14 +- .../src/dograh_sdk/_generated_models.py | 4 +- ui/package-lock.json | 2001 +++++++++++++++-- ui/package.json | 7 +- ui/src/components/SpinLoader.tsx | 17 +- ui/src/components/layout/AppSidebar.tsx | 36 +- .../components/layout/SidebarTeamSwitcher.tsx | 78 + .../__tests__/SidebarTeamSwitcher.test.tsx | 302 +++ .../auth/providers/StackProviderWrapper.tsx | 31 +- .../__tests__/StackProviderWrapper.test.tsx | 81 + ui/src/lib/browserReload.ts | 5 + ui/vitest.config.mts | 28 + 59 files changed, 3531 insertions(+), 517 deletions(-) create mode 100644 api/alembic/versions/00b0201ad918_backfill_org_model_configuration_v2.py create mode 100644 api/alembic/versions/c52dc3cccfb0_add_last_validated_at_on_org_config.py create mode 100644 api/tests/test_backfill_org_model_configuration_v2_migration.py create mode 100644 api/tests/test_user_configuration_validation.py create mode 100644 ui/src/components/layout/SidebarTeamSwitcher.tsx create mode 100644 ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx create mode 100644 ui/src/lib/auth/providers/__tests__/StackProviderWrapper.test.tsx create mode 100644 ui/src/lib/browserReload.ts create mode 100644 ui/vitest.config.mts diff --git a/api/alembic/versions/00b0201ad918_backfill_org_model_configuration_v2.py b/api/alembic/versions/00b0201ad918_backfill_org_model_configuration_v2.py new file mode 100644 index 00000000..21af52f5 --- /dev/null +++ b/api/alembic/versions/00b0201ad918_backfill_org_model_configuration_v2.py @@ -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 diff --git a/api/alembic/versions/c52dc3cccfb0_add_last_validated_at_on_org_config.py b/api/alembic/versions/c52dc3cccfb0_add_last_validated_at_on_org_config.py new file mode 100644 index 00000000..f4ceeaf9 --- /dev/null +++ b/api/alembic/versions/c52dc3cccfb0_add_last_validated_at_on_org_config.py @@ -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") diff --git a/api/db/models.py b/api/db/models.py index 66837724..09a1a7dc 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -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") diff --git a/api/db/organization_client.py b/api/db/organization_client.py index c7bf16f3..307f9122 100644 --- a/api/db/organization_client.py +++ b/api/db/organization_client.py @@ -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 + ) ) ) ) diff --git a/api/db/organization_configuration_client.py b/api/db/organization_configuration_client.py index 94cb7576..1d197a98 100644 --- a/api/db/organization_configuration_client.py +++ b/api/db/organization_configuration_client.py @@ -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: diff --git a/api/db/organization_usage_client.py b/api/db/organization_usage_client.py index d1a52e7c..a9e52948 100644 --- a/api/db/organization_usage_client.py +++ b/api/db/organization_usage_client.py @@ -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, diff --git a/api/db/workflow_client.py b/api/db/workflow_client.py index 33e08c1a..c271c448 100644 --- a/api/db/workflow_client.py +++ b/api/db/workflow_client.py @@ -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() diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 38be831c..a5184874 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -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() diff --git a/api/routes/agent_stream.py b/api/routes/agent_stream.py index 105c4670..f5de8f38 100644 --- a/api/routes/agent_stream.py +++ b/api/routes/agent_stream.py @@ -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: diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 4519eca7..85413da4 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -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( diff --git a/api/routes/knowledge_base.py b/api/routes/knowledge_base.py index 70eeabbc..348a10b8 100644 --- a/api/routes/knowledge_base.py +++ b/api/routes/knowledge_base.py @@ -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 diff --git a/api/routes/organization.py b/api/routes/organization.py index 83c52bcf..eea48619 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -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 = ( diff --git a/api/routes/organization_usage.py b/api/routes/organization_usage.py index df3cd824..9212ef7f 100644 --- a/api/routes/organization_usage.py +++ b/api/routes/organization_usage.py @@ -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 diff --git a/api/routes/public_agent.py b/api/routes/public_agent.py index 65222c56..b1a338a3 100644 --- a/api/routes/public_agent.py +++ b/api/routes/public_agent.py @@ -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: diff --git a/api/routes/public_embed.py b/api/routes/public_embed.py index 616f9816..a6126eb5 100644 --- a/api/routes/public_embed.py +++ b/api/routes/public_embed.py @@ -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}") diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 336127c5..8caa209f 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -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: diff --git a/api/routes/user.py b/api/routes/user.py index c9c443b3..2622b167 100644 --- a/api/routes/user.py +++ b/api/routes/user.py @@ -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]) diff --git a/api/routes/webrtc_signaling.py b/api/routes/webrtc_signaling.py index 79eb12ff..0cf079a2 100644 --- a/api/routes/webrtc_signaling.py +++ b/api/routes/webrtc_signaling.py @@ -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", ) diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 37115da9..1783d235 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -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 diff --git a/api/routes/workflow_text_chat.py b/api/routes/workflow_text_chat.py index 47254330..649cfb71 100644 --- a/api/routes/workflow_text_chat.py +++ b/api/routes/workflow_text_chat.py @@ -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, ) diff --git a/api/services/campaign/campaign_call_dispatcher.py b/api/services/campaign/campaign_call_dispatcher.py index 9ccc3da6..b8ab1e79 100644 --- a/api/services/campaign/campaign_call_dispatcher.py +++ b/api/services/campaign/campaign_call_dispatcher.py @@ -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: diff --git a/api/services/configuration/ai_model_configuration.py b/api/services/configuration/ai_model_configuration.py index 3aabdae6..f43b98a3 100644 --- a/api/services/configuration/ai_model_configuration.py +++ b/api/services/configuration/ai_model_configuration.py @@ -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, diff --git a/api/services/organization_context.py b/api/services/organization_context.py index b17b8f4f..30a6f3bb 100644 --- a/api/services/organization_context.py +++ b/api/services/organization_context.py @@ -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 diff --git a/api/services/pipecat/in_memory_buffers.py b/api/services/pipecat/in_memory_buffers.py index 349c1add..7455f4fb 100644 --- a/api/services/pipecat/in_memory_buffers.py +++ b/api/services/pipecat/in_memory_buffers.py @@ -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: diff --git a/api/services/pipecat/realtime_feedback_observer.py b/api/services/pipecat/realtime_feedback_observer.py index 0644edbe..7217f2f6 100644 --- a/api/services/pipecat/realtime_feedback_observer.py +++ b/api/services/pipecat/realtime_feedback_observer.py @@ -53,10 +53,10 @@ from pipecat.frames.frames import ( TranscriptionFrame, TTSSpeakFrame, TTSTextFrame, - UserStartedSpeakingFrame, - UserStoppedSpeakingFrame, UserMuteStartedFrame, UserMuteStoppedFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 87c0f3d0..86852554 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -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, ) diff --git a/api/services/quota_service.py b/api/services/quota_service.py index c3f860b5..b3cdcdda 100644 --- a/api/services/quota_service.py +++ b/api/services/quota_service.py @@ -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, diff --git a/api/services/telephony/ari_manager.py b/api/services/telephony/ari_manager.py index fc337a4c..cc6a2fae 100644 --- a/api/services/telephony/ari_manager.py +++ b/api/services/telephony/ari_manager.py @@ -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: diff --git a/api/services/workflow/qa/llm_config.py b/api/services/workflow/qa/llm_config.py index 9f4d06f8..c43fad12 100644 --- a/api/services/workflow/qa/llm_config.py +++ b/api/services/workflow/qa/llm_config.py @@ -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, diff --git a/api/services/workflow/text_chat_runner.py b/api/services/workflow/text_chat_runner.py index 7fa31d4a..34db2a05 100644 --- a/api/services/workflow/text_chat_runner.py +++ b/api/services/workflow/text_chat_runner.py @@ -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, ) diff --git a/api/tasks/knowledge_base_processing.py b/api/tasks/knowledge_base_processing.py index 31f85530..11a3ef66 100644 --- a/api/tasks/knowledge_base_processing.py +++ b/api/tasks/knowledge_base_processing.py @@ -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 diff --git a/api/tests/integrations/_run_pipeline_helpers.py b/api/tests/integrations/_run_pipeline_helpers.py index 6b71c110..0d5ff6c8 100644 --- a/api/tests/integrations/_run_pipeline_helpers.py +++ b/api/tests/integrations/_run_pipeline_helpers.py @@ -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( diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py index 151f2013..ab1bd8a5 100644 --- a/api/tests/test_ai_model_configuration_v2.py +++ b/api/tests/test_ai_model_configuration_v2.py @@ -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( diff --git a/api/tests/test_backfill_org_model_configuration_v2_migration.py b/api/tests/test_backfill_org_model_configuration_v2_migration.py new file mode 100644 index 00000000..42bcaad1 --- /dev/null +++ b/api/tests/test_backfill_org_model_configuration_v2_migration.py @@ -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 + ) diff --git a/api/tests/test_masked_key_rejection.py b/api/tests/test_masked_key_rejection.py index 437d1cc9..1b02641a 100644 --- a/api/tests/test_masked_key_rejection.py +++ b/api/tests/test_masked_key_rejection.py @@ -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) diff --git a/api/tests/test_public_agent_routes.py b/api/tests/test_public_agent_routes.py index dee4cbef..4c2a1793 100644 --- a/api/tests/test_public_agent_routes.py +++ b/api/tests/test_public_agent_routes.py @@ -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( diff --git a/api/tests/test_public_signaling_origin.py b/api/tests/test_public_signaling_origin.py index e181ad4d..a5f0a76e 100644 --- a/api/tests/test_public_signaling_origin.py +++ b/api/tests/test_public_signaling_origin.py @@ -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 diff --git a/api/tests/test_quota_service.py b/api/tests/test_quota_service.py index 7aa00ef2..441254f5 100644 --- a/api/tests/test_quota_service.py +++ b/api/tests/test_quota_service.py @@ -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() diff --git a/api/tests/test_realtime_feedback_events.py b/api/tests/test_realtime_feedback_events.py index 1cfcaf89..82bc5cf2 100644 --- a/api/tests/test_realtime_feedback_events.py +++ b/api/tests/test_realtime_feedback_events.py @@ -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, ) diff --git a/api/tests/test_realtime_feedback_observer.py b/api/tests/test_realtime_feedback_observer.py index 781339b5..233b6c2a 100644 --- a/api/tests/test_realtime_feedback_observer.py +++ b/api/tests/test_realtime_feedback_observer.py @@ -1,5 +1,5 @@ -from types import SimpleNamespace import re +from types import SimpleNamespace import pytest from pipecat.frames.frames import ( diff --git a/api/tests/test_telephony_routes.py b/api/tests/test_telephony_routes.py index f92c63d3..2aeb5588 100644 --- a/api/tests/test_telephony_routes.py +++ b/api/tests/test_telephony_routes.py @@ -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 ( diff --git a/api/tests/test_user_configuration_validation.py b/api/tests/test_user_configuration_validation.py new file mode 100644 index 00000000..d4032d94 --- /dev/null +++ b/api/tests/test_user_configuration_validation.py @@ -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() diff --git a/api/tests/test_webrtc_signaling_concurrency.py b/api/tests/test_webrtc_signaling_concurrency.py index f5163d15..73236c35 100644 --- a/api/tests/test_webrtc_signaling_concurrency.py +++ b/api/tests/test_webrtc_signaling_concurrency.py @@ -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, diff --git a/api/tests/test_workflow_text_chat.py b/api/tests/test_workflow_text_chat.py index 972661bc..7b964935 100644 --- a/api/tests/test_workflow_text_chat.py +++ b/api/tests/test_workflow_text_chat.py @@ -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( diff --git a/api/tests/test_xai_tts_service_factory.py b/api/tests/test_xai_tts_service_factory.py index dc612016..2a53c038 100644 --- a/api/tests/test_xai_tts_service_factory.py +++ b/api/tests/test_xai_tts_service_factory.py @@ -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 diff --git a/api/utils/transcript.py b/api/utils/transcript.py index 6f8d32ee..a71b8cf6 100644 --- a/api/utils/transcript.py +++ b/api/utils/transcript.py @@ -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 diff --git a/deploy/helm/dograh/examples/values-aws.yaml b/deploy/helm/dograh/examples/values-aws.yaml index 39175b36..8facab25 100644 --- a/deploy/helm/dograh/examples/values-aws.yaml +++ b/deploy/helm/dograh/examples/values-aws.yaml @@ -54,7 +54,7 @@ serviceAccount: eks.amazonaws.com/role-arn: "" # set via --set web: - replicaCount: 3 + replicaCount: 2 autoscaling: web: diff --git a/deploy/helm/dograh/examples/values-single-node.yaml b/deploy/helm/dograh/examples/values-single-node.yaml index b0f293f3..4b50b12b 100644 --- a/deploy/helm/dograh/examples/values-single-node.yaml +++ b/deploy/helm/dograh/examples/values-single-node.yaml @@ -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: diff --git a/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py index abe107d5..0492509c 100644 --- a/sdk/python/src/dograh_sdk/_generated_models.py +++ b/sdk/python/src/dograh_sdk/_generated_models.py @@ -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 diff --git a/ui/package-lock.json b/ui/package-lock.json index be9d890c..e4165468 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "1.40.0", + "version": "1.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.40.0", + "version": "1.41.0", "dependencies": { "@calcom/embed-react": "^1.5.3", "@dagrejs/dagre": "^1.1.4", @@ -57,18 +57,22 @@ "@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" } }, "node_modules/@ai-sdk/gateway": { @@ -182,6 +186,57 @@ "module-details-from-path": "^1.0.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@aws-sdk/client-kms": { "version": "3.1079.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1079.0.tgz", @@ -482,6 +537,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -668,7 +724,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", - "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -721,6 +776,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@calcom/embed-core": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/@calcom/embed-core/-/embed-core-1.5.3.tgz", @@ -750,6 +818,148 @@ "@calcom/embed-core": "1.5.3" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dagrejs/dagre": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", @@ -774,32 +984,10 @@ "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.0.tgz", - "integrity": "sha512-H+N/FqT07NmLmt6OFFtDfwe8PNygprzBikrEMyQfgqSmT0vzE515Pz7R8izwB9q/zsH/MA64AKoul3sA6/CzVg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", - "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -812,7 +1000,6 @@ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -831,15 +1018,13 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/babel-plugin/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -849,7 +1034,6 @@ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -862,22 +1046,19 @@ "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/memoize": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/react": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -902,7 +1083,6 @@ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", @@ -915,22 +1095,19 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/unitless": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", - "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -939,15 +1116,13 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", @@ -1509,6 +1684,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", @@ -2236,7 +2429,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2485,6 +2677,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2506,6 +2699,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2521,6 +2715,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", @@ -2571,6 +2766,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", @@ -2635,6 +2831,16 @@ "@oslojs/encoding": "1.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@posthog/core": { "version": "1.34.0", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.34.0.tgz", @@ -8510,6 +8716,300 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", @@ -8555,6 +9055,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9749,6 +10250,7 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.16" } @@ -10032,6 +10534,64 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", @@ -10043,6 +10603,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/css-font-loading-module": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", @@ -10146,6 +10724,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -10172,26 +10757,26 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", - "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -10202,6 +10787,7 @@ "integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -10211,7 +10797,6 @@ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "*" } @@ -10721,12 +11306,160 @@ "node": ">= 18" } }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -10736,29 +11469,25 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -10769,15 +11498,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -10790,7 +11517,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -10800,7 +11526,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -10809,15 +11534,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -10834,7 +11557,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -10848,7 +11570,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -10861,7 +11582,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -10876,7 +11596,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -10892,15 +11611,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@xyflow/react": { "version": "12.10.2", @@ -10967,6 +11684,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10988,7 +11706,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -11058,7 +11775,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11076,7 +11792,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11092,8 +11807,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-colors": { "version": "4.1.3", @@ -11316,6 +12030,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -11401,7 +12125,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -11449,6 +12172,16 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bn.js": { "version": "4.12.4", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", @@ -11519,6 +12252,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -11685,6 +12419,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11723,7 +12467,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -11962,7 +12705,6 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", - "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -11974,6 +12716,15 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/crc": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/crc/-/crc-4.3.2.tgz", @@ -12025,6 +12776,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -12134,6 +12899,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -12225,6 +12991,58 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -12330,6 +13148,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -12480,12 +13305,18 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" @@ -12577,12 +13408,24 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", - "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -12591,8 +13434,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-abstract": { "version": "1.23.9", @@ -12864,6 +13706,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13037,6 +13880,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -13323,7 +14167,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -13337,6 +14180,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/export-to-csv": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/export-to-csv/-/export-to-csv-1.4.0.tgz", @@ -13438,8 +14291,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.19.1", @@ -13487,8 +14339,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", @@ -13949,11 +14800,23 @@ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "react-is": "^16.7.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -13982,6 +14845,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -14379,6 +15243,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -14585,7 +15456,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -14600,7 +15470,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -14674,6 +15543,96 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -14697,8 +15656,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", @@ -14822,6 +15780,27 @@ "lightningcss-win32-x64-msvc": "1.29.2" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-darwin-arm64": { "version": "1.29.2", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", @@ -15036,15 +16015,13 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/loader-runner": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" }, @@ -15105,6 +16082,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -15124,19 +16111,24 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -15176,7 +16168,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -15220,7 +16211,6 @@ "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -15332,14 +16322,14 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "15.5.20", "resolved": "https://registry.npmjs.org/next/-/next-15.5.20.tgz", "integrity": "sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.5.20", "@swc/helpers": "0.5.15", @@ -15588,6 +16578,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -15726,7 +16730,6 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -15740,6 +16743,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -15795,7 +16811,6 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -16035,6 +17050,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -16169,6 +17219,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16199,6 +17250,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -16225,6 +17277,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -16249,13 +17302,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -16395,7 +17450,6 @@ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", @@ -16461,7 +17515,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -16499,8 +17554,7 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -16537,7 +17591,6 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16616,11 +17669,46 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -16781,6 +17869,19 @@ "node": ">=10" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -16792,7 +17893,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16829,7 +17929,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -16841,8 +17940,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/secure-json-parse": { "version": "4.0.0", @@ -17101,6 +18199,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -17170,6 +18275,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stacktrace-parser": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", @@ -17191,6 +18303,13 @@ "node": ">=8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -17386,8 +18505,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", @@ -17427,6 +18545,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz", @@ -17441,7 +18566,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz", "integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -17470,7 +18596,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -17488,8 +18613,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/thread-stream": { "version": "3.1.0", @@ -17530,15 +18654,32 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", - "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.3", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -17548,11 +18689,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -17563,11 +18707,12 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17575,6 +18720,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz", + "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.7" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz", + "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -17594,6 +18769,19 @@ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -17769,6 +18957,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17796,10 +18985,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unrs-resolver": { @@ -17895,7 +19094,6 @@ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -17977,12 +19175,459 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2" }, @@ -18007,7 +19652,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.3.tgz", "integrity": "sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -18053,7 +19697,6 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -18063,7 +19706,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -18077,11 +19719,20 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -18202,6 +19853,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -18235,6 +19903,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -18247,16 +19932,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", @@ -18361,6 +20036,7 @@ "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "license": "MIT", + "peer": true, "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", @@ -18401,6 +20077,7 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/ui/package.json b/ui/package.json index 76f9a8ad..051b5664 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" } } diff --git a/ui/src/components/SpinLoader.tsx b/ui/src/components/SpinLoader.tsx index 06d7d531..f738dc2a 100644 --- a/ui/src/components/SpinLoader.tsx +++ b/ui/src/components/SpinLoader.tsx @@ -1,9 +1,14 @@ import { Loader2 } from "lucide-react"; -export default function SpinLoader(){ - return( -
- -
- ) +interface SpinLoaderProps { + label?: string; +} + +export default function SpinLoader({ label }: SpinLoaderProps) { + return ( +
+ + {label && {label}} +
+ ); } diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx index 9483edc1..77062f6e 100644 --- a/ui/src/components/layout/AppSidebar.tsx +++ b/ui/src/components/layout/AppSidebar.tsx @@ -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(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" && (
- - } - > - { - router.refresh(); - }} - /> - +
)} diff --git a/ui/src/components/layout/SidebarTeamSwitcher.tsx b/ui/src/components/layout/SidebarTeamSwitcher.tsx new file mode 100644 index 00000000..33b6f957 --- /dev/null +++ b/ui/src/components/layout/SidebarTeamSwitcher.tsx @@ -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 ; +} + +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 ( +
+ } + > + { + void handleChange(team); + }} + triggerClassName="w-full" + /> + + {isSwitching && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx b/ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx new file mode 100644 index 00000000..d3075c04 --- /dev/null +++ b/ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx @@ -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 | 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; + }) => ( + + ), +})); + +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 ? ( +
+ ) : ( + this.props.children + ); + } +} + +const translationValue = { + quetzalKeys: new Map(), + quetzalLocale: new Map(), +}; + +function withProviders( + children: React.ReactNode, + authValue?: AuthContextType +) { + const inner = authValue ? ( + {children} + ) : ( + children + ); + return ( + + {inner} + + ); +} + +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( + errors.push(e)}> + + + ) + ); + expect(await screen.findByRole("combobox")).toBeTruthy(); + + stackState.user = null; + rerender( + withProviders( + errors.push(e)}> + + + ) + ); + + 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( + , + 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((resolve) => { + finishSwitch = () => { + if (stackState.user) { + stackState.user.selectedTeam = team; + } + resolve(); + }; + }); + }); + + render( + withProviders( + , + 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( + , + 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( + errors.push(e)}> + + , + 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( + errors.push(e)}> + + , + makeAuthValue(null) + ) + ); + + expect(errors.map((e) => e.message)).toEqual([]); + expect(screen.queryByRole("combobox")).toBeNull(); + }); +}); diff --git a/ui/src/lib/auth/providers/StackProviderWrapper.tsx b/ui/src/lib/auth/providers/StackProviderWrapper.tsx index 128ecbc4..5398ff5e 100644 --- a/ui/src/lib/auth/providers/StackProviderWrapper.tsx +++ b/ui/src/lib/auth/providers/StackProviderWrapper.tsx @@ -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; +}) { + 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 ( @@ -130,7 +127,7 @@ export function StackProviderWrapper({ children, projectId, publishableClientKey return ( - + {children} diff --git a/ui/src/lib/auth/providers/__tests__/StackProviderWrapper.test.tsx b/ui/src/lib/auth/providers/__tests__/StackProviderWrapper.test.tsx new file mode 100644 index 00000000..46ac2af0 --- /dev/null +++ b/ui/src/lib/auth/providers/__tests__/StackProviderWrapper.test.tsx @@ -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 ( +
+ {auth.loading ? "loading" : auth.user?.id ?? "signed-out"} +
+ ); +} + +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( + + + + ); + expect(screen.getByTestId("auth-state").textContent).toBe("stack-user-1"); + + stackState.user = null; + rerender( + + + + ); + + expect(screen.getByTestId("auth-state").textContent).toBe("signed-out"); + }); +}); diff --git a/ui/src/lib/browserReload.ts b/ui/src/lib/browserReload.ts new file mode 100644 index 00000000..755e94ac --- /dev/null +++ b/ui/src/lib/browserReload.ts @@ -0,0 +1,5 @@ +"use client"; + +export function reloadApp() { + window.location.reload(); +} diff --git a/ui/vitest.config.mts b/ui/vitest.config.mts new file mode 100644 index 00000000..b606f307 --- /dev/null +++ b/ui/vitest.config.mts @@ -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/], + }, + }, + }, +});