diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0fef5f8a..dc38bc3d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.33.0" + ".": "1.34.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8842b706..894aa1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 1.34.0 (2026-06-03) + + + +## What's Changed +### Features +* feat: add mcp guides for various topic and stages for bot building by @a6kme in https://github.com/dograh-hq/dograh/pull/380 +* feat: allow overriding base URL of OpenAI STT and TTS by @developer603 in https://github.com/dograh-hq/dograh/pull/377 +* feat: add Azure AI multi-provider support (TTS, STT, Embeddings, Realtime) by @vishaldhateria in https://github.com/dograh-hq/dograh/pull/381 +### Bug Fixes +* fix: support object and array parameters in custom HTTP tools by @mvanhorn in https://github.com/dograh-hq/dograh/pull/373 +* fix(telephony): resolve transfer context via call-sid index instead of KEYS scan by @shiminshen in https://github.com/dograh-hq/dograh/pull/387 +* fix(webrtc): enforce embed allowed-domain policy on public signaling websocket by @shiminshen in https://github.com/dograh-hq/dograh/pull/388 +* fix: use runtime BACKEND_URL for proxying by @a6kme in https://github.com/dograh-hq/dograh/pull/411 +* fix: add CORS preflight handler and ACAO header for embed config endpoint by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/403 +### Other Changes +* Add Sarvam LLM, update Sarvam STT models, expose usage_info on run detail by @abhaybabbar in https://github.com/dograh-hq/dograh/pull/351 +* fix: make email lookup case-insensitive in get_user_by_email by @developer603 in https://github.com/dograh-hq/dograh/pull/397 + +## New Contributors +* @abhaybabbar made their first contribution in https://github.com/dograh-hq/dograh/pull/351 +* @mvanhorn made their first contribution in https://github.com/dograh-hq/dograh/pull/373 +* @developer603 made their first contribution in https://github.com/dograh-hq/dograh/pull/377 +* @vishaldhateria made their first contribution in https://github.com/dograh-hq/dograh/pull/381 +* @shiminshen made their first contribution in https://github.com/dograh-hq/dograh/pull/387 + +**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.33.0...dograh-v1.34.0 + ## [1.33.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.32.0...dograh-v1.33.0) (2026-05-31) diff --git a/api/alembic/versions/384be6596b36_make_email_case_insensitive.py b/api/alembic/versions/384be6596b36_make_email_case_insensitive.py index a300f477..11357c98 100644 --- a/api/alembic/versions/384be6596b36_make_email_case_insensitive.py +++ b/api/alembic/versions/384be6596b36_make_email_case_insensitive.py @@ -5,28 +5,38 @@ Revises: 6bd9f67ec994 Create Date: 2026-06-02 07:58:00.002359 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. -revision: str = '384be6596b36' -down_revision: Union[str, None] = '6bd9f67ec994' +revision: str = "384be6596b36" +down_revision: Union[str, None] = "6bd9f67ec994" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_email'), table_name='users') - op.create_index('ix_users_email_lower', 'users', [sa.literal_column('lower(email)')], unique=True, postgresql_where=sa.text('email IS NOT NULL')) + op.drop_index(op.f("ix_users_email"), table_name="users") + op.create_index( + "ix_users_email_lower", + "users", + [sa.literal_column("lower(email)")], + unique=True, + postgresql_where=sa.text("email IS NOT NULL"), + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('ix_users_email_lower', table_name='users', postgresql_where=sa.text('email IS NOT NULL')) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.drop_index( + "ix_users_email_lower", + table_name="users", + postgresql_where=sa.text("email IS NOT NULL"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) # ### end Alembic commands ### diff --git a/api/app.py b/api/app.py index b2b28111..1dd9413f 100644 --- a/api/app.py +++ b/api/app.py @@ -117,6 +117,15 @@ app.add_middleware( allow_headers=["*"], ) + +def _add_public_embed_cors_middleware() -> None: + from api.routes.public_embed import PublicEmbedCORSMiddleware + + app.add_middleware(PublicEmbedCORSMiddleware, api_prefix=API_PREFIX) + + +_add_public_embed_cors_middleware() + api_router = APIRouter() # include subrouters here diff --git a/api/pyproject.toml b/api/pyproject.toml index e5c764bc..b0368db3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,5 +1,5 @@ [project] name = "dograh-api" -version = "1.33.0" +version = "1.34.0" description = "Backend API for Dograh voice AI platform" requires-python = ">=3.13,<3.14" diff --git a/api/routes/public_embed.py b/api/routes/public_embed.py index 058def54..e8a699a7 100644 --- a/api/routes/public_embed.py +++ b/api/routes/public_embed.py @@ -7,6 +7,7 @@ They handle CORS, domain validation, and session management for embedded workflo import secrets from datetime import UTC, datetime, timedelta from typing import Optional +from urllib.parse import urlsplit from fastapi import ( APIRouter, @@ -16,6 +17,8 @@ from fastapi import ( ) from loguru import logger from pydantic import BaseModel +from starlette.datastructures import Headers +from starlette.types import ASGIApp, Receive, Scope, Send from api.db import db_client from api.enums import WorkflowRunMode @@ -27,6 +30,9 @@ from api.routes.turn_credentials import ( router = APIRouter(prefix="/public/embed") +EMBED_CORS_ALLOW_HEADERS = "Content-Type, Origin" +EMBED_CORS_MAX_AGE = "86400" + class InitEmbedRequest(BaseModel): """Request model for initializing an embed session""" @@ -70,11 +76,9 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: # If no domains specified, allow all origins return True - # Extract domain from origin (remove protocol) - if "://" in origin: - domain = origin.split("://")[1].split("/")[0].split(":")[0] - else: - domain = origin + domain, origin_port = _parse_origin_host_port(origin) + if not domain: + return False # Normalize domain for www matching def normalize_www(d: str) -> tuple[str, str]: @@ -87,16 +91,23 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: domain_variants = normalize_www(domain) for allowed in allowed_domains: + allowed = str(allowed).strip().lower() if allowed == "*": return True - elif allowed.startswith("*."): + allowed_domain, allowed_port = _parse_origin_host_port(allowed) + if not allowed_domain: + continue + if allowed_port is not None and allowed_port != origin_port: + continue + + if allowed_domain.startswith("*."): # Wildcard subdomain matching - base_domain = allowed[2:] + base_domain = allowed_domain[2:] if domain == base_domain or domain.endswith("." + base_domain): return True else: # Check both www and non-www versions - allowed_variants = normalize_www(allowed) + allowed_variants = normalize_www(allowed_domain) # If any variant of domain matches any variant of allowed, it's valid if any( dv in allowed_variants or av in domain_variants @@ -108,6 +119,24 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: return False +def _parse_origin_host_port(value: str) -> tuple[str, str | None]: + candidate = value.strip().lower() + if not candidate: + return "", None + + if "://" not in candidate and not candidate.startswith("//"): + candidate = f"//{candidate}" + + parsed = urlsplit(candidate) + try: + parsed_port = parsed.port + except ValueError: + parsed_port = None + + port = str(parsed_port) if parsed_port is not None else None + return (parsed.hostname or "").rstrip("."), port + + def generate_session_token() -> str: """Generate a cryptographically secure session token""" return f"emb_session_{secrets.token_urlsafe(32)}" @@ -121,8 +150,120 @@ def get_request_origin(request: Request) -> str: return origin +def _cors_response(origin: str, methods: str) -> Response: + return Response( + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": EMBED_CORS_ALLOW_HEADERS, + "Access-Control-Max-Age": EMBED_CORS_MAX_AGE, + "Vary": "Origin", + } + ) + + +def _allow_embed_origin(response: Response, origin: str) -> None: + response.headers["Access-Control-Allow-Origin"] = origin + vary = response.headers.get("Vary") + if not vary: + response.headers["Vary"] = "Origin" + return + + vary_values = {value.strip().lower() for value in vary.split(",")} + if "origin" not in vary_values: + response.headers["Vary"] = f"{vary}, Origin" + + +async def _config_preflight_response(token: str, origin: str) -> Response: + embed_token = await db_client.get_embed_token_by_token(token) + if not embed_token or not embed_token.is_active: + return Response(status_code=403) + + if not validate_origin(origin, embed_token.allowed_domains or []): + return Response(status_code=403) + + return _cors_response(origin, "GET, OPTIONS") + + +async def _turn_credentials_preflight_response( + session_token: str, origin: str +) -> Response: + embed_session = await db_client.get_embed_session_by_token(session_token) + if not embed_session: + return Response(status_code=403) + + if embed_session.expires_at and embed_session.expires_at < datetime.now(UTC): + return Response(status_code=403) + + embed_token = await db_client.get_embed_token_by_id(embed_session.embed_token_id) + if not embed_token: + return Response(status_code=403) + + if not validate_origin(origin, embed_token.allowed_domains or []): + return Response(status_code=403) + + return _cors_response(origin, "GET, OPTIONS") + + +async def build_public_embed_preflight_response( + path: str, origin: str, requested_method: str, api_prefix: str = "/api/v1" +) -> Response | None: + """Handle embed preflights before global CORSMiddleware rejects external sites.""" + public_embed_prefix = f"{api_prefix.rstrip('/')}/public/embed" + + if path == f"{public_embed_prefix}/init": + if requested_method.upper() != "POST": + return Response(status_code=405) + return _cors_response(origin, "POST, OPTIONS") + + config_prefix = f"{public_embed_prefix}/config/" + if path.startswith(config_prefix): + if requested_method.upper() != "GET": + return Response(status_code=405) + token = path[len(config_prefix) :].split("/", 1)[0] + return await _config_preflight_response(token, origin) + + turn_credentials_prefix = f"{public_embed_prefix}/turn-credentials/" + if path.startswith(turn_credentials_prefix): + if requested_method.upper() != "GET": + return Response(status_code=405) + session_token = path[len(turn_credentials_prefix) :].split("/", 1)[0] + return await _turn_credentials_preflight_response(session_token, origin) + + return None + + +class PublicEmbedCORSMiddleware: + """Allow token-gated embed CORS before global SaaS CORS rejects preflights.""" + + def __init__(self, app: ASGIApp, api_prefix: str = "/api/v1"): + self.app = app + self.api_prefix = api_prefix + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("method") != "OPTIONS": + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + origin = headers.get("origin") + requested_method = headers.get("access-control-request-method") + + if origin and requested_method: + response = await build_public_embed_preflight_response( + scope.get("path", ""), origin, requested_method, self.api_prefix + ) + if response is not None: + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + @router.post("/init", response_model=InitEmbedResponse) -async def initialize_embed_session(request: Request, init_request: InitEmbedRequest): +async def initialize_embed_session( + request: Request, init_request: InitEmbedRequest, response: Response +): """Initialize an embed session with token validation and domain checking. This endpoint: @@ -158,6 +299,9 @@ async def initialize_embed_session(request: Request, init_request: InitEmbedRequ ) raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + if origin: + _allow_embed_origin(response, origin) + # Create workflow run try: workflow_run = await db_client.create_workflow_run( @@ -204,8 +348,19 @@ async def initialize_embed_session(request: Request, init_request: InitEmbedRequ ) +@router.options("/config/{token}") +async def options_embed_config(token: str, request: Request): + """Fallback OPTIONS handler for the embed config endpoint. + + Browser preflights include Access-Control-Request-Method and are handled by + PublicEmbedCORSMiddleware before global CORS. This keeps non-conformant + OPTIONS requests on the same validation path. + """ + return await _config_preflight_response(token, request.headers.get("origin", "")) + + @router.get("/config/{token}", response_model=EmbedConfigResponse) -async def get_embed_config(token: str, request: Request): +async def get_embed_config(token: str, request: Request, response: Response): """Get embed configuration without creating a session. This endpoint is used to fetch widget configuration for display purposes @@ -226,6 +381,11 @@ async def get_embed_config(token: str, request: Request): if not validate_origin(origin, embed_token.allowed_domains or []): raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + # Set CORS header explicitly; the global CORSMiddleware covers only + # first-party origins; this endpoint is fetched by external embed sites. + if origin: + _allow_embed_origin(response, origin) + # Extract settings with defaults settings = embed_token.settings or {} @@ -243,24 +403,20 @@ async def get_embed_config(token: str, request: Request): @router.options("/init") async def options_init(request: Request): - """Handle CORS preflight for init endpoint""" + """Fallback OPTIONS handler for init endpoint.""" + # Browser preflights are handled by PublicEmbedCORSMiddleware before global CORS. # For init endpoint, we need to check the token in the request body # But OPTIONS requests don't have body, so we'll be permissive # The actual validation happens in the POST request origin = request.headers.get("origin", "*") - return Response( - headers={ - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Origin", - "Access-Control-Max-Age": "86400", - } - ) + return _cors_response(origin, "POST, OPTIONS") @router.get("/turn-credentials/{session_token}", response_model=TurnCredentialsResponse) -async def get_public_turn_credentials(session_token: str, request: Request): +async def get_public_turn_credentials( + session_token: str, request: Request, response: Response +): """Get TURN credentials for an embed session. This endpoint allows embedded widgets to obtain TURN server credentials @@ -295,6 +451,9 @@ async def get_public_turn_credentials(session_token: str, request: Request): ) raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + if origin: + _allow_embed_origin(response, origin) + # Check if TURN is configured if not TURN_SECRET: raise HTTPException( @@ -316,63 +475,8 @@ async def get_public_turn_credentials(session_token: str, request: Request): @router.options("/turn-credentials/{session_token}") async def options_turn_credentials(request: Request, session_token: str): - """Handle CORS preflight for TURN credentials endpoint""" - origin = request.headers.get("origin", "*") - - # Try to validate the session token and get allowed domains - allowed_origin = origin - try: - embed_session = await db_client.get_embed_session_by_token(session_token) - if embed_session: - embed_token = await db_client.get_embed_token_by_id( - embed_session.embed_token_id - ) - if embed_token: - # Check if origin is in allowed domains (empty means allow all) - if validate_origin(origin, embed_token.allowed_domains or []): - allowed_origin = origin - else: - allowed_origin = "" - except Exception: - # On error, be permissive for OPTIONS - pass - - return Response( - headers={ - "Access-Control-Allow-Origin": allowed_origin, - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", - } - ) - - -@router.options("/config/{token}") -async def options_config(request: Request, token: str): - """Handle CORS preflight for config endpoint""" - # Get origin header - origin = request.headers.get("origin", "*") - - # Try to validate the token and get allowed domains - allowed_origin = origin - try: - embed_token = await db_client.get_embed_token_by_token(token) - if embed_token and embed_token.is_active: - # Check if origin is in allowed domains - if validate_origin(origin, embed_token.allowed_domains or []): - allowed_origin = origin - else: - # If not allowed, don't include the origin - allowed_origin = "" - except Exception: - # On error, be permissive for OPTIONS - pass - - return Response( - headers={ - "Access-Control-Allow-Origin": allowed_origin, - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", - } + """Fallback OPTIONS handler for TURN credentials endpoint.""" + # Browser preflights are handled by PublicEmbedCORSMiddleware before global CORS. + return await _turn_credentials_preflight_response( + session_token, request.headers.get("origin", "") ) diff --git a/api/services/pipecat/pre_call_fetch.py b/api/services/pipecat/pre_call_fetch.py index 77761117..8a2025bb 100644 --- a/api/services/pipecat/pre_call_fetch.py +++ b/api/services/pipecat/pre_call_fetch.py @@ -15,6 +15,29 @@ from api.utils.credential_auth import build_auth_header PRE_CALL_FETCH_TIMEOUT_SECONDS = 10 +def _extract_initial_context(response_data: Dict[str, Any]) -> Dict[str, Any]: + """Pull the context variables out of a pre-call fetch response. + + The canonical key is ``initial_context``. The legacy ``dynamic_variables`` + key is still accepted for backward compatibility, so existing endpoints + keep working; ``initial_context`` takes precedence when both are present. + + Either key may appear at the top level or nested under ``call_inbound``: + {"call_inbound": {"initial_context": {...}}} | {"initial_context": {...}} + {"call_inbound": {"dynamic_variables": {...}}} | {"dynamic_variables": {...}} + """ + container = response_data.get("call_inbound") + if not isinstance(container, dict): + container = response_data + + for key in ("initial_context", "dynamic_variables"): + value = container.get(key) + if isinstance(value, dict): + return value + + return {} + + async def execute_pre_call_fetch( *, url: str, @@ -77,24 +100,16 @@ async def execute_pre_call_fetch( ) return {} - # Extract dynamic_variables from Retell-compatible response - # Supports: {call_inbound: {dynamic_variables: {...}}} - # or: {dynamic_variables: {...}} - dynamic_vars = {} - call_inbound = response_data.get("call_inbound") - if isinstance(call_inbound, dict): - dynamic_vars = call_inbound.get("dynamic_variables", {}) - elif "dynamic_variables" in response_data: - dynamic_vars = response_data["dynamic_variables"] - - if not isinstance(dynamic_vars, dict): - dynamic_vars = {} + # Extract the variables to merge into initial_context. Prefers + # the canonical `initial_context` key, falling back to the + # legacy `dynamic_variables` key for backward compatibility. + initial_context_vars = _extract_initial_context(response_data) logger.info( f"Pre-call fetch: success ({response.status_code}), " - f"dynamic_variables keys: {list(dynamic_vars.keys())}" + f"initial_context keys: {list(initial_context_vars.keys())}" ) - return dynamic_vars + return initial_context_vars else: logger.warning( f"Pre-call fetch: HTTP {response.status_code} - " diff --git a/api/services/telephony/providers/vobiz/routes.py b/api/services/telephony/providers/vobiz/routes.py index 15c2def9..6e8e1317 100644 --- a/api/services/telephony/providers/vobiz/routes.py +++ b/api/services/telephony/providers/vobiz/routes.py @@ -6,9 +6,8 @@ provider registry — see ProviderSpec.router. import json from datetime import UTC, datetime -from typing import Optional -from fastapi import APIRouter, Header, Request +from fastapi import APIRouter, HTTPException, Request from loguru import logger from pipecat.utils.run_context import set_current_run_id from starlette.responses import HTMLResponse @@ -29,6 +28,30 @@ from api.utils.telephony_helper import ( router = APIRouter() +async def _verify_vobiz_callback( + provider, + webhook_url: str, + callback_data: dict, + headers: dict, + raw_body: str, + *, + log_prefix: str, +) -> None: + """Verify a Vobiz callback signature, failing closed. + + Vobiz signs every callback, so a missing signature header is an invalid + request — ``provider.verify_inbound_signature`` returns ``False`` for both + missing and forged signatures. Reject with HTTP 403 (per Vobiz's + callback-validation docs) so the caller never reaches status processing. + """ + is_valid = await provider.verify_inbound_signature( + webhook_url, callback_data, headers, raw_body + ) + if not is_valid: + logger.warning(f"{log_prefix} Invalid or missing Vobiz callback signature") + raise HTTPException(status_code=403, detail="Invalid webhook signature") + + @router.post("/vobiz-xml", include_in_schema=False) async def handle_vobiz_xml_webhook( workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int @@ -65,8 +88,6 @@ async def handle_vobiz_xml_webhook( async def handle_vobiz_hangup_callback( workflow_run_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz hangup callback (sent when call ends). @@ -75,82 +96,23 @@ async def handle_vobiz_hangup_callback( """ set_current_run_id(workflow_run_id) - # Logging all headers and body to understand what Vobiz actually sends all_headers = dict(request.headers) - logger.info( - f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}" - ) # Parse the callback data from the raw body so signed webhooks can verify # the exact bytes Vobiz sent without draining the request stream first. callback_data, raw_body = await parse_webhook_request(request) - # TODO: Remove this debug logging after Vobiz team clarifies webhook authentication - logger.info( - f"[run {workflow_run_id}] Vobiz hangup callback - Body: {json.dumps(callback_data)}" - ) logger.info( f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}" ) - # Verify signature if Vobiz provided any supported signature header. - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) - ) - if has_vobiz_signature: - # We need the workflow run to get organization for provider credentials - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning( - f"[run {workflow_run_id}] Workflow run not found for signature verification" - ) - return {"status": "error", "reason": "workflow_run_not_found"} - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning( - f"[run {workflow_run_id}] Workflow not found for signature verification" - ) - return {"status": "error", "reason": "workflow_not_found"} - - provider = await get_telephony_provider_for_run( - workflow_run, workflow.organization_id - ) - - # Verify signature - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}" - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[run {workflow_run_id}] Invalid Vobiz hangup callback signature" - ) - return {"status": "error", "reason": "invalid_signature"} - - logger.info(f"[run {workflow_run_id}] Vobiz hangup callback signature verified") - else: - # Get workflow run for processing (signature verification already got it if needed) - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.warning( f"[run {workflow_run_id}] Workflow run not found for Vobiz hangup callback" ) return {"status": "ignored", "reason": "workflow_run_not_found"} - # Get workflow and provider workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) if not workflow: logger.warning(f"[run {workflow_run_id}] Workflow not found") @@ -160,6 +122,21 @@ async def handle_vobiz_hangup_callback( workflow_run, workflow.organization_id ) + # Fail closed: Vobiz signs every callback, so reject unsigned/forged ones + # before they can mutate call state. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = ( + f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}" + ) + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[run {workflow_run_id}]", + ) + logger.debug( f"[run {workflow_run_id}] Processing Vobiz hangup with provider: {provider.PROVIDER_NAME}" ) @@ -167,10 +144,6 @@ async def handle_vobiz_hangup_callback( # Parse the callback data into generic format parsed_data = provider.parse_status_callback(callback_data) - logger.debug( - f"[run {workflow_run_id}] Parsed Vobiz callback data: {json.dumps(parsed_data)}" - ) - # Create StatusCallbackRequest from parsed data status_update = StatusCallbackRequest( call_id=parsed_data["call_id"], @@ -194,8 +167,6 @@ async def handle_vobiz_hangup_callback( async def handle_vobiz_ring_callback( workflow_run_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz ring callback (sent when call starts ringing). @@ -204,84 +175,46 @@ async def handle_vobiz_ring_callback( """ set_current_run_id(workflow_run_id) - # Logging all headers and body to understand what Vobiz actually sends all_headers = dict(request.headers) - logger.info( - f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}" - ) # Parse the callback data from the raw body so signed webhooks can verify # the exact bytes Vobiz sent without draining the request stream first. callback_data, raw_body = await parse_webhook_request(request) - # TODO: Remove this debug logging after Vobiz team clarifies webhook authentication - logger.info( - f"[run {workflow_run_id}] Vobiz ring callback - Body: {json.dumps(callback_data)}" - ) - logger.info( f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}" ) - # Verify signature if Vobiz provided any supported signature header. - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) - ) - if has_vobiz_signature: - # We need the workflow run to get organization for provider credentials - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning( - f"[run {workflow_run_id}] Workflow run not found for signature verification" - ) - return {"status": "error", "reason": "workflow_run_not_found"} - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning( - f"[run {workflow_run_id}] Workflow not found for signature verification" - ) - return {"status": "error", "reason": "workflow_not_found"} - - provider = await get_telephony_provider_for_run( - workflow_run, workflow.organization_id - ) - - # Verify signature - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = ( - f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}" - ) - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[run {workflow_run_id}] Invalid Vobiz ring callback signature" - ) - return {"status": "error", "reason": "invalid_signature"} - - logger.info(f"[run {workflow_run_id}] Vobiz ring callback signature verified") - else: - # Get workflow run for processing (signature verification already got it if needed) - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.warning( f"[run {workflow_run_id}] Workflow run not found for Vobiz ring callback" ) return {"status": "ignored", "reason": "workflow_run_not_found"} + workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) + if not workflow: + logger.warning(f"[run {workflow_run_id}] Workflow not found") + return {"status": "ignored", "reason": "workflow_not_found"} + + provider = await get_telephony_provider_for_run( + workflow_run, workflow.organization_id + ) + + # Fail closed: reject unsigned/forged ring callbacks before logging them. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = ( + f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}" + ) + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[run {workflow_run_id}]", + ) + # Log the ringing event telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", []) ring_log = { @@ -308,15 +241,10 @@ async def handle_vobiz_ring_callback( async def handle_vobiz_hangup_callback_by_workflow( workflow_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id.""" all_headers = dict(request.headers) - logger.info( - f"[workflow {workflow_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}" - ) try: callback_data, raw_body = await parse_webhook_request(request) @@ -364,35 +292,18 @@ async def handle_vobiz_hangup_callback_by_workflow( workflow_run, workflow.organization_id ) - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) + # Fail closed: Vobiz signs every callback, so reject unsigned/forged ones + # before they can mutate call state. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}" + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[workflow {workflow_id}]", ) - if has_vobiz_signature: - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}" - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[workflow {workflow_id}] Invalid Vobiz hangup callback signature" - ) - return {"status": "error", "message": "invalid_signature"} - - logger.info( - f"[workflow {workflow_id}] Vobiz hangup callback signature verified" - ) try: parsed_data = provider.parse_status_callback(callback_data) diff --git a/api/tests/telephony/vobiz/test_routes.py b/api/tests/telephony/vobiz/test_routes.py index 7b80d468..cfccfc90 100644 --- a/api/tests/telephony/vobiz/test_routes.py +++ b/api/tests/telephony/vobiz/test_routes.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, patch from urllib.parse import urlencode import pytest +from fastapi import HTTPException from starlette.requests import Request from api.services.telephony.providers.vobiz.provider import VobizProvider from api.services.telephony.providers.vobiz.routes import ( handle_vobiz_hangup_callback, + handle_vobiz_hangup_callback_by_workflow, handle_vobiz_ring_callback, ) @@ -225,3 +227,154 @@ async def test_vobiz_verify_inbound_signature_rejects_missing_signature(): {}, {}, ) + + +@pytest.mark.asyncio +async def test_vobiz_hangup_callback_rejects_missing_signature(): + """An unsigned hangup callback must be rejected before status processing.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "completed", + "From": "15551230001", + "To": "15551230002", + "Direction": "outbound", + "Duration": "12", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/hangup-callback/123", + form_data=form_data, + ) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + patch( + "api.services.telephony.providers.vobiz.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(workflow_id=7) + ) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_hangup_callback( + workflow_run_id=123, + request=request, + ) + + assert exc_info.value.status_code == 403 + process_status.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vobiz_ring_callback_rejects_missing_signature(): + """An unsigned ring callback must be rejected before it is logged.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "ringing", + "From": "15551230001", + "To": "15551230002", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/ring-callback/123", + form_data=form_data, + ) + + workflow_run = SimpleNamespace(workflow_id=7, logs={}) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + ): + db_client.get_workflow_run_by_id = AsyncMock(return_value=workflow_run) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + db_client.update_workflow_run = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_ring_callback( + workflow_run_id=123, + request=request, + ) + + assert exc_info.value.status_code == 403 + db_client.update_workflow_run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vobiz_hangup_callback_by_workflow_rejects_missing_signature(): + """An unsigned by-workflow hangup callback must be rejected before processing.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "completed", + "From": "15551230001", + "To": "15551230002", + "Direction": "outbound", + "Duration": "12", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/hangup-callback/workflow/7", + form_data=form_data, + ) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + patch( + "api.services.telephony.providers.vobiz.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + db_client.get_workflow_run_by_call_id = AsyncMock( + return_value=SimpleNamespace(id=123, workflow_id=7) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_hangup_callback_by_workflow( + workflow_id=7, + request=request, + ) + + assert exc_info.value.status_code == 403 + process_status.assert_not_awaited() diff --git a/api/tests/test_pre_call_fetch.py b/api/tests/test_pre_call_fetch.py new file mode 100644 index 00000000..8016da21 --- /dev/null +++ b/api/tests/test_pre_call_fetch.py @@ -0,0 +1,66 @@ +from api.services.pipecat.pre_call_fetch import _extract_initial_context + + +class TestExtractInitialContext: + """Tests for _extract_initial_context, the pre-call fetch response parser.""" + + def test_initial_context_nested_under_call_inbound(self): + """The canonical `initial_context` key nested under `call_inbound`.""" + response = {"call_inbound": {"initial_context": {"customer_name": "Jane"}}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_initial_context_at_top_level(self): + """The canonical `initial_context` key at the top level.""" + response = {"initial_context": {"customer_name": "Jane"}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_legacy_dynamic_variables_nested(self): + """The legacy `dynamic_variables` key still works nested under `call_inbound`.""" + response = {"call_inbound": {"dynamic_variables": {"customer_name": "Jane"}}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_legacy_dynamic_variables_at_top_level(self): + """The legacy `dynamic_variables` key still works at the top level.""" + response = {"dynamic_variables": {"customer_name": "Jane"}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_initial_context_takes_precedence_over_legacy(self): + """When both keys are present, `initial_context` wins.""" + response = { + "call_inbound": { + "initial_context": {"source": "new"}, + "dynamic_variables": {"source": "legacy"}, + } + } + assert _extract_initial_context(response) == {"source": "new"} + + def test_falls_back_to_legacy_when_initial_context_not_a_dict(self): + """A non-dict `initial_context` falls back to `dynamic_variables`.""" + response = { + "initial_context": None, + "dynamic_variables": {"customer_name": "Jane"}, + } + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_nested_values_preserved(self): + """Nested objects pass through untouched for dot-notation access.""" + response = { + "call_inbound": { + "initial_context": {"customer": {"address": {"city": "LA"}}} + } + } + assert _extract_initial_context(response) == { + "customer": {"address": {"city": "LA"}} + } + + def test_empty_when_no_known_keys(self): + """A response with neither key yields an empty dict.""" + assert _extract_initial_context({"call_inbound": {"agent_id": 1}}) == {} + + def test_empty_when_call_inbound_missing(self): + """No `call_inbound` and no top-level keys yields an empty dict.""" + assert _extract_initial_context({}) == {} + + def test_non_dict_vars_yield_empty(self): + """A non-dict value under a known key yields an empty dict.""" + assert _extract_initial_context({"initial_context": "nope"}) == {} diff --git a/api/tests/test_public_embed_cors.py b/api/tests/test_public_embed_cors.py new file mode 100644 index 00000000..5683f38c --- /dev/null +++ b/api/tests/test_public_embed_cors.py @@ -0,0 +1,274 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from api.routes.public_embed import PublicEmbedCORSMiddleware, router + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["https://app.dograh.com"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(PublicEmbedCORSMiddleware, api_prefix="/api/v1") +app.include_router(router, prefix="/api/v1") +client = TestClient(app, raise_server_exceptions=False) + +_ACTIVE_TOKEN = SimpleNamespace( + id=10, + is_active=True, + expires_at=None, + allowed_domains=[], + workflow_id=1, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + +_RESTRICTED_TOKEN = SimpleNamespace( + id=20, + is_active=True, + expires_at=None, + allowed_domains=["allowed.example.com"], + workflow_id=2, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + +_LOCALHOST_TOKEN = SimpleNamespace( + id=30, + is_active=True, + expires_at=None, + allowed_domains=["localhost:3000", "localhost:3020"], + workflow_id=3, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + + +@pytest.fixture(autouse=True) +def _patch_db(monkeypatch): + async def _get_token(token): + if token == "valid": + return _ACTIVE_TOKEN + if token == "restricted": + return _RESTRICTED_TOKEN + if token == "localhost": + return _LOCALHOST_TOKEN + return None + + async def _get_token_by_id(token_id): + if token_id == _ACTIVE_TOKEN.id: + return _ACTIVE_TOKEN + if token_id == _RESTRICTED_TOKEN.id: + return _RESTRICTED_TOKEN + if token_id == _LOCALHOST_TOKEN.id: + return _LOCALHOST_TOKEN + return None + + async def _get_session(session_token): + if session_token == "session-valid": + return SimpleNamespace(embed_token_id=_ACTIVE_TOKEN.id, expires_at=None) + if session_token == "session-restricted": + return SimpleNamespace(embed_token_id=_RESTRICTED_TOKEN.id, expires_at=None) + return None + + async def _create_workflow_run(**_kwargs): + return SimpleNamespace(id=123) + + async def _noop(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_token_by_token", + _get_token, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_token_by_id", + _get_token_by_id, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_session_by_token", + _get_session, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.create_workflow_run", + _create_workflow_run, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.create_embed_session", + _noop, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.increment_embed_token_usage", + _noop, + ) + monkeypatch.setattr("api.routes.public_embed.TURN_SECRET", "test-secret") + monkeypatch.setattr( + "api.routes.public_embed.generate_turn_credentials", + lambda _user_id: { + "username": "turn-user", + "password": "turn-password", + "ttl": 3600, + "uris": ["turn:example.com:3478"], + }, + ) + + +def _assert_embed_cors(resp, origin: str): + assert resp.headers.get("access-control-allow-origin") == origin + assert "origin" in { + value.strip().lower() for value in resp.headers.get("vary", "").split(",") + } + + +def test_options_config_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/config/valid", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_config_accepts_allowed_localhost_port(): + origin = "http://localhost:3020" + resp = client.options( + "/api/v1/public/embed/config/localhost", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_config_rejects_unknown_token(): + resp = client.options( + "/api/v1/public/embed/config/unknown", + headers={ + "Origin": "https://mysite.vercel.app", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 + + +def test_options_config_rejects_disallowed_origin(): + resp = client.options( + "/api/v1/public/embed/config/restricted", + headers={ + "Origin": "https://notallowed.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 + + +def test_get_config_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.get( + "/api/v1/public/embed/config/valid", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_get_config_accepts_allowed_localhost_port(): + origin = "http://localhost:3020" + resp = client.get( + "/api/v1/public/embed/config/localhost", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_get_config_rejects_unlisted_localhost_port(): + resp = client.get( + "/api/v1/public/embed/config/localhost", + headers={"Origin": "http://localhost:3021"}, + ) + assert resp.status_code == 403 + + +def test_get_config_rejects_disallowed_origin(): + resp = client.get( + "/api/v1/public/embed/config/restricted", + headers={"Origin": "https://notallowed.example.com"}, + ) + assert resp.status_code == 403 + + +def test_init_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.post( + "/api/v1/public/embed/init", + headers={"Origin": origin}, + json={"token": "valid"}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_turn_credentials_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.get( + "/api/v1/public/embed/turn-credentials/session-valid", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_init_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/init", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "POST", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_turn_credentials_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/turn-credentials/session-valid", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_turn_credentials_rejects_disallowed_origin(): + resp = client.options( + "/api/v1/public/embed/turn-credentials/session-restricted", + headers={ + "Origin": "https://notallowed.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 diff --git a/docker-compose.yaml b/docker-compose.yaml index d440aa10..0bd27178 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,7 +3,11 @@ services: image: pgvector/pgvector:pg17 environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + # Sourced from .env. Defaults to "postgres" + # NOTE: changing this on an existing install does NOT + # re-key the database — the password is baked into the volume on first init. + # You can manually change the password using psql in the container + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-postgres}" POSTGRES_DB: postgres logging: driver: "json-file" @@ -136,7 +140,7 @@ services: BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}" # Database configuration (using containerized postgres) - DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres" + DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" # Redis configuration (using containerized redis) REDIS_URL: "redis://:redissecret@redis:6379" diff --git a/docs/core-concepts/context-and-variables.mdx b/docs/core-concepts/context-and-variables.mdx index bfd81b02..274689c4 100644 --- a/docs/core-concepts/context-and-variables.mdx +++ b/docs/core-concepts/context-and-variables.mdx @@ -18,20 +18,10 @@ initial_context ──► Agent ──► gathered_context Data available to the agent before the call starts — the contact's name, account details, appointment information, anything the agent should know upfront. It can be set from several places: -- **API trigger** — pass it in the request body when calling `POST /public/agent/{uuid}` or `POST /telephony/initiate-call` -- **Campaign CSV** — columns beyond `phone_number` automatically become `initial_context` fields for each contact's call -- **Dashboard** — set default template context variables on the agent, used when no external context is provided - -```json -{ - "phone_number": "+14155550100", - "initial_context": { - "customer_name": "Jane Smith", - "plan": "premium", - "renewal_date": "April 1" - } -} -``` +- **[API trigger](/voice-agent/api-trigger)** — pass it in the request body when calling `POST /public/agent/{uuid}` or `POST /telephony/initiate-call` +- **[Campaign CSV](/core-concepts/campaigns)** — columns beyond `phone_number` automatically become `initial_context` fields for each contact's call +- **[Pre-call data fetch](/voice-agent/pre-call-data-fetch)** — enrich the context with data from your CRM or ERP via an HTTP call as the call starts, before the agent speaks +- **[Agent Settings](/voice-agent/template-variables#using-template-variables-for-testing)** — set template context variables on the agent for testing; they're included in test calls from the workflow editor and ignored on production calls ### Template variables @@ -103,7 +93,7 @@ Data the agent collects *during* the call. You configure what to extract in the Extracted variables -`gathered_context` is returned in the run record after the call completes and is available in [webhook payloads](/developer/webhooks) for downstream processing. +`gathered_context` is returned in the run record after the call completes and is available in [webhook payloads](/developer/webhooks) for downstream processing. It is **not** available as a template variable in Agent prompts — prompts can only reference `initial_context` fields. ## Data flow example diff --git a/docs/images/template-variables.png b/docs/images/template-variables.png new file mode 100644 index 00000000..3594f9da Binary files /dev/null and b/docs/images/template-variables.png differ diff --git a/docs/voice-agent/api-trigger.mdx b/docs/voice-agent/api-trigger.mdx index 1a464b6d..e5168c7c 100644 --- a/docs/voice-agent/api-trigger.mdx +++ b/docs/voice-agent/api-trigger.mdx @@ -118,7 +118,7 @@ For example, if your request includes: } ``` -You can reference the user's name in your prompt as `{{initial_context.user.name}}`. +You can reference the user's name in your agent prompt as `{{user.name}}` — in Agent prompts, `initial_context` fields are referenced directly by name (not prefixed with `initial_context.`). See [template variables](/voice-agent/template-variables) for the exact syntax in prompts versus webhook payloads. See [Context & Variables](/core-concepts/context-and-variables) for more on how data flows through a call. diff --git a/docs/voice-agent/pre-call-data-fetch.mdx b/docs/voice-agent/pre-call-data-fetch.mdx index 53d1d9ae..a793930b 100644 --- a/docs/voice-agent/pre-call-data-fetch.mdx +++ b/docs/voice-agent/pre-call-data-fetch.mdx @@ -11,7 +11,7 @@ Pre-Call Data Fetch allows you to enrich the call context with external data bef 1. A call arrives (inbound) or is initiated (outbound). 2. Dograh sends a **POST** request to your configured endpoint with a standardized payload. 3. The caller hears a ring-back tone while waiting for the response. -4. Your API responds with a JSON object containing `dynamic_variables`. +4. Your API responds with a JSON object containing an `initial_context` object. 5. The variables are merged into the call's initial context. 6. The voice agent starts with full access to the fetched data via `{{variable_name}}` syntax. @@ -50,12 +50,12 @@ The `Content-Type` header is set to `application/json`. If you configured a cred ## Expected Response Format -Your API should return a **JSON object** with a `2xx` status code. The variables to inject into the call context should be placed inside the `dynamic_variables` key: +Your API should return a **JSON object** with a `2xx` status code. The variables to inject into the call context should be placed inside the `initial_context` key: ```json { "call_inbound": { - "dynamic_variables": { + "initial_context": { "customer_name": "Jane Doe", "account_status": "active", "loyalty_tier": "gold", @@ -65,34 +65,38 @@ Your API should return a **JSON object** with a `2xx` status code. The variables } ``` -You can also place `dynamic_variables` at the top level: +You can also place `initial_context` at the top level: ```json { - "dynamic_variables": { + "initial_context": { "customer_name": "Jane Doe", "account_status": "active" } } ``` + +The legacy `dynamic_variables` key is still accepted as a drop-in alias for `initial_context`, so existing integrations keep working without any changes. Use `initial_context` for new integrations. If a response contains both keys, `initial_context` takes precedence. + + After the response is received, you can reference these values anywhere template variables are supported: - **Greeting**: `Hello {{customer_name}}, thank you for calling!` - **Prompt**: `The customer is a {{loyalty_tier}} member with {{open_tickets}} open support tickets.` -If the response is not a valid JSON object, does not contain `dynamic_variables`, or the request fails or times out, the call proceeds normally without the additional context. The pre-call fetch never blocks or fails a call. +If the response is not a valid JSON object, does not contain `initial_context` (or the legacy `dynamic_variables`), or the request fails or times out, the call proceeds normally without the additional context. The pre-call fetch never blocks or fails a call. ## Nested Variables -If your `dynamic_variables` contain nested objects, you can access them using dot notation: +If your `initial_context` contains nested objects, you can access them using dot notation: ```json { "call_inbound": { - "dynamic_variables": { + "initial_context": { "customer": { "name": "Jane Doe", "address": { @@ -153,7 +157,7 @@ app.post("/dograh/pre-call", async (req, res) => { res.json({ call_inbound: { - dynamic_variables: { + initial_context: { customer_name: customer.name, account_status: customer.status, loyalty_tier: customer.tier, diff --git a/docs/voice-agent/template-variables.mdx b/docs/voice-agent/template-variables.mdx index 3db4a56b..325317fe 100644 --- a/docs/voice-agent/template-variables.mdx +++ b/docs/voice-agent/template-variables.mdx @@ -4,13 +4,23 @@ description: "You can use Template Variables in your prompts for your Agent node --- ### Template Rendering -You can reference template variables which is passed as [`initial_context`](/core-concepts/context-and-variables#initial_context) either using the [API Trigger](/voice-agent/api-trigger) or when uploading a Sheet for a [campaign](/core-concepts/campaigns). You can also use any extracted variable as [`gathered_context`](/core-concepts/context-and-variables#gathered_context) -The template rendering can take nested values. +You reference template variables with `{{double_brace}}` syntax. The data comes from [`initial_context`](/core-concepts/context-and-variables#initial_context) — set via the [API Trigger](/voice-agent/api-trigger), a [campaign](/core-concepts/campaigns) sheet, or a [Pre-Call Data Fetch](/voice-agent/pre-call-data-fetch) that enriches the context when the call starts — and, in Webhook payloads only, from [`gathered_context`](/core-concepts/context-and-variables#gathered_context) (variables extracted during the call). -Example: If the initial context is +**The syntax depends on where you use it:** -``` +| Where | `initial_context` | `gathered_context` | +| --- | --- | --- | +| Agent node prompts | `{{field_name}}` (referenced directly) | Not available | +| Webhook Node payloads | `{{initial_context.field_name}}` | `{{gathered_context.field_name}}` | + +#### Agent node prompts + +In an Agent node prompt, reference each `initial_context` field **directly by name**. Nested values are supported with dot notation. + +Example: if the initial context is + +```json { "initial_context": { "user": { @@ -20,14 +30,26 @@ Example: If the initial context is } ``` -You can write your prompt to access the user's name as below +write your prompt to access the user's name as below: -Prompt: `You are Alice, who is talking to {{initial_context.user.name}}.` +Prompt: `You are Alice, who is talking to {{user.name}}.` + + +Variables extracted during the call (`gathered_context`) are **not** available in Agent prompts — a prompt can only reference `initial_context` fields. To act on extracted data, send it to a [Webhook Node](/voice-agent/webhook). + + +#### Webhook Node payloads + +When constructing a [Webhook Node](/voice-agent/webhook) payload, the context objects are nested under their names, so reference them with the `initial_context.` and `gathered_context.` prefixes: + +Payload value: `{{initial_context.user.name}}` or `{{gathered_context.call_disposition}}` ### Using Template Variables for Testing Template variables defined in your workflow **Settings > Context Variables** are included in test calls (both web and phone) made from the workflow editor. This is useful for simulating data that would normally come from telephony or an API trigger. +Template Variables panel in workflow Settings, showing a customer_name variable and fields to add new key/value pairs + For example, you can set `caller_number` and `called_number` as context variables to test [Pre-Call Data Fetch](/voice-agent/pre-call-data-fetch#testing-with-test-calls) without needing a real inbound call. diff --git a/scripts/setup_local.ps1 b/scripts/setup_local.ps1 index 2958f307..d8b99137 100644 --- a/scripts/setup_local.ps1 +++ b/scripts/setup_local.ps1 @@ -243,6 +243,7 @@ if ($UseCoturn) { Write-Info "[2/$TotalSteps] Creating environment file..." $ossJwtSecret = New-HexSecret 32 +$postgresPassword = New-HexSecret 32 $envLines = @( '# Container registry for Dograh images' @@ -251,6 +252,11 @@ $envLines = @( '# JWT secret for OSS authentication' "OSS_JWT_SECRET=$ossJwtSecret" '' + '# PostgreSQL password. Used by the postgres container on first init and by' + "# the API's DATABASE_URL. Do not change after the first start — the password" + '# is baked into the postgres data volume when it is first created.' + "POSTGRES_PASSWORD=$postgresPassword" + '' '# Telemetry (set to false to disable)' "ENABLE_TELEMETRY=$EnableTelemetry" '' diff --git a/scripts/setup_local.sh b/scripts/setup_local.sh index 674185e1..e94fb60c 100755 --- a/scripts/setup_local.sh +++ b/scripts/setup_local.sh @@ -150,6 +150,7 @@ fi ENV_STEP=$TOTAL_STEPS echo -e "${BLUE}[$ENV_STEP/$TOTAL_STEPS] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) +POSTGRES_PASSWORD=$(openssl rand -hex 32) cat > .env << ENV_EOF # Container registry for Dograh images @@ -158,6 +159,11 @@ REGISTRY=$REGISTRY # JWT secret for OSS authentication OSS_JWT_SECRET=$OSS_JWT_SECRET +# PostgreSQL password. Used by the postgres container on first init and by the +# API's DATABASE_URL. Do not change after the first start — the password is +# baked into the postgres data volume when it is first created. +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + # Telemetry (set to false to disable) ENABLE_TELEMETRY=$ENABLE_TELEMETRY diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index d958b694..919c881d 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -251,6 +251,7 @@ echo -e "${GREEN}✓ SSL certificates generated${NC}" echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) +POSTGRES_PASSWORD=$(openssl rand -hex 32) cat > .env << ENV_EOF # Remote deployments run with production signaling and HTTPS defaults @@ -276,6 +277,11 @@ FORCE_TURN_RELAY=$FORCE_TURN_RELAY # JWT secret for OSS authentication OSS_JWT_SECRET=$OSS_JWT_SECRET +# PostgreSQL password. Used by the postgres container on first init and by the +# API's DATABASE_URL. Do not change after the first start — the password is +# baked into the postgres data volume when it is first created. +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + # Telemetry (set to false to disable) ENABLE_TELEMETRY=$ENABLE_TELEMETRY diff --git a/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py index 7f2fa8a7..ec27e029 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-rs5H7P.json -# timestamp: 2026-06-02T06:01:29+00:00 +# filename: dograh-openapi-uraOZf.json +# timestamp: 2026-06-03T11:53:30+00:00 from __future__ import annotations diff --git a/ui/next.config.ts b/ui/next.config.ts index 1b8a3996..98242c20 100644 --- a/ui/next.config.ts +++ b/ui/next.config.ts @@ -9,11 +9,6 @@ const nextConfig: NextConfig = { }, async rewrites() { return [ - // API proxy for backend calls (excluding Next.js API routes) - { - source: "/api/:path((?!config|auth).*)*", - destination: `${process.env.BACKEND_URL || 'http://localhost:8000'}/api/:path*`, - }, { source: "/ingest/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*", diff --git a/ui/package.json b/ui/package.json index cb82d1ac..77c8e3cf 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "1.33.0", + "version": "1.34.0", "private": true, "scripts": { "dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack", diff --git a/ui/src/app/api/v1/[...path]/route.ts b/ui/src/app/api/v1/[...path]/route.ts new file mode 100644 index 00000000..7f89b0ab --- /dev/null +++ b/ui/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getServerBackendUrl } from "@/lib/apiClient"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const HOP_BY_HOP_HEADERS = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +function trimTrailingSlash(url: string) { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function buildBackendUrl(request: NextRequest) { + const backendUrl = trimTrailingSlash(getServerBackendUrl()); + return `${backendUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; +} + +function createRequestHeaders(request: NextRequest) { + const headers = new Headers(request.headers); + + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header); + } + + headers.delete("accept-encoding"); + headers.delete("content-length"); + headers.delete("host"); + + return headers; +} + +function createResponseHeaders(response: Response) { + const headers = new Headers(response.headers); + const setCookies = response.headers.getSetCookie(); + + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header); + } + + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("set-cookie"); + + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + + return headers; +} + +async function getRequestBody(request: NextRequest) { + if (request.method === "GET" || request.method === "HEAD") { + return undefined; + } + + return request.arrayBuffer(); +} + +async function proxyRequest(request: NextRequest) { + const backendUrl = buildBackendUrl(request); + + try { + const response = await fetch(backendUrl, { + method: request.method, + headers: createRequestHeaders(request), + body: await getRequestBody(request), + cache: "no-store", + }); + + return new Response(request.method === "HEAD" ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers: createResponseHeaders(response), + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown backend proxy error"; + + return NextResponse.json( + { + detail: `Backend request failed while proxying to ${backendUrl}: ${message}`, + }, + { status: 502 }, + ); + } +} + +export const GET = proxyRequest; +export const POST = proxyRequest; +export const PUT = proxyRequest; +export const PATCH = proxyRequest; +export const DELETE = proxyRequest; +export const OPTIONS = proxyRequest; +export const HEAD = proxyRequest; diff --git a/ui/src/lib/auth/config.ts b/ui/src/lib/auth/config.ts index b58927bc..1958297d 100644 --- a/ui/src/lib/auth/config.ts +++ b/ui/src/lib/auth/config.ts @@ -1,5 +1,7 @@ import "server-only"; +import { getServerBackendUrl } from "@/lib/apiClient"; + let cachedAuthProvider: string | null = null; /** @@ -12,7 +14,7 @@ export async function getAuthProvider(): Promise { } try { - const backendUrl = process.env.BACKEND_URL || "http://localhost:8000"; + const backendUrl = getServerBackendUrl(); const res = await fetch(`${backendUrl}/api/v1/health`, { next: { revalidate: 300 }, }); diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts index f73231a9..12014dcb 100644 --- a/ui/src/middleware.ts +++ b/ui/src/middleware.ts @@ -1,6 +1,8 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { getServerBackendUrl } from '@/lib/apiClient'; + const OSS_TOKEN_COOKIE = 'dograh_auth_token'; // Paths that don't require authentication in OSS mode @@ -14,7 +16,7 @@ async function fetchAuthProvider(): Promise { } try { - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; + const backendUrl = getServerBackendUrl(); const res = await fetch(`${backendUrl}/api/v1/health`); if (res.ok) { const data = await res.json();