diff --git a/.env.example b/.env.example index 158eec76d..674d89278 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,30 @@ GEMINI_API_KEY=your-gemini-api-key-here GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your-client-secret +# ============================================ +# DOMAIN CONFIGURATION +# ============================================ +# Defaults to scope3.com for backwards compatibility with existing deployments. +# Change these to your own domain when deploying to a new environment. + +# Your base domain (e.g., scope3.com, yourdomain.com) +BASE_DOMAIN=scope3.com + +# Sales agent domain (full domain for the sales agent server) +# Format: . or just +SALES_AGENT_DOMAIN=sales-agent.scope3.com + +# Admin domain (subdomain for admin UI) +# Format: . +ADMIN_DOMAIN=admin.sales-agent.scope3.com + +# Domain for super admin email addresses +# Super admins from this domain get full access across all tenants +SUPER_ADMIN_DOMAIN=scope3.com + # OAuth Redirect URI (must match Google OAuth credentials exactly) # For production with nginx routing through /admin: -GOOGLE_OAUTH_REDIRECT_URI=https://sales-agent.scope3.com/admin/auth/google/callback +GOOGLE_OAUTH_REDIRECT_URI=https://${SALES_AGENT_DOMAIN}/admin/auth/google/callback # For local development (if using nginx): # GOOGLE_OAUTH_REDIRECT_URI=http://localhost/admin/auth/google/callback diff --git a/.github/workflows/commitizen-check.yml b/.github/workflows/commitizen-check.yml index f5830ee2f..856b2d580 100644 --- a/.github/workflows/commitizen-check.yml +++ b/.github/workflows/commitizen-check.yml @@ -28,6 +28,7 @@ jobs: run: | export PATH="$HOME/.cargo/bin:$PATH" uv sync + uv pip install commitizen - name: Check commits follow conventional commit format run: | diff --git a/CLAUDE.md b/CLAUDE.md index 986755a71..f046bc351 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1207,7 +1207,7 @@ app = create_flask_app(agent) # Provides all standard endpoints ### Admin UI Access ```bash # Local: http://localhost:8001 -# Reference Production: https://admin.sales-agent.scope3.com +# Reference Production: https://admin.${SALES_AGENT_DOMAIN} # Your Production: Configure based on your hosting setup ``` diff --git a/docs/HOSTING.md b/docs/HOSTING.md new file mode 100644 index 000000000..b613f7365 --- /dev/null +++ b/docs/HOSTING.md @@ -0,0 +1,451 @@ +# Hosting Your Own AdCP Sales Agent + +This guide covers everything you need to host your own AdCP Sales Agent instance. + +## Prerequisites + +- Docker installed (for local/container deployment) +- PostgreSQL 15+ database +- Domain name (for production) +- Google OAuth credentials +- (Optional) Google Ad Manager account with OAuth credentials + +## Required Environment Variables + +### Core Configuration + +#### Domain Configuration +```bash +# Your base domain (e.g., scope3.com, yourdomain.com) +BASE_DOMAIN=yourdomain.com + +# Sales agent domain (where the agent will be hosted) +SALES_AGENT_DOMAIN=sales-agent.yourdomain.com + +# Admin domain (for admin UI) +ADMIN_DOMAIN=admin.sales-agent.yourdomain.com + +# Super admin domain (emails from this domain get full access) +SUPER_ADMIN_DOMAIN=yourdomain.com +``` + +**Default if not set**: Defaults to `scope3.com` for backwards compatibility. + +#### Authentication & Authorization +```bash +# Google OAuth (for admin UI login) +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-client-secret + +# OAuth redirect URI (must match Google OAuth credentials exactly) +GOOGLE_OAUTH_REDIRECT_URI=https://sales-agent.yourdomain.com/admin/auth/google/callback + +# Super admin emails (comma-separated) +SUPER_ADMIN_EMAILS=admin@yourdomain.com,admin2@yourdomain.com + +# (Optional) Additional super admin domains +SUPER_ADMIN_DOMAINS=yourdomain.com,company.com +``` + +#### Database +```bash +# Full PostgreSQL connection URL +DATABASE_URL=postgresql://username:password@host:port/database + +# Or individual components: +DB_TYPE=postgresql +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=adcp +DB_USER=adcp_user +DB_PASSWORD=secure_password +``` + +#### API Keys +```bash +# Gemini API (for AI features) +GEMINI_API_KEY=your-gemini-api-key + +# (Optional) Google Ad Manager OAuth +GAM_OAUTH_CLIENT_ID=your-gam-client-id.apps.googleusercontent.com +GAM_OAUTH_CLIENT_SECRET=your-gam-client-secret + +# (Optional) Approximated (for custom domain proxying) +APPROXIMATED_API_KEY=your-approximated-api-key +APPROXIMATED_PROXY_IP=37.16.24.200 +APPROXIMATED_BACKEND_URL=sales-agent.yourdomain.com +``` + +#### GCP Configuration (Optional) +```bash +# Google Cloud Platform project (for service account auto-provisioning) +GCP_PROJECT_ID=your-gcp-project-id + +# (Optional) Service account credentials JSON +GOOGLE_APPLICATION_CREDENTIALS_JSON='{"type": "service_account", ...}' +``` + +#### Production Settings +```bash +# Environment mode +PRODUCTION=true +ENVIRONMENT=production # or 'development', 'staging' + +# Mode flags +ADCP_UNIFIED_MODE=true + +# Port configuration (usually not needed - defaults work) +ADCP_SALES_PORT=8080 +ADMIN_UI_PORT=8001 +A2A_PORT=8091 + +# Database type +DB_TYPE=postgresql +``` + +## Deployment Options + +### Option 1: Fly.io (Recommended) + +1. **Install Fly CLI**: + ```bash + curl -L https://fly.io/install.sh | sh + ``` + +2. **Login to Fly**: + ```bash + fly auth login + ``` + +3. **Create app** (first time only): + ```bash + fly apps create your-app-name + ``` + +4. **Set secrets**: + ```bash + # Domain configuration + fly secrets set BASE_DOMAIN=yourdomain.com --app your-app-name + fly secrets set SALES_AGENT_DOMAIN=sales-agent.yourdomain.com --app your-app-name + fly secrets set ADMIN_DOMAIN=admin.sales-agent.yourdomain.com --app your-app-name + fly secrets set SUPER_ADMIN_DOMAIN=yourdomain.com --app your-app-name + + # Authentication + fly secrets set GOOGLE_CLIENT_ID=your-client-id --app your-app-name + fly secrets set GOOGLE_CLIENT_SECRET=your-client-secret --app your-app-name + fly secrets set GOOGLE_OAUTH_REDIRECT_URI=https://sales-agent.yourdomain.com/admin/auth/google/callback --app your-app-name + fly secrets set SUPER_ADMIN_EMAILS=admin@yourdomain.com --app your-app-name + + # API Keys + fly secrets set GEMINI_API_KEY=your-gemini-key --app your-app-name + fly secrets set GAM_OAUTH_CLIENT_ID=your-gam-id --app your-app-name + fly secrets set GAM_OAUTH_CLIENT_SECRET=your-gam-secret --app your-app-name + + # GCP (if using service account auto-provisioning) + fly secrets set GCP_PROJECT_ID=your-gcp-project --app your-app-name + + # Production flags + fly secrets set PRODUCTION=true --app your-app-name + fly secrets set ENVIRONMENT=production --app your-app-name + fly secrets set ADCP_UNIFIED_MODE=true --app your-app-name + ``` + +5. **Create PostgreSQL database**: + ```bash + fly postgres create --name your-app-db --region iad + fly postgres attach your-app-db --app your-app-name + ``` + +6. **Update fly.toml**: + - Change `app = "your-app-name"` + - Update `primary_region` if needed + +7. **Deploy**: + ```bash + fly deploy --app your-app-name + ``` + +8. **Check status**: + ```bash + fly status --app your-app-name + fly logs --app your-app-name + ``` + +9. **Configure DNS**: + - Point your domain to Fly.io app: + ```bash + fly ips list --app your-app-name + ``` + - Add A/AAAA records to your DNS + +### Option 2: Docker Compose (Local/Development) + +1. **Copy environment file**: + ```bash + cp .env.example .env.secrets + ``` + +2. **Edit .env.secrets** with your values (see Required Environment Variables above) + +3. **Start services**: + ```bash + docker-compose up -d + ``` + +4. **Check logs**: + ```bash + docker-compose logs -f + ``` + +5. **Access**: + - Admin UI: http://localhost:8001 + - MCP Server: http://localhost:8080 + - A2A Server: http://localhost:8091 + +### Option 3: Kubernetes + +1. **Create namespace**: + ```bash + kubectl create namespace adcp-sales-agent + ``` + +2. **Create secrets**: + ```bash + kubectl create secret generic adcp-secrets \ + --from-literal=BASE_DOMAIN=yourdomain.com \ + --from-literal=GOOGLE_CLIENT_ID=your-client-id \ + --from-literal=GOOGLE_CLIENT_SECRET=your-client-secret \ + --from-literal=GEMINI_API_KEY=your-gemini-key \ + --from-literal=DATABASE_URL=postgresql://... \ + -n adcp-sales-agent + ``` + +3. **Deploy**: + ```bash + kubectl apply -f k8s/ -n adcp-sales-agent + ``` + +4. **Expose service**: + ```bash + kubectl expose deployment adcp-sales-agent --type=LoadBalancer --port=443 -n adcp-sales-agent + ``` + +## Google OAuth Setup + +1. **Go to Google Cloud Console** → APIs & Services → Credentials + +2. **Create OAuth 2.0 Client ID**: + - Application type: Web application + - Name: AdCP Sales Agent + - Authorized JavaScript origins: + - `https://sales-agent.yourdomain.com` + - `http://localhost` (for development) + - Authorized redirect URIs: + - `https://sales-agent.yourdomain.com/admin/auth/google/callback` + - `http://localhost:8001/admin/auth/google/callback` (for development) + +3. **Copy credentials**: + - Client ID → `GOOGLE_CLIENT_ID` + - Client Secret → `GOOGLE_CLIENT_SECRET` + +4. **Configure OAuth consent screen**: + - User type: Internal (for organization) or External + - Add scopes: email, profile, openid + - Add test users (if using External with testing status) + +## Google Ad Manager Setup (Optional) + +Only needed if using GAM adapter for real ad serving. + +1. **Create GAM API credentials** in Google Cloud Console + +2. **Set environment variables**: + ```bash + GAM_OAUTH_CLIENT_ID=your-gam-client-id + GAM_OAUTH_CLIENT_SECRET=your-gam-client-secret + ``` + +3. **Configure in Admin UI**: + - Login as super admin + - Create/edit tenant + - Go to "Ad Server" settings + - Select "Google Ad Manager" adapter + - Follow OAuth flow to authorize + +## Database Setup + +### First Time Setup + +1. **Run migrations**: + ```bash + # If using Docker: + docker-compose exec web python migrate.py + + # If running locally: + uv run python migrate.py + ``` + +2. **Verify database**: + ```bash + # Check tables were created + psql $DATABASE_URL -c "\dt" + ``` + +### Creating First Tenant + +1. **Login to Admin UI** at https://sales-agent.yourdomain.com/admin + +2. **Create tenant**: + - Click "Create Tenant" + - Enter organization name and subdomain + - Configure ad server adapter (Mock for testing, GAM for production) + +3. **Create products**: + - Go to tenant → Products + - Add advertising products (video ads, display ads, etc.) + +4. **Add advertisers**: + - Go to tenant → Advertisers + - Create advertiser accounts + - Get API tokens for each advertiser + +## Health Checks & Monitoring + +### Health Endpoint +```bash +curl https://sales-agent.yourdomain.com/health +``` + +Expected response: +```json +{ + "status": "healthy", + "database": "connected", + "timestamp": "2025-10-29T18:00:00Z" +} +``` + +### Monitoring Logs + +**Fly.io**: +```bash +fly logs --app your-app-name +``` + +**Docker**: +```bash +docker-compose logs -f +``` + +**Kubernetes**: +```bash +kubectl logs -f deployment/adcp-sales-agent -n adcp-sales-agent +``` + +## Troubleshooting + +### OAuth Login Fails + +**Issue**: "redirect_uri_mismatch" error + +**Fix**: +1. Check `GOOGLE_OAUTH_REDIRECT_URI` matches Google OAuth credentials exactly +2. Ensure redirect URI is added to authorized redirect URIs in Google Console +3. Must include `/admin/auth/google/callback` path + +### Database Connection Fails + +**Issue**: "connection refused" or "authentication failed" + +**Fix**: +1. Verify `DATABASE_URL` is correct +2. Check database is running: `psql $DATABASE_URL -c "SELECT 1"` +3. Ensure database user has proper permissions +4. Run migrations: `python migrate.py` + +### Domain Configuration Issues + +**Issue**: URLs pointing to wrong domain + +**Fix**: +1. Check `BASE_DOMAIN` and `SALES_AGENT_DOMAIN` environment variables +2. Verify DNS is pointing to correct server +3. Clear browser cookies/cache +4. Check `SESSION_COOKIE_DOMAIN` is set correctly (auto-configured from domain vars) + +### "No products found" Error + +**Issue**: Can't create media buys + +**Fix**: +1. Login to Admin UI +2. Go to tenant → Products +3. Create at least one product +4. Ensure product has valid configuration (pricing, formats, etc.) + +## Security Considerations + +### Required for Production + +1. **Use HTTPS**: Always use SSL/TLS certificates for production +2. **Secure secrets**: Use secret management (Fly.io secrets, k8s secrets, env vars) +3. **Restrict super admin**: Limit `SUPER_ADMIN_EMAILS` to trusted users only +4. **Database encryption**: Use encrypted database connections +5. **Strong passwords**: Use strong database passwords +6. **API key rotation**: Rotate API keys regularly + +### Never Commit These to Git + +- `.env.secrets` - Contains all secrets +- Any file with API keys, passwords, or OAuth credentials +- Database connection strings with credentials + +## Scaling + +### Vertical Scaling (Fly.io) + +```bash +# Increase memory/CPU +fly scale vm shared-cpu-2x --memory 4096 --app your-app-name +``` + +### Horizontal Scaling + +```bash +# Add more instances +fly scale count 3 --app your-app-name +``` + +### Database Scaling + +- Use connection pooling (PgBouncer) +- Enable read replicas for read-heavy workloads +- Consider managed PostgreSQL (Fly Postgres, AWS RDS, etc.) + +## Migration from Scope3 Domain + +If migrating from existing scope3.com deployment: + +1. **Current deployment continues to work** - defaults to scope3.com + +2. **To migrate to new domain**: + ```bash + fly secrets set BASE_DOMAIN=yourdomain.com --app adcp-sales-agent + fly secrets set SALES_AGENT_DOMAIN=sales-agent.yourdomain.com --app adcp-sales-agent + fly secrets set ADMIN_DOMAIN=admin.sales-agent.yourdomain.com --app adcp-sales-agent + fly secrets set SUPER_ADMIN_DOMAIN=yourdomain.com --app adcp-sales-agent + ``` + +3. **Update OAuth credentials** with new redirect URIs + +4. **Update DNS** to point to your domain + +5. **Deploy** - no code changes needed + +## Support & Documentation + +- **Main Documentation**: `/docs` directory +- **Architecture**: `/docs/ARCHITECTURE.md` +- **API Documentation**: `/docs/api` +- **Testing Guide**: `/docs/testing` +- **GitHub Issues**: https://github.com/adcontextprotocol/salesagent/issues diff --git a/fly.toml b/fly.toml index 7145f1bfe..070fbe500 100644 --- a/fly.toml +++ b/fly.toml @@ -51,12 +51,10 @@ primary_region = "iad" ADK_WEB_PORT = "8091" A2A_PORT = "8091" DB_TYPE = "postgresql" - SKIP_NGINX = "false" - # A2A Configuration - A2A_MOCK_MODE = "false" - A2A_SERVER_URL = "https://sales-agent.scope3.com/a2a" - # GCP Configuration (for auto-provisioning service accounts) - GCP_PROJECT_ID = "bok-playground" + # Domain configuration - set these as secrets: + # fly secrets set BASE_DOMAIN=your-domain.com + # fly secrets set SALES_AGENT_DOMAIN=sales-agent.your-domain.com + # fly secrets set GCP_PROJECT_ID=your-gcp-project [mounts] source = "adcp_data" diff --git a/pyproject.toml b/pyproject.toml index d320c16d1..31cb715b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ ui-tests = [ [dependency-groups] dev = [ + "commitizen>=3.29.0", "mocker>=1.1.1", "mypy>=1.18.2", "pytest>=8.4.1", @@ -142,3 +143,12 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = ["E722", "F821"] "admin_ui.py" = ["E402", "E722"] + +[tool.commitizen] +name = "cz_conventional_commits" +version = "0.1.0" +tag_format = "v$version" +update_changelog_on_bump = true +version_files = [ + "pyproject.toml:version" +] diff --git a/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta b/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta index bd247b373..3fcd12251 100644 --- a/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta +++ b/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta @@ -1,6 +1,7 @@ { - "etag": "W/\"68ffaa97-1ce0\"", - "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT", - "downloaded_at": "2025-10-27T20:50:42.491975", - "schema_ref": "/schemas/v1/core/webhook-payload.json" + "etag": "W/\"6901f913-1ce0\"", + "last-modified": "Wed, 29 Oct 2025 11:22:59 GMT", + "downloaded_at": "2025-10-29T12:14:52.976640", + "schema_ref": "/schemas/v1/core/webhook-payload.json", + "content_hash": "3037a3a80eb9e20dc2034a6701bf871e944f910e3ea1fec0885361ed06ace8eb" } diff --git a/schemas/v1/_schemas_v1_enums_task-type_json.json.meta b/schemas/v1/_schemas_v1_enums_task-type_json.json.meta index 091746cb1..52f225692 100644 --- a/schemas/v1/_schemas_v1_enums_task-type_json.json.meta +++ b/schemas/v1/_schemas_v1_enums_task-type_json.json.meta @@ -1,6 +1,7 @@ { - "etag": "W/\"68ffaa97-531\"", - "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT", - "downloaded_at": "2025-10-27T20:50:43.126772", - "schema_ref": "/schemas/v1/enums/task-type.json" + "etag": "W/\"6901f913-531\"", + "last-modified": "Wed, 29 Oct 2025 11:22:59 GMT", + "downloaded_at": "2025-10-29T12:14:53.546672", + "schema_ref": "/schemas/v1/enums/task-type.json", + "content_hash": "7d0bd6c33ff8e507f1b2c642c00f92bbbd30f4db489f9be7fe07a10276ee604f" } diff --git a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta index 3a7b2e45a..28b4795a5 100644 --- a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta +++ b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta @@ -1,7 +1,7 @@ { - "etag": "W/\"68ffaa97-7b9\"", - "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT", - "downloaded_at": "2025-10-28T16:15:28.603429", + "etag": "W/\"6901f913-7a1\"", + "last-modified": "Wed, 29 Oct 2025 11:22:59 GMT", + "downloaded_at": "2025-10-29T12:14:53.580413", "schema_ref": "/schemas/v1/media-buy/get-products-request.json", - "content_hash": "3de0c3f8a90fb2f1aacc80c973ccebe53737107fcea7c64abb69a33eaa3c7caf" + "content_hash": "1aee2a71dbf4212648696c52b98d69be4f9678bb12fed78b093357337e0a47c0" } diff --git a/schemas/v1/index.json b/schemas/v1/index.json index 00e501c0f..bbce47d17 100644 --- a/schemas/v1/index.json +++ b/schemas/v1/index.json @@ -4,7 +4,7 @@ "title": "AdCP Schema Registry v1", "version": "1.0.0", "description": "Registry of all AdCP JSON schemas for validation and discovery", - "adcp_version": "2.2.0", + "adcp_version": "2.3.0", "standard_formats_version": "2.0.0", "versioning": { "note": "AdCP uses path-based versioning. The schema URL path (/schemas/v1/) indicates the version. Individual request/response schemas do NOT include adcp_version fields. Compatibility follows semantic versioning rules." @@ -31,7 +31,7 @@ ] } }, - "lastUpdated": "2025-10-20", + "lastUpdated": "2025-10-29", "baseUrl": "/schemas/v1", "schemas": { "core": { diff --git a/schemas/v1/index.json.meta b/schemas/v1/index.json.meta index 322127cc2..aed7a3ec9 100644 --- a/schemas/v1/index.json.meta +++ b/schemas/v1/index.json.meta @@ -1,6 +1,6 @@ { - "etag": "W/\"68ffaa97-4dd6\"", - "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT", - "downloaded_at": "2025-10-28T16:15:28.411191", - "content_hash": "6cd4b75cb8bdb945fb8f5ae99b12b99365fe5fe83f457bc94ce66d13fa9d78b7" + "etag": "W/\"6901f913-4dd6\"", + "last-modified": "Wed, 29 Oct 2025 11:22:59 GMT", + "downloaded_at": "2025-10-29T12:14:51.932120", + "content_hash": "68833f9d6e976254013f88e06ae9578b780961f9bf57acaa28a2889921d3a16d" } diff --git a/src/a2a_server/adcp_a2a_server.py b/src/a2a_server/adcp_a2a_server.py index 332df09a6..d8d8f878a 100644 --- a/src/a2a_server/adcp_a2a_server.py +++ b/src/a2a_server/adcp_a2a_server.py @@ -61,6 +61,7 @@ from src.core.audit_logger import get_audit_logger from src.core.auth_utils import get_principal_from_token from src.core.config_loader import get_current_tenant +from src.core.domain_config import get_a2a_server_url, get_sales_agent_domain from src.core.schemas import ( GetSignalsRequest, ) @@ -2111,9 +2112,9 @@ def create_agent_card() -> AgentCard: Returns: AgentCard with AdCP Sales Agent capabilities """ - # Use new production domain for agent card + # Use configured domain for agent card # Note: This will be overridden dynamically in the endpoint handlers - server_url = "https://sales-agent.scope3.com/a2a" + server_url = get_a2a_server_url() from a2a.types import AgentCapabilities, AgentSkill @@ -2304,14 +2305,15 @@ def get_protocol(hostname: str) -> str: else: # Fallback to Host header host = get_header_case_insensitive(request.headers, "Host") or "" - if host and host != "sales-agent.scope3.com": + sales_domain = get_sales_agent_domain() + if host and host != sales_domain: # For external domains or localhost, use appropriate protocol protocol = get_protocol(host) server_url = f"{protocol}://{host}/a2a" logger.info(f"Using Host header: {host} -> {server_url}") else: - # Default fallback - production HTTPS - server_url = "https://sales-agent.scope3.com/a2a" + # Default fallback - configured production URL + server_url = get_a2a_server_url() logger.info(f"Using default URL: {server_url}") # Create a copy of the static agent card with dynamic URL diff --git a/src/admin/app.py b/src/admin/app.py index 7a9398c86..ffba4a43c 100644 --- a/src/admin/app.py +++ b/src/admin/app.py @@ -34,6 +34,11 @@ from src.admin.blueprints.tenants import tenants_bp from src.admin.blueprints.users import users_bp from src.admin.blueprints.workflows import workflows_bp +from src.core.domain_config import ( + get_session_cookie_domain, + get_tenant_url, + is_sales_agent_domain, +) # Configure logging logging.basicConfig(level=logging.INFO) @@ -111,7 +116,7 @@ def create_app(config=None): app.config["SESSION_COOKIE_HTTPONLY"] = False # Allow EventSource to access cookies app.config["SESSION_COOKIE_SAMESITE"] = "None" # Required for EventSource cross-origin requests app.config["SESSION_COOKIE_PATH"] = "/admin/" # Ensure cookies work for all /admin/* paths - app.config["SESSION_COOKIE_DOMAIN"] = ".sales-agent.scope3.com" # Allow cookies across subdomains for OAuth + app.config["SESSION_COOKIE_DOMAIN"] = get_session_cookie_domain() # Allow cookies across subdomains for OAuth else: app.config["SESSION_COOKIE_SECURE"] = False # Allow HTTP in dev app.config["SESSION_COOKIE_HTTPONLY"] = True # Standard setting for dev @@ -188,8 +193,8 @@ def redirect_external_domain_admin(): logger.debug(f"No Apx-Incoming-Host header for /admin request: {request.path}") return None # Not from Approximated, allow normal routing - # Check if it's an external domain (not ending in .sales-agent.scope3.com) - if apx_host.endswith(".sales-agent.scope3.com"): + # Check if it's an external domain (not part of sales agent domain) + if is_sales_agent_domain(apx_host): logger.debug(f"Subdomain request to /admin, allowing: {apx_host}") return None # Subdomain request, allow normal routing @@ -212,7 +217,7 @@ def redirect_external_domain_admin(): ) if os.environ.get("PRODUCTION") == "true": - redirect_url = f"https://{tenant_subdomain}.sales-agent.scope3.com{path_with_admin}" + redirect_url = f"{get_tenant_url(tenant_subdomain)}{path_with_admin}" else: # Local dev: Use localhost with port port = os.environ.get("ADMIN_UI_PORT", "8001") diff --git a/src/admin/blueprints/auth.py b/src/admin/blueprints/auth.py index ce9a8bfbe..e5eb68ebf 100644 --- a/src/admin/blueprints/auth.py +++ b/src/admin/blueprints/auth.py @@ -11,6 +11,13 @@ from src.admin.utils import is_super_admin # type: ignore[attr-defined] from src.core.database.database_session import get_db_session from src.core.database.models import Tenant +from src.core.domain_config import ( + extract_subdomain_from_host, + get_oauth_redirect_uri, + get_sales_agent_url, + get_super_admin_domain, + is_sales_agent_domain, +) logger = logging.getLogger(__name__) @@ -84,12 +91,12 @@ def login(): f"Detected tenant context from Approximated headers: {approximated_host} -> {tenant_context}" ) - # Fallback to direct domain routing (sales-agent.scope3.com) + # Fallback to direct domain routing if not tenant_context: tenant_subdomain = None - if ".sales-agent.scope3.com" in host and not host.startswith("admin."): - # Extract tenant subdomain (e.g., "scribd" from "scribd.sales-agent.scope3.com") - tenant_subdomain = host.split(".")[0] + if is_sales_agent_domain(host) and not host.startswith("admin."): + # Extract tenant subdomain from configured domain + tenant_subdomain = extract_subdomain_from_host(host) if tenant_subdomain: # Look up tenant by subdomain @@ -177,7 +184,7 @@ def tenant_google_auth(tenant_id): # Always use the registered OAuth redirect URI for Google (no modifications allowed) if os.environ.get("PRODUCTION") == "true": # For production, always use the exact registered redirect URI - redirect_uri = "https://sales-agent.scope3.com/admin/auth/google/callback" + redirect_uri = get_oauth_redirect_uri() else: # Development fallback redirect_uri = url_for("auth.google_callback", _external=True) @@ -259,7 +266,8 @@ def google_callback(): email_domain = email.split("@")[1] if "@" in email else "" # Check if user is super admin - if email_domain == "scope3.com" or is_super_admin(email): + super_admin_domain = get_super_admin_domain() + if email_domain == super_admin_domain or is_super_admin(email): session["is_super_admin"] = True session["role"] = "super_admin" flash(f"Welcome {user.get('name', email)}! (Super Admin)", "success") @@ -474,7 +482,7 @@ def gam_authorize(tenant_id): # Determine callback URI if os.environ.get("PRODUCTION") == "true": - callback_uri = "https://sales-agent.scope3.com/admin/auth/gam/callback" + callback_uri = f"{get_sales_agent_url()}/admin/auth/gam/callback" else: callback_uri = url_for("auth.gam_callback", _external=True) @@ -541,7 +549,7 @@ def gam_callback(): # Determine callback URI (must match the one used in authorization) if os.environ.get("PRODUCTION") == "true": - callback_uri = "https://sales-agent.scope3.com/admin/auth/gam/callback" + callback_uri = f"{get_sales_agent_url()}/admin/auth/gam/callback" else: callback_uri = url_for("auth.gam_callback", _external=True) diff --git a/src/admin/blueprints/authorized_properties.py b/src/admin/blueprints/authorized_properties.py index 056f9a7e3..3b6eb0b5c 100644 --- a/src/admin/blueprints/authorized_properties.py +++ b/src/admin/blueprints/authorized_properties.py @@ -15,6 +15,7 @@ from src.admin.utils.audit_decorator import log_admin_action from src.core.database.database_session import get_db_session from src.core.database.models import AuthorizedProperty, PropertyTag, Tenant +from src.core.domain_config import get_tenant_url from src.core.schemas import ( PROPERTY_ERROR_MESSAGES, PROPERTY_REQUIRED_FIELDS, @@ -246,7 +247,7 @@ def _construct_agent_url(tenant_id: str, request: Any) -> str: return url else: # Fallback to subdomain pattern - url = f"https://{subdomain}.sales-agent.scope3.com" + url = get_tenant_url(subdomain) logger.info(f"🌐 Production: using subdomain pattern -> {url}") return url diff --git a/src/admin/blueprints/core.py b/src/admin/blueprints/core.py index bdefd6e6d..63c28126e 100644 --- a/src/admin/blueprints/core.py +++ b/src/admin/blueprints/core.py @@ -14,6 +14,12 @@ from src.admin.utils.audit_decorator import log_admin_action from src.core.database.database_session import get_db_session from src.core.database.models import Principal, Tenant +from src.core.domain_config import ( + extract_subdomain_from_host, + get_a2a_server_url, + get_mcp_server_url, + is_sales_agent_domain, +) logger = logging.getLogger(__name__) @@ -34,9 +40,9 @@ def get_tenant_from_hostname(): tenant = db_session.scalars(select(Tenant).filter_by(virtual_host=approximated_host)).first() return tenant - # Fallback to direct domain routing (sales-agent.scope3.com) - if ".sales-agent.scope3.com" in host and not host.startswith("admin."): - tenant_subdomain = host.split(".")[0] + # Fallback to direct domain routing + if is_sales_agent_domain(host) and not host.startswith("admin."): + tenant_subdomain = extract_subdomain_from_host(host) with get_db_session() as db_session: tenant = db_session.scalars(select(Tenant).filter_by(subdomain=tenant_subdomain)).first() return tenant @@ -56,13 +62,13 @@ def index(): logger.info(f"[LANDING DEBUG] Host: {host}, Apx-Incoming-Host: {approximated_host}, Path: {request.path}") logger.info(f"[LANDING DEBUG] All headers: {dict(request.headers)}") - # admin.sales-agent.scope3.com should go to login + # Admin domain should go to login if (approximated_host and approximated_host.startswith("admin.")) or host.startswith("admin."): logger.info("[LANDING DEBUG] Detected admin domain, redirecting to login") return redirect(url_for("auth.login")) # Check if we're on an external virtual host (via Approximated) - if approximated_host and not approximated_host.endswith(".sales-agent.scope3.com"): + if approximated_host and not is_sales_agent_domain(approximated_host): # External domain detected - check if tenant exists for this virtual host logger.info(f"[LANDING DEBUG] External domain detected: {approximated_host}, checking for tenant") tenant = get_tenant_from_hostname() @@ -79,14 +85,14 @@ def index(): ) return render_template("landing.html") - # Check if we're on a tenant-specific subdomain (*.sales-agent.scope3.com) + # Check if we're on a tenant-specific subdomain tenant = get_tenant_from_hostname() if tenant: # Subdomain tenants redirect to login logger.info(f"[LANDING DEBUG] Tenant subdomain detected: {tenant.tenant_id}, redirecting to login") return redirect(url_for("auth.login")) - # Main domain (sales-agent.scope3.com) - show signup landing + # Main domain - show signup landing logger.info("[LANDING DEBUG] Main domain detected, redirecting to /signup") return redirect(url_for("public.landing")) @@ -364,9 +370,9 @@ def mcp_test(): # Get server URLs - use production URLs if in production, otherwise localhost if os.environ.get("PRODUCTION") == "true": - # In production, both servers are accessible at the virtual host domain - mcp_server_url = "https://sales-agent.scope3.com/mcp" # Remove trailing slash - a2a_server_url = "https://sales-agent.scope3.com/a2a" + # In production, both servers are accessible at the configured domain + mcp_server_url = get_mcp_server_url() + a2a_server_url = get_a2a_server_url() else: # In development, use localhost with the configured ports from environment # Default to common development ports if not set diff --git a/src/admin/blueprints/public.py b/src/admin/blueprints/public.py index 2b728143e..fe5c9cf32 100644 --- a/src/admin/blueprints/public.py +++ b/src/admin/blueprints/public.py @@ -12,6 +12,7 @@ from src.core.database.database_session import get_db_session from src.core.database.models import AdapterConfig, CurrencyLimit, Principal, Tenant, User +from src.core.domain_config import extract_subdomain_from_host, get_sales_agent_domain, is_sales_agent_domain logger = logging.getLogger(__name__) @@ -52,9 +53,10 @@ def landing(): return redirect(url_for("auth.login")) # Check subdomain routing - if ".sales-agent.scope3.com" in host and not host.startswith("admin."): - tenant_subdomain = host.split(".")[0] - if tenant_subdomain and tenant_subdomain != "sales-agent": + if is_sales_agent_domain(host) and not host.startswith("admin."): + tenant_subdomain = extract_subdomain_from_host(host) + sales_domain = get_sales_agent_domain() + if tenant_subdomain and tenant_subdomain != sales_domain.split(".")[0]: tenant = db_session.scalars(select(Tenant).filter_by(subdomain=tenant_subdomain)).first() if tenant: # On a tenant subdomain - redirect to login instead diff --git a/src/admin/blueprints/schemas.py b/src/admin/blueprints/schemas.py index 77d6b2ae7..92c9ffa5f 100644 --- a/src/admin/blueprints/schemas.py +++ b/src/admin/blueprints/schemas.py @@ -9,6 +9,7 @@ from flask import Blueprint, jsonify +from src.core.domain_config import get_sales_agent_url from src.core.schema_validation import create_schema_registry logger = logging.getLogger(__name__) @@ -38,9 +39,10 @@ def get_schema(schema_name: str): for registry_name, schema in schema_registry.items(): if registry_name.replace("_", "").replace("-", "") == normalized_name: # Add metadata to the schema + base_url = get_sales_agent_url() schema_with_meta = { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": f"https://sales-agent.scope3.com/schemas/adcp/v2.4/{schema_name}.json", + "$id": f"{base_url}/schemas/adcp/v2.4/{schema_name}.json", "title": f"AdCP {registry_name.title()} Response Schema", "description": f"JSON Schema for AdCP v2.4 {registry_name} response validation", **schema, @@ -80,7 +82,7 @@ def list_schemas(): """ try: schema_registry = create_schema_registry() - base_url = "https://sales-agent.scope3.com/schemas/adcp/v2.4" + base_url = f"{get_sales_agent_url()}/schemas/adcp/v2.4" schemas_index = { "schemas": {}, @@ -115,7 +117,7 @@ def list_versions(): "available_versions": ["v2.4"], "current_version": "v2.4", "description": "Available AdCP schema versions", - "latest_url": "https://sales-agent.scope3.com/schemas/adcp/v2.4/", + "latest_url": f"{get_sales_agent_url()}/schemas/adcp/v2.4/", } ) @@ -134,7 +136,7 @@ def schemas_root(): "description": "Advertising Context Protocol", "versions": ["v2.4"], "current_version": "v2.4", - "url": "https://sales-agent.scope3.com/schemas/adcp/", + "url": f"{get_sales_agent_url()}/schemas/adcp/", } }, "description": "JSON Schema service for API validation", diff --git a/src/admin/blueprints/tenants.py b/src/admin/blueprints/tenants.py index cc2edc4ee..478abe03e 100644 --- a/src/admin/blueprints/tenants.py +++ b/src/admin/blueprints/tenants.py @@ -21,6 +21,7 @@ from src.admin.utils.audit_decorator import log_admin_action from src.core.database.database_session import get_db_session from src.core.database.models import Principal, Tenant, User +from src.core.domain_config import get_sales_agent_domain from src.core.validation import sanitize_form_data, validate_form_data from src.services.setup_checklist_service import SetupChecklistService @@ -362,6 +363,7 @@ def tenant_settings(tenant_id, section=None): admin_port=admin_port, is_production=is_production, script_name=script_name, + sales_agent_domain=get_sales_agent_domain(), authorized_domains=authorized_domains, authorized_emails=authorized_emails, product_count=product_count, diff --git a/src/admin/domain_access.py b/src/admin/domain_access.py index 5893dabff..2fd4b3675 100644 --- a/src/admin/domain_access.py +++ b/src/admin/domain_access.py @@ -11,6 +11,7 @@ from src.core.database.database_session import get_db_session from src.core.database.models import Tenant, User +from src.core.domain_config import get_super_admin_domain logger = logging.getLogger(__name__) @@ -166,16 +167,18 @@ def get_user_tenant_access(email: str) -> dict: """ email_domain = extract_email_domain(email) + super_admin_domain = get_super_admin_domain() + result: dict[str, Tenant | list[Tenant] | bool | int | None] = { "domain_tenant": None, "email_tenants": [], - "is_super_admin": email_domain == "scope3.com", + "is_super_admin": email_domain == super_admin_domain, "total_access": 0, } # Check domain-based access total_access = 0 - if email_domain and email_domain != "scope3.com": + if email_domain and email_domain != super_admin_domain: domain_tenant = find_tenant_by_authorized_domain(email_domain) if domain_tenant: result["domain_tenant"] = domain_tenant @@ -202,10 +205,11 @@ def add_authorized_domain(tenant_id: str, domain: str) -> bool: True if successful, False otherwise """ domain_lower = domain.lower() + super_admin_domain = get_super_admin_domain() - # Security check - prevent scope3.com hijacking - if domain_lower == "scope3.com": - logger.error(f"Attempted to add scope3.com domain to tenant {tenant_id}") + # Security check - prevent super admin domain hijacking + if domain_lower == super_admin_domain: + logger.error(f"Attempted to add super admin domain {domain} to tenant {tenant_id}") return False with get_db_session() as session: @@ -293,10 +297,11 @@ def add_authorized_email(tenant_id: str, email: str) -> bool: True if successful, False otherwise """ email_lower = email.lower() + super_admin_domain = get_super_admin_domain() - # Security check - prevent scope3.com email hijacking - if email_lower.endswith("@scope3.com"): - logger.error(f"Attempted to add scope3.com email {email} to tenant {tenant_id}") + # Security check - prevent super admin domain email hijacking + if email_lower.endswith(f"@{super_admin_domain}"): + logger.error(f"Attempted to add super admin domain email {email} to tenant {tenant_id}") return False with get_db_session() as session: diff --git a/src/core/domain_config.py b/src/core/domain_config.py new file mode 100644 index 000000000..d26c244c0 --- /dev/null +++ b/src/core/domain_config.py @@ -0,0 +1,135 @@ +""" +Domain configuration utilities. + +This module provides centralized domain configuration that can be customized +via environment variables, making the codebase vendor-neutral. +""" + +import os + + +def get_base_domain() -> str: + """Get the base domain (e.g., scope3.com). + + Defaults to scope3.com for backwards compatibility with existing deployments. + Override with BASE_DOMAIN environment variable to use a different domain. + """ + return os.getenv("BASE_DOMAIN", "scope3.com") + + +def get_sales_agent_domain() -> str: + """Get the sales agent domain (e.g., sales-agent.example.com).""" + return os.getenv("SALES_AGENT_DOMAIN", f"sales-agent.{get_base_domain()}") + + +def get_admin_domain() -> str: + """Get the admin domain (e.g., admin.sales-agent.example.com).""" + return os.getenv("ADMIN_DOMAIN", f"admin.{get_sales_agent_domain()}") + + +def get_super_admin_domain() -> str: + """Get the domain for super admin emails (e.g., example.com).""" + return os.getenv("SUPER_ADMIN_DOMAIN", get_base_domain()) + + +def get_sales_agent_url(protocol: str = "https") -> str: + """Get the full sales agent URL (e.g., https://sales-agent.example.com).""" + return f"{protocol}://{get_sales_agent_domain()}" + + +def get_admin_url(protocol: str = "https") -> str: + """Get the full admin URL (e.g., https://admin.sales-agent.example.com).""" + return f"{protocol}://{get_admin_domain()}" + + +def get_a2a_server_url(protocol: str = "https") -> str: + """Get the A2A server URL (e.g., https://sales-agent.example.com/a2a).""" + return f"{get_sales_agent_url(protocol)}/a2a" + + +def get_mcp_server_url(protocol: str = "https") -> str: + """Get the MCP server URL (e.g., https://sales-agent.example.com/mcp).""" + return f"{get_sales_agent_url(protocol)}/mcp" + + +def is_sales_agent_domain(host: str) -> bool: + """ + Check if the given host is part of the sales agent domain. + + Args: + host: The hostname to check (e.g., "tenant.sales-agent.example.com") + + Returns: + True if the host ends with the sales agent domain + """ + return host.endswith(f".{get_sales_agent_domain()}") or host == get_sales_agent_domain() + + +def is_admin_domain(host: str) -> bool: + """ + Check if the given host is the admin domain. + + Args: + host: The hostname to check + + Returns: + True if the host is the admin domain + """ + return host == get_admin_domain() or host.startswith(f"{get_admin_domain()}:") + + +def extract_subdomain_from_host(host: str) -> str | None: + """ + Extract the subdomain from a host if it's a sales agent domain. + + Args: + host: The hostname (e.g., "tenant.sales-agent.example.com") + + Returns: + The subdomain (e.g., "tenant") or None if not a subdomain + """ + sales_domain = get_sales_agent_domain() + + if f".{sales_domain}" in host: + return host.split(f".{sales_domain}")[0] + + return None + + +def get_tenant_url(subdomain: str, protocol: str = "https") -> str: + """ + Get the URL for a specific tenant subdomain. + + Args: + subdomain: The tenant subdomain + protocol: The protocol (http or https) + + Returns: + The full tenant URL (e.g., https://tenant.sales-agent.example.com) + """ + return f"{protocol}://{subdomain}.{get_sales_agent_domain()}" + + +def get_oauth_redirect_uri(protocol: str = "https") -> str: + """ + Get the OAuth redirect URI. + + Returns: + The OAuth callback URL (e.g., https://sales-agent.example.com/admin/auth/google/callback) + """ + # Allow override via environment variable + env_uri = os.getenv("GOOGLE_OAUTH_REDIRECT_URI") + if env_uri: + return env_uri + + return f"{get_sales_agent_url(protocol)}/admin/auth/google/callback" + + +def get_session_cookie_domain() -> str: + """ + Get the session cookie domain (with leading dot for subdomain sharing). + + Returns: + The cookie domain (e.g., ".sales-agent.example.com") + """ + return f".{get_sales_agent_domain()}" diff --git a/src/core/main.py b/src/core/main.py index fc91e6d57..1aacb71e7 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -39,6 +39,10 @@ ) from src.core.database.models import Principal as ModelPrincipal from src.core.database.models import Product as ModelProduct +from src.core.domain_config import ( + extract_subdomain_from_host, + is_sales_agent_domain, +) # Schema models (explicit imports to avoid collisions) # Schema adapters (wrapping generated schemas) @@ -577,9 +581,9 @@ async def debug_root_logic(request: Request): debug_info["exact_tenant_lookup"] = tenant is not None # If no exact match, check for domain-based routing patterns - if not tenant and ".sales-agent.scope3.com" in virtual_host and not virtual_host.startswith("admin."): + if not tenant and is_sales_agent_domain(virtual_host) and not virtual_host.startswith("admin."): debug_info["step"] = "subdomain_fallback" - subdomain = virtual_host.split(".sales-agent.scope3.com")[0] + subdomain = extract_subdomain_from_host(virtual_host) debug_info["extracted_subdomain"] = subdomain # This is the fallback logic we don't need for test-agent @@ -690,9 +694,9 @@ async def handle_landing_page(request: Request): ) # Check if this is a subdomain request - if apx_host and ".sales-agent.scope3.com" in apx_host: + if apx_host and is_sales_agent_domain(apx_host): # Extract subdomain from apx_host - subdomain = apx_host.split(".sales-agent.scope3.com")[0] + subdomain = extract_subdomain_from_host(apx_host) # Look up tenant by subdomain try: diff --git a/src/core/schema_validation.py b/src/core/schema_validation.py index 552111844..337278a2f 100644 --- a/src/core/schema_validation.py +++ b/src/core/schema_validation.py @@ -11,6 +11,8 @@ from pydantic import BaseModel, ConfigDict +from src.core.domain_config import get_sales_agent_url + class SchemaMetadata(BaseModel): """Metadata about the JSON Schema for API response validation.""" @@ -55,7 +57,7 @@ def get_schema_reference(model_class: type[BaseModel], base_url: str | None = No Schema reference URL """ if base_url is None: - base_url = "https://sales-agent.scope3.com" + base_url = get_sales_agent_url() schema_name = model_class.__name__.lower().replace("response", "") return urljoin(base_url, f"/schemas/adcp/v2.4/{schema_name}.json") @@ -128,7 +130,7 @@ def enhance_mcp_response_with_schema( response_data=response_data, model_class=model_class, include_full_schema=include_full_schema, - base_url="https://sales-agent.scope3.com", + base_url=get_sales_agent_url(), ) @@ -149,7 +151,7 @@ def enhance_a2a_response_with_schema( enhanced_response = response_data.copy() # Add schema metadata at the top level - schema_metadata = create_schema_metadata(model_class, "https://sales-agent.scope3.com") + schema_metadata = create_schema_metadata(model_class, get_sales_agent_url()) enhanced_response["$schema"] = schema_metadata.model_dump() if include_full_schema: diff --git a/src/core/schemas_generated/__init__.py b/src/core/schemas_generated/__init__.py index 96e09916f..d894d0cc0 100644 --- a/src/core/schemas_generated/__init__.py +++ b/src/core/schemas_generated/__init__.py @@ -1,4 +1,4 @@ -# SCHEMA_HASH: 1ab1d336e15cb56f9b8d1b70469f2c53 +# SCHEMA_HASH: e0e8152aa4ab03df7efc72ffa03c965f """ Auto-generated Pydantic models from AdCP JSON schemas. diff --git a/src/core/schemas_generated/_schemas_v1_media_buy_get_products_request_json.py b/src/core/schemas_generated/_schemas_v1_media_buy_get_products_request_json.py index 924e91aa3..667979a84 100644 --- a/src/core/schemas_generated/_schemas_v1_media_buy_get_products_request_json.py +++ b/src/core/schemas_generated/_schemas_v1_media_buy_get_products_request_json.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: _schemas_v1_media-buy_get-products-request_json.json -# schema_hash: ee282dea5581 +# schema_hash: 5317ffaeba09 from __future__ import annotations @@ -302,7 +302,7 @@ class GetProductsRequest(BaseModel): ) brief: Annotated[str | None, Field(description="Natural language description of campaign requirements")] = None brand_manifest: Annotated[ - BrandManifest | BrandManifest12 | AnyUrl, + BrandManifest | BrandManifest12 | AnyUrl | None, Field( description="Brand manifest provided either as an inline object or a URL string pointing to a hosted manifest", examples=[ @@ -321,5 +321,5 @@ class GetProductsRequest(BaseModel): ], title="Brand Manifest Reference", ), - ] + ] = None filters: Annotated[Filters | None, Field(description="Structured filters for product discovery")] = None diff --git a/src/landing/landing_page.py b/src/landing/landing_page.py index 00f093b5b..158a00aa6 100644 --- a/src/landing/landing_page.py +++ b/src/landing/landing_page.py @@ -5,6 +5,13 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape +from src.core.domain_config import ( + extract_subdomain_from_host, + get_sales_agent_url, + get_tenant_url, + is_sales_agent_domain, +) + def _get_jinja_env() -> Environment: """Get configured Jinja2 environment for landing page templates.""" @@ -32,7 +39,7 @@ def _determine_base_url(virtual_host: str | None = None) -> str: if virtual_host: return f"https://{virtual_host}" # Fallback to production domain - return "https://sales-agent.scope3.com" + return get_sales_agent_url() # Check for virtual host in development if virtual_host: @@ -56,9 +63,10 @@ def _extract_tenant_subdomain(tenant: dict, virtual_host: str | None = None) -> """ # First try virtual host if virtual_host: - # Extract subdomain from virtual host (e.g., scribd.sales-agent.scope3.com -> scribd) - if ".sales-agent.scope3.com" in virtual_host: - return virtual_host.split(".sales-agent.scope3.com")[0] + # Extract subdomain from virtual host using domain config + subdomain = extract_subdomain_from_host(virtual_host) + if subdomain: + return subdomain elif "." in virtual_host: # Generic virtual host, use first part return virtual_host.split(".")[0] @@ -96,11 +104,11 @@ def generate_tenant_landing_page(tenant: dict, virtual_host: str | None = None) agent_card_url = f"{base_url}/.well-known/agent.json" # Admin URL: For external domains, use subdomain; otherwise use current domain - is_external_domain = virtual_host and not virtual_host.endswith(".sales-agent.scope3.com") + is_external_domain = virtual_host and not is_sales_agent_domain(virtual_host) if is_external_domain and tenant_subdomain: # External domain: Point admin to tenant subdomain if os.getenv("PRODUCTION") == "true": - admin_url = f"https://{tenant_subdomain}.sales-agent.scope3.com/admin/" + admin_url = f"{get_tenant_url(tenant_subdomain)}/admin/" else: # Local dev: Use localhost with subdomain simulation admin_url = f"http://{tenant_subdomain}.localhost:8001/admin/" @@ -120,7 +128,6 @@ def generate_tenant_landing_page(tenant: dict, virtual_host: str | None = None) "agent_card_url": agent_card_url, "admin_url": admin_url, "adcp_docs_url": "https://adcontextprotocol.org", - "scope3_url": "https://scope3.com", # Virtual host info "virtual_host": virtual_host, "is_production": os.getenv("PRODUCTION") == "true", diff --git a/static/js/tenant_settings.js b/static/js/tenant_settings.js index 24e4d66d0..cbb8d17da 100644 --- a/static/js/tenant_settings.js +++ b/static/js/tenant_settings.js @@ -26,7 +26,8 @@ const config = (function() { a2aPort: configEl.dataset.a2aPort || '8091', isProduction: configEl.dataset.isProduction === 'true', virtualHost: configEl.dataset.virtualHost || '', - subdomain: configEl.dataset.subdomain || '' + subdomain: configEl.dataset.subdomain || '', + salesAgentDomain: configEl.dataset.salesAgentDomain || 'sales-agent.example.com' }; })(); @@ -886,7 +887,7 @@ function generateA2ACode() { : `http://localhost:${config.a2aPort}`; const agentUriAlt = config.isProduction - ? `https://${config.subdomain}.sales-agent.scope3.com` + ? `https://${config.subdomain}.${config.salesAgentDomain}` : `http://localhost:${config.a2aPort}`; const code = ` @@ -1173,11 +1174,11 @@ function copyA2AConfig(principalId, principalName, accessToken) { if (config.isProduction) { // Production: Use subdomain or virtual host if (config.subdomain) { - a2aUrl = `https://${config.subdomain}.sales-agent.scope3.com/a2a`; + a2aUrl = `https://${config.subdomain}.${config.salesAgentDomain}/a2a`; } else if (config.virtualHost) { a2aUrl = `https://${config.virtualHost}/a2a`; } else { - a2aUrl = `https://sales-agent.scope3.com/a2a`; + a2aUrl = `https://${config.salesAgentDomain}/a2a`; } } else { // Development: Use localhost with configured port @@ -1229,11 +1230,11 @@ function copyMCPConfig(principalId, principalName, accessToken) { if (config.isProduction) { // Production: Use subdomain or virtual host if (config.subdomain) { - mcpUrl = `https://${config.subdomain}.sales-agent.scope3.com/mcp`; + mcpUrl = `https://${config.subdomain}.${config.salesAgentDomain}/mcp`; } else if (config.virtualHost) { mcpUrl = `https://${config.virtualHost}/mcp`; } else { - mcpUrl = `https://sales-agent.scope3.com/mcp`; + mcpUrl = `https://${config.salesAgentDomain}/mcp`; } } else { // Development: Use localhost with MCP port (8080) diff --git a/templates/tenant_settings.html b/templates/tenant_settings.html index 270d8c226..7cacfa3fb 100644 --- a/templates/tenant_settings.html +++ b/templates/tenant_settings.html @@ -2131,6 +2131,7 @@

Sales Agent Currently Inactive

data-virtual-host="{{ tenant.virtual_host if tenant.virtual_host else '' }}" data-subdomain="{{ tenant.subdomain if tenant.subdomain else '' }}" data-tenant-ad-server="{{ tenant.ad_server if tenant.ad_server else '' }}" + data-sales-agent-domain="{{ sales_agent_domain if sales_agent_domain else 'sales-agent.example.com' }}" style="display: none;"> diff --git a/tests/unit/test_virtual_host_landing_page.py b/tests/unit/test_virtual_host_landing_page.py index b3c3a1d1a..6963e030b 100644 --- a/tests/unit/test_virtual_host_landing_page.py +++ b/tests/unit/test_virtual_host_landing_page.py @@ -133,16 +133,16 @@ def test_landing_page_url_generation_development(self): assert "http://localhost:8080/a2a" in html_content assert "http://localhost:8080/.well-known/agent.json" in html_content - def test_landing_page_scope3_integration(self): - """Test that landing page includes Scope3 link for buying agents.""" - tenant = {"name": "Scope3 Test Publisher", "subdomain": "scope3test"} + def test_landing_page_basic_content(self): + """Test that landing page includes basic content elements.""" + tenant = {"name": "Test Publisher", "subdomain": "testpub"} html_content = generate_tenant_landing_page(tenant) - # Check for Scope3 integration - assert "Need a Buying Agent?" in html_content - assert "scope3.com" in html_content - assert "Get Agent from Scope3" in html_content + # Check for basic landing page content + assert "Test Publisher" in html_content + assert "AdCP" in html_content + assert "testpub" in html_content def test_landing_page_admin_dashboard_link(self): """Test that landing page includes admin dashboard link.""" diff --git a/uv.lock b/uv.lock index fad1fd1fe..30d356b04 100644 --- a/uv.lock +++ b/uv.lock @@ -122,6 +122,7 @@ ui-tests = [ [package.dev-dependencies] dev = [ + { name = "commitizen" }, { name = "datamodel-code-generator" }, { name = "jsonref" }, { name = "mocker" }, @@ -191,6 +192,7 @@ provides-extras = ["dev", "ui-tests"] [package.metadata.requires-dev] dev = [ + { name = "commitizen", specifier = ">=3.29.0" }, { name = "datamodel-code-generator", specifier = ">=0.26.0" }, { name = "jsonref", specifier = ">=1.1.0" }, { name = "mocker", specifier = ">=1.1.1" },