diff --git a/.env.example b/.env.example index 6f87908..3318ebe 100644 --- a/.env.example +++ b/.env.example @@ -5,32 +5,32 @@ CUSTOCRM_PLATFORM_DOMAIN=platform.localhost CUSTOCRM_ACME_EMAIL=local@example.invalid CUSTOCRM_ADMIN_PORT=18001 -HUB_ENV=local -HUB_DEBUG=true -HUB_SECRET_KEY=change-me-only-for-local-development +CUS_ENV=local +CUS_DEBUG=true +CUS_SECRET_KEY=change-me-only-for-local-development CUSTOCRM_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175 CUSTOCRM_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011 -HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175 +CUS_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175 # Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из -# HUB_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key(). -HUB_FIELD_ENCRYPTION_KEY= +# CUS_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key(). +CUS_FIELD_ENCRYPTION_KEY= # AI-провайдер. Локально по умолчанию тестовый адаптер; в production задайте ключ. -# HUB_AI_PROVIDER=openrouter -HUB_OPENROUTER_API_KEY= -# HUB_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 -# HUB_AI_REQUEST_TIMEOUT=30 -# HUB_AI_MAX_RETRIES=2 -# HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 +# CUS_AI_PROVIDER=openrouter +CUS_OPENROUTER_API_KEY= +# CUS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +# CUS_AI_REQUEST_TIMEOUT=30 +# CUS_AI_MAX_RETRIES=2 +# CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 -# Транспортная безопасность. Вне HUB_DEBUG включается автоматически; здесь — для переопределения. -# HUB_COOKIE_SECURE=true -# HUB_SSL_REDIRECT=true -# HUB_HSTS_SECONDS=31536000 -# HUB_COOKIE_SAMESITE=Lax +# Транспортная безопасность. Вне CUS_DEBUG включается автоматически; здесь — для переопределения. +# CUS_COOKIE_SECURE=true +# CUS_SSL_REDIRECT=true +# CUS_HSTS_SECONDS=31536000 +# CUS_COOKIE_SAMESITE=Lax # Email (по умолчанию console backend; для реальной отправки задайте SMTP). # EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend @@ -56,26 +56,26 @@ REDIS_URL=redis://redis:6379/0 # Tenant-owned object storage. Production требует S3-compatible backend; # local/test могут явно использовать filesystem. -HUB_STORAGE_BACKEND=filesystem -# HUB_S3_BUCKET=custocrm -# HUB_S3_ENDPOINT_URL=https://s3.example.invalid -# HUB_S3_REGION=ru-central1 -# HUB_S3_ACCESS_KEY= -# HUB_S3_SECRET_KEY= -# HUB_S3_ADDRESSING_STYLE=path -# HUB_S3_URL_EXPIRY_SECONDS=900 +CUS_STORAGE_BACKEND=filesystem +# CUS_S3_BUCKET=custocrm +# CUS_S3_ENDPOINT_URL=https://s3.example.invalid +# CUS_S3_REGION=ru-central1 +# CUS_S3_ACCESS_KEY= +# CUS_S3_SECRET_KEY= +# CUS_S3_ADDRESSING_STYLE=path +# CUS_S3_URL_EXPIRY_SECONDS=900 # P2P calls: invite — 5 минут, access token — 1 час. -HUB_CALL_INVITE_TTL_SECONDS=300 -HUB_CALL_ACCESS_TTL_SECONDS=3600 -HUB_CALL_CONNECT_GRACE_SECONDS=120 -HUB_CALL_RECONNECT_GRACE_SECONDS=60 +CUS_CALL_INVITE_TTL_SECONDS=300 +CUS_CALL_ACCESS_TTL_SECONDS=3600 +CUS_CALL_CONNECT_GRACE_SECONDS=120 +CUS_CALL_RECONNECT_GRACE_SECONDS=60 # STUN для WebRTC (через запятую). Локально обычно пусто: direct ICE на localhost. -HUB_CALL_STUN_URLS= +CUS_CALL_STUN_URLS= # TURN (Coturn) — production-контур; локально пусто (direct/STUN достаточно). -HUB_CALL_TURN_URLS= -HUB_CALL_TURN_SECRET= -HUB_CALL_TURN_TTL_SECONDS=3600 +CUS_CALL_TURN_URLS= +CUS_CALL_TURN_SECRET= +CUS_CALL_TURN_TTL_SECONDS=3600 INTERNAL_UI_PORT=5173 WEB_CHAT_PORT=5175 diff --git a/apps/backend/Dockerfile.production b/apps/backend/Dockerfile.production index a9ee97a..ac67e9e 100644 --- a/apps/backend/Dockerfile.production +++ b/apps/backend/Dockerfile.production @@ -20,12 +20,12 @@ COPY content /app/content # Build-time dummy values satisfy C04 runtime guards (distinct DB users in # settings_database, S3 bucket in settings_storage) that only matter at runtime; # collectstatic touches neither. Runtime values come from instance .env. -RUN cd apps/backend && HUB_SECRET_KEY=collectstatic-build HUB_DEBUG=false HUB_ENV=production \ +RUN cd apps/backend && CUS_SECRET_KEY=collectstatic-build CUS_DEBUG=false CUS_ENV=production \ CUSTOCRM_APP_ALLOWED_HOSTS=collectstatic.invalid \ POSTGRES_APP_USER=build-app \ POSTGRES_PLATFORM_USER=build-platform \ POSTGRES_MIGRATION_USER=build-migration \ - HUB_S3_BUCKET=build-placeholder \ + CUS_S3_BUCKET=build-placeholder \ python manage.py collectstatic --noinput RUN chown -R hub:hub /app diff --git a/apps/backend/hub_backend/settings_admin.py b/apps/backend/hub_backend/settings_admin.py index eeb3450..f2d3b1f 100644 --- a/apps/backend/hub_backend/settings_admin.py +++ b/apps/backend/hub_backend/settings_admin.py @@ -3,7 +3,7 @@ import os from hub_backend.settings_base import * -HUB_RUNTIME_SURFACE = "admin" +CUS_RUNTIME_SURFACE = "admin" ROOT_URLCONF = "hub_backend.urls_admin" ASGI_APPLICATION = "hub_backend.asgi_admin.application" WSGI_APPLICATION = "hub_backend.wsgi_admin.application" @@ -22,7 +22,7 @@ SESSION_COOKIE_PATH = "/" CSRF_COOKIE_PATH = "/" SECURE_SSL_REDIRECT = env_bool("CUSTOCRM_ADMIN_SSL_REDIRECT", False) -HUB_CONTENT_SECURITY_POLICY = os.environ.get( +CUS_CONTENT_SECURITY_POLICY = os.environ.get( "CUSTOCRM_ADMIN_CSP", "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; " "form-action 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " diff --git a/apps/backend/hub_backend/settings_app.py b/apps/backend/hub_backend/settings_app.py index 216ddd0..405d143 100644 --- a/apps/backend/hub_backend/settings_app.py +++ b/apps/backend/hub_backend/settings_app.py @@ -5,7 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from hub_backend.settings_base import * -HUB_RUNTIME_SURFACE = "app" +CUS_RUNTIME_SURFACE = "app" ROOT_URLCONF = "hub_backend.urls_app" ASGI_APPLICATION = "hub_backend.asgi_app.application" WSGI_APPLICATION = "hub_backend.wsgi_app.application" @@ -15,11 +15,11 @@ if not DEBUG and not TESTING and not _app_hosts: raise ImproperlyConfigured("CUSTOCRM_APP_ALLOWED_HOSTS is required for the app surface") ALLOWED_HOSTS = env_list( "CUSTOCRM_APP_ALLOWED_HOSTS", - env_list("HUB_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]), + env_list("CUS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]), ) CSRF_TRUSTED_ORIGINS = env_list( "CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS", - env_list("HUB_CSRF_TRUSTED_ORIGINS", []), + env_list("CUS_CSRF_TRUSTED_ORIGINS", []), ) SESSION_COOKIE_NAME = os.environ.get( @@ -35,8 +35,8 @@ CSRF_COOKIE_DOMAIN = None SESSION_COOKIE_PATH = "/" CSRF_COOKIE_PATH = "/" -HUB_PUBLIC_BASE_URL = os.environ.get("CUSTOCRM_APP_PUBLIC_BASE_URL", HUB_PUBLIC_BASE_URL) -HUB_CONTENT_SECURITY_POLICY = os.environ.get( +CUS_PUBLIC_BASE_URL = os.environ.get("CUSTOCRM_APP_PUBLIC_BASE_URL", CUS_PUBLIC_BASE_URL) +CUS_CONTENT_SECURITY_POLICY = os.environ.get( "CUSTOCRM_APP_CSP", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", ) diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py index 4291153..605b57a 100644 --- a/apps/backend/hub_backend/settings_base.py +++ b/apps/backend/hub_backend/settings_base.py @@ -15,15 +15,15 @@ INSECURE_SECRET_KEY = "local-development-only" # Автоопределение тестового прогона, чтобы manage.py test / pytest работали # без ручного выставления production-окружения. TESTING = "test" in sys.argv or "pytest" in sys.modules -SECRET_KEY = os.environ.get("HUB_SECRET_KEY", INSECURE_SECRET_KEY) -DEBUG = env_bool("HUB_DEBUG") -ALLOWED_HOSTS = env_list("HUB_ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) -CSRF_TRUSTED_ORIGINS = env_list("HUB_CSRF_TRUSTED_ORIGINS", []) +SECRET_KEY = os.environ.get("CUS_SECRET_KEY", INSECURE_SECRET_KEY) +DEBUG = env_bool("CUS_DEBUG") +ALLOWED_HOSTS = env_list("CUS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) +CSRF_TRUSTED_ORIGINS = env_list("CUS_CSRF_TRUSTED_ORIGINS", []) # Запрещаем запуск в production с дефолтным/пустым ключом подписи. if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}: raise ImproperlyConfigured( - "HUB_SECRET_KEY must be set to a strong value when HUB_DEBUG is disabled" + "CUS_SECRET_KEY must be set to a strong value when CUS_DEBUG is disabled" ) INSTALLED_APPS = [ @@ -142,26 +142,26 @@ INTERNAL_UI_BASE_URL = os.environ.get("INTERNAL_UI_BASE_URL", "http://localhost: # Ключ шифрования секретов в БД (Fernet). В production задаётся явно; иначе # детерминированно выводится из SECRET_KEY (см. hub_platform.identity.crypto). -HUB_FIELD_ENCRYPTION_KEY = os.environ.get("HUB_FIELD_ENCRYPTION_KEY", "") +CUS_FIELD_ENCRYPTION_KEY = os.environ.get("CUS_FIELD_ENCRYPTION_KEY", "") # AI provider runtime. The local adapter is explicit and test-only. -HUB_AI_PROVIDER = os.environ.get("HUB_AI_PROVIDER", "") -if TESTING and not HUB_AI_PROVIDER: - HUB_AI_PROVIDER = "test" -HUB_OPENROUTER_BASE_URL = os.environ.get("HUB_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") -HUB_AI_REQUEST_TIMEOUT = float(os.environ.get("HUB_AI_REQUEST_TIMEOUT", "30")) -HUB_AI_MAX_RETRIES = int(os.environ.get("HUB_AI_MAX_RETRIES", "2")) -HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int( - os.environ.get("HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0") +CUS_AI_PROVIDER = os.environ.get("CUS_AI_PROVIDER", "") +if TESTING and not CUS_AI_PROVIDER: + CUS_AI_PROVIDER = "test" +CUS_OPENROUTER_BASE_URL = os.environ.get("CUS_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") +CUS_AI_REQUEST_TIMEOUT = float(os.environ.get("CUS_AI_REQUEST_TIMEOUT", "30")) +CUS_AI_MAX_RETRIES = int(os.environ.get("CUS_AI_MAX_RETRIES", "2")) +CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int( + os.environ.get("CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0") ) # 0 = без лимита -HUB_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели -HUB_AI_EMBEDDING_MODEL = os.environ.get("HUB_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small") +CUS_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели +CUS_AI_EMBEDDING_MODEL = os.environ.get("CUS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small") # CustoAI platform credential is configured only through environment/secret storage. -HUB_CUSTOAI_API_KEY = os.environ.get("HUB_CUSTOAI_API_KEY", "") -HUB_CUSTOAI_BASE_URL = os.environ.get("HUB_CUSTOAI_BASE_URL", "https://ai.api.cloud.yandex.net/v1") -HUB_CUSTOAI_MODEL = os.environ.get( - "HUB_CUSTOAI_MODEL", +CUS_CUSTOAI_API_KEY = os.environ.get("CUS_CUSTOAI_API_KEY", "") +CUS_CUSTOAI_BASE_URL = os.environ.get("CUS_CUSTOAI_BASE_URL", "https://ai.api.cloud.yandex.net/v1") +CUS_CUSTOAI_MODEL = os.environ.get( + "CUS_CUSTOAI_MODEL", "gpt://b1g89tr9t8iedhnl8pgg/yandexgpt-5.1/latest", ) @@ -169,7 +169,7 @@ HUB_CUSTOAI_MODEL = os.environ.get( # и inbound-поллинг, и outbox-диспатч в одном потоке — при большом hold-time # getUpdates/updates блокирует цикл и outbox (приглашения звонков, уведомления, # ответы AI) уходит с задержкой в размер long-poll на каждое подключение. -HUB_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("HUB_MESSENGER_POLL_TIMEOUT_SECONDS", "2")) +CUS_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("CUS_MESSENGER_POLL_TIMEOUT_SECONDS", "2")) # Password reset link lifetime. UI обещает 30 минут (default_token_generator uses this setting). PASSWORD_RESET_TIMEOUT = int(os.environ.get("PASSWORD_RESET_TIMEOUT", str(30 * 60))) @@ -179,13 +179,13 @@ _secure_default = not DEBUG and not TESTING SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_CONTENT_TYPE_NOSNIFF = True SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SAMESITE = os.environ.get("HUB_COOKIE_SAMESITE", "Lax") +SESSION_COOKIE_SAMESITE = os.environ.get("CUS_COOKIE_SAMESITE", "Lax") CSRF_COOKIE_SAMESITE = SESSION_COOKIE_SAMESITE -SESSION_COOKIE_SECURE = env_bool("HUB_COOKIE_SECURE", _secure_default) -CSRF_COOKIE_SECURE = env_bool("HUB_COOKIE_SECURE", _secure_default) -SECURE_SSL_REDIRECT = env_bool("HUB_SSL_REDIRECT", _secure_default) +SESSION_COOKIE_SECURE = env_bool("CUS_COOKIE_SECURE", _secure_default) +CSRF_COOKIE_SECURE = env_bool("CUS_COOKIE_SECURE", _secure_default) +SECURE_SSL_REDIRECT = env_bool("CUS_SSL_REDIRECT", _secure_default) SECURE_HSTS_SECONDS = int( - os.environ.get("HUB_HSTS_SECONDS", str(60 * 60 * 24 * 365) if _secure_default else "0") + os.environ.get("CUS_HSTS_SECONDS", str(60 * 60 * 24 * 365) if _secure_default else "0") ) SECURE_HSTS_INCLUDE_SUBDOMAINS = SECURE_HSTS_SECONDS > 0 SECURE_HSTS_PRELOAD = SECURE_HSTS_SECONDS > 0 @@ -196,45 +196,45 @@ STATIC_ROOT = BASE_DIR / "staticfiles" # Файловые вложения знаний (ADR-HUB-0023). Файлы отдаются только через # download-endpoint (FileResponse), прямого статик-роутинга MEDIA нет. MEDIA_URL = "media/" -HUB_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings( +CUS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings( base_dir=BASE_DIR, debug=DEBUG, testing=TESTING, ) # Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений). -HUB_PUBLIC_BASE_URL = os.environ.get("HUB_PUBLIC_BASE_URL", "http://localhost:8000") +CUS_PUBLIC_BASE_URL = os.environ.get("CUS_PUBLIC_BASE_URL", "http://localhost:8000") # P2P calls: opaque invitation lifetime and short-lived signaling/media access. -HUB_CALL_INVITE_TTL_SECONDS = int(os.environ.get("HUB_CALL_INVITE_TTL_SECONDS", str(5 * 60))) -HUB_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("HUB_CALL_ACCESS_TTL_SECONDS", str(60 * 60))) +CUS_CALL_INVITE_TTL_SECONDS = int(os.environ.get("CUS_CALL_INVITE_TTL_SECONDS", str(5 * 60))) +CUS_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("CUS_CALL_ACCESS_TTL_SECONDS", str(60 * 60))) # Grace period: принятый звонок без установленного соединения закрывается FAILED. -HUB_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_CONNECT_GRACE_SECONDS", str(2 * 60))) +CUS_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("CUS_CALL_CONNECT_GRACE_SECONDS", str(2 * 60))) # Grace period восстановления активного звонка после обрыва участника. -HUB_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_RECONNECT_GRACE_SECONDS", str(60))) +CUS_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("CUS_CALL_RECONNECT_GRACE_SECONDS", str(60))) # C07 concurrent quota: lease TTL for a p2p-call slot reservation. A crashed # session is released by the reservation sweep once the lease lapses. -HUB_CONCURRENT_CALL_LEASE_SECONDS = int( - os.environ.get("HUB_CONCURRENT_CALL_LEASE_SECONDS", str(2 * 60 * 60)) +CUS_CONCURRENT_CALL_LEASE_SECONDS = int( + os.environ.get("CUS_CONCURRENT_CALL_LEASE_SECONDS", str(2 * 60 * 60)) ) if ( - HUB_CALL_INVITE_TTL_SECONDS <= 0 - or HUB_CALL_ACCESS_TTL_SECONDS <= 0 - or HUB_CALL_CONNECT_GRACE_SECONDS <= 0 - or HUB_CALL_RECONNECT_GRACE_SECONDS <= 0 + CUS_CALL_INVITE_TTL_SECONDS <= 0 + or CUS_CALL_ACCESS_TTL_SECONDS <= 0 + or CUS_CALL_CONNECT_GRACE_SECONDS <= 0 + or CUS_CALL_RECONNECT_GRACE_SECONDS <= 0 ): raise ImproperlyConfigured("HUB call token TTL values must be positive") # ICE-серверы для WebRTC (SPEC-HUB-0013 §10): direct-first через STUN, TURN как # fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp). -HUB_CALL_STUN_URLS = env_list("HUB_CALL_STUN_URLS", []) +CUS_CALL_STUN_URLS = env_list("CUS_CALL_STUN_URLS", []) # TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials # по общему static-auth-secret. Пусто локально -> только STUN/direct ICE. -HUB_CALL_TURN_URLS = env_list("HUB_CALL_TURN_URLS", []) -HUB_CALL_TURN_SECRET = os.environ.get("HUB_CALL_TURN_SECRET", "") -HUB_CALL_TURN_TTL_SECONDS = int(os.environ.get("HUB_CALL_TURN_TTL_SECONDS", str(60 * 60))) -if HUB_CALL_TURN_TTL_SECONDS <= 0: - raise ImproperlyConfigured("HUB_CALL_TURN_TTL_SECONDS must be positive") +CUS_CALL_TURN_URLS = env_list("CUS_CALL_TURN_URLS", []) +CUS_CALL_TURN_SECRET = os.environ.get("CUS_CALL_TURN_SECRET", "") +CUS_CALL_TURN_TTL_SECONDS = int(os.environ.get("CUS_CALL_TURN_TTL_SECONDS", str(60 * 60))) +if CUS_CALL_TURN_TTL_SECONDS <= 0: + raise ImproperlyConfigured("CUS_CALL_TURN_TTL_SECONDS must be positive") DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены. @@ -259,13 +259,13 @@ REST_FRAMEWORK = { } CORS_ALLOWED_ORIGINS = env_list( - "HUB_CORS_ALLOWED_ORIGINS", + "CUS_CORS_ALLOWED_ORIGINS", ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"], ) # Конкретное значение задаёт surface settings. Middleware не добавляет header, # если policy пуста (например, в узком техническом тесте). -HUB_CONTENT_SECURITY_POLICY = "" +CUS_CONTENT_SECURITY_POLICY = "" LOGGING = { "version": 1, diff --git a/apps/backend/hub_backend/settings_database.py b/apps/backend/hub_backend/settings_database.py index cbb57bf..07f87da 100644 --- a/apps/backend/hub_backend/settings_database.py +++ b/apps/backend/hub_backend/settings_database.py @@ -30,9 +30,9 @@ def _credentials() -> tuple[dict[str, str], dict[str, str]]: def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]: - role = os.environ.get("HUB_DB_ROLE", "app").lower() + role = os.environ.get("CUS_DB_ROLE", "app").lower() if role not in {"app", "platform", "migration"}: - raise ImproperlyConfigured("HUB_DB_ROLE must be app, platform or migration") + raise ImproperlyConfigured("CUS_DB_ROLE must be app, platform or migration") users, passwords = _credentials() if not debug and not testing and len(set(users.values())) != 3: raise ImproperlyConfigured( diff --git a/apps/backend/hub_backend/settings_platform.py b/apps/backend/hub_backend/settings_platform.py index a49ec65..b0fe48a 100644 --- a/apps/backend/hub_backend/settings_platform.py +++ b/apps/backend/hub_backend/settings_platform.py @@ -5,7 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from hub_backend.settings_base import * -HUB_RUNTIME_SURFACE = "platform" +CUS_RUNTIME_SURFACE = "platform" ROOT_URLCONF = "hub_backend.urls_platform" ASGI_APPLICATION = "hub_backend.asgi_platform.application" WSGI_APPLICATION = "hub_backend.wsgi_platform.application" @@ -39,7 +39,7 @@ CSRF_COOKIE_DOMAIN = None SESSION_COOKIE_PATH = "/" CSRF_COOKIE_PATH = "/" -HUB_CONTENT_SECURITY_POLICY = os.environ.get( +CUS_CONTENT_SECURITY_POLICY = os.environ.get( "CUSTOCRM_PLATFORM_CSP", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", ) diff --git a/apps/backend/hub_backend/settings_storage.py b/apps/backend/hub_backend/settings_storage.py index b98368b..ec4a53e 100644 --- a/apps/backend/hub_backend/settings_storage.py +++ b/apps/backend/hub_backend/settings_storage.py @@ -11,11 +11,11 @@ def build_storage_settings( testing: bool, ) -> tuple[str, Path, dict[str, dict]]: backend = os.environ.get( - "HUB_STORAGE_BACKEND", + "CUS_STORAGE_BACKEND", "filesystem" if debug or testing else "s3", ).lower() if backend not in {"filesystem", "s3"}: - raise ImproperlyConfigured("HUB_STORAGE_BACKEND must be 's3' or 'filesystem'") + raise ImproperlyConfigured("CUS_STORAGE_BACKEND must be 's3' or 'filesystem'") if not debug and not testing and backend != "s3": raise ImproperlyConfigured("Production tenant storage must use the S3 backend") @@ -23,28 +23,28 @@ def build_storage_settings( "BACKEND": "hub_platform.tenancy.storage_backends.TenantFileSystemStorage", } if backend == "s3": - bucket_name = os.environ.get("HUB_S3_BUCKET", "") + bucket_name = os.environ.get("CUS_S3_BUCKET", "") if not bucket_name: - raise ImproperlyConfigured("HUB_S3_BUCKET is required for S3 storage") + raise ImproperlyConfigured("CUS_S3_BUCKET is required for S3 storage") default_storage = { "BACKEND": "hub_platform.tenancy.storage_backends.TenantS3Storage", "OPTIONS": { "bucket_name": bucket_name, - "endpoint_url": os.environ.get("HUB_S3_ENDPOINT_URL") or None, - "region_name": os.environ.get("HUB_S3_REGION") or None, - "access_key": os.environ.get("HUB_S3_ACCESS_KEY") or None, - "secret_key": os.environ.get("HUB_S3_SECRET_KEY") or None, - "addressing_style": os.environ.get("HUB_S3_ADDRESSING_STYLE", "path"), + "endpoint_url": os.environ.get("CUS_S3_ENDPOINT_URL") or None, + "region_name": os.environ.get("CUS_S3_REGION") or None, + "access_key": os.environ.get("CUS_S3_ACCESS_KEY") or None, + "secret_key": os.environ.get("CUS_S3_SECRET_KEY") or None, + "addressing_style": os.environ.get("CUS_S3_ADDRESSING_STYLE", "path"), "default_acl": None, "file_overwrite": False, "querystring_auth": True, "querystring_expire": int( - os.environ.get("HUB_S3_URL_EXPIRY_SECONDS", "900") + os.environ.get("CUS_S3_URL_EXPIRY_SECONDS", "900") ), }, } - media_root = Path(os.environ.get("HUB_MEDIA_ROOT", base_dir / "media")) + media_root = Path(os.environ.get("CUS_MEDIA_ROOT", base_dir / "media")) storages = { "default": default_storage, "staticfiles": { diff --git a/apps/backend/hub_platform/ai/credits.py b/apps/backend/hub_platform/ai/credits.py index de218fc..bdd9bd3 100644 --- a/apps/backend/hub_platform/ai/credits.py +++ b/apps/backend/hub_platform/ai/credits.py @@ -95,8 +95,8 @@ def reserve_managed_ai_tokens( idempotency_key=key, lease_seconds=max( 60, - int(settings.HUB_AI_REQUEST_TIMEOUT) - * (int(settings.HUB_AI_MAX_RETRIES) + 1) + int(settings.CUS_AI_REQUEST_TIMEOUT) + * (int(settings.CUS_AI_MAX_RETRIES) + 1) + 120, ), source="ai.managed_invocation", diff --git a/apps/backend/hub_platform/ai/indexing.py b/apps/backend/hub_platform/ai/indexing.py index c5c2a76..6aa027a 100644 --- a/apps/backend/hub_platform/ai/indexing.py +++ b/apps/backend/hub_platform/ai/indexing.py @@ -24,7 +24,7 @@ def reindex_knowledge(knowledge: Knowledge) -> list[KnowledgeFragment]: embeddings = embed_texts( organization=knowledge.organization, texts=chunks, - model=settings.HUB_AI_EMBEDDING_MODEL, + model=settings.CUS_AI_EMBEDDING_MODEL, purpose="knowledge_index", ) vectors = [result.vector for result in embeddings] diff --git a/apps/backend/hub_platform/ai/invocation.py b/apps/backend/hub_platform/ai/invocation.py index 8d203e5..3a2f55b 100644 --- a/apps/backend/hub_platform/ai/invocation.py +++ b/apps/backend/hub_platform/ai/invocation.py @@ -46,7 +46,7 @@ def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMPro effective_model = agent.model if mode == CredentialMode.CUSTOAI: assert_managed_ai_entitlement(channel=channel) - effective_model = settings.HUB_CUSTOAI_MODEL + effective_model = settings.CUS_CUSTOAI_MODEL elif mode == CredentialMode.BYOK: assert_byok_ai_entitlement(channel=channel) effective_model = routing.resolve_model(channel, fallback_model=agent.model) @@ -70,7 +70,7 @@ def invoke_chat( used_fragment_ids: list | None = None, ) -> ChatResult: fallback_model = ( - settings.HUB_CUSTOAI_MODEL + settings.CUS_CUSTOAI_MODEL if channel.ai_agent.credential_mode == CredentialMode.CUSTOAI else model or channel.ai_agent.model ) @@ -102,7 +102,7 @@ def invoke_chat( try: result: ChatResult = call_with_resilience( lambda: provider.chat(messages=safe_messages, model=model, params=effective_params), - retries=settings.HUB_AI_MAX_RETRIES, + retries=settings.CUS_AI_MAX_RETRIES, breaker=_breaker, ) except ProviderError as error: @@ -158,7 +158,7 @@ def embed_texts( provider = get_provider(channel=channel) results: list[EmbeddingResult] = call_with_resilience( lambda: provider.embed(texts=texts, model=model), - retries=settings.HUB_AI_MAX_RETRIES, + retries=settings.CUS_AI_MAX_RETRIES, breaker=_breaker, ) tokens = sum(result.tokens for result in results) diff --git a/apps/backend/hub_platform/ai/limits.py b/apps/backend/hub_platform/ai/limits.py index 1d1a23e..2a5400a 100644 --- a/apps/backend/hub_platform/ai/limits.py +++ b/apps/backend/hub_platform/ai/limits.py @@ -22,7 +22,7 @@ def daily_cost_micros(channel=None) -> int: def assert_within_limits(channel, agent) -> None: - global_limit = settings.HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS + global_limit = settings.CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS if global_limit and daily_cost_micros() >= global_limit: raise LimitExceeded("Global daily AI cost limit reached") # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается diff --git a/apps/backend/hub_platform/ai/models.py b/apps/backend/hub_platform/ai/models.py index 8915e2b..83eb561 100644 --- a/apps/backend/hub_platform/ai/models.py +++ b/apps/backend/hub_platform/ai/models.py @@ -82,7 +82,7 @@ class KnowledgeAttachment(TenantRelationModel): from django.urls import reverse path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id}) - return settings.HUB_PUBLIC_BASE_URL.rstrip("/") + path + return settings.CUS_PUBLIC_BASE_URL.rstrip("/") + path class KnowledgeFragment(TenantRelationModel): diff --git a/apps/backend/hub_platform/ai/pricing.py b/apps/backend/hub_platform/ai/pricing.py index fbc5dde..2f68c6f 100644 --- a/apps/backend/hub_platform/ai/pricing.py +++ b/apps/backend/hub_platform/ai/pricing.py @@ -2,7 +2,7 @@ from django.conf import settings # micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов. # Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost). -# Реальные/уточнённые цены задаются через HUB_AI_PRICING. +# Реальные/уточнённые цены задаются через CUS_AI_PRICING. DEFAULT_PRICING = { "openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60}, "anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0}, @@ -10,7 +10,7 @@ DEFAULT_PRICING = { def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int: - table = {**DEFAULT_PRICING, **getattr(settings, "HUB_AI_PRICING", {})} + table = {**DEFAULT_PRICING, **getattr(settings, "CUS_AI_PRICING", {})} price = table.get(model) if not price: return 0 diff --git a/apps/backend/hub_platform/ai/provider/factory.py b/apps/backend/hub_platform/ai/provider/factory.py index 5643459..9abad24 100644 --- a/apps/backend/hub_platform/ai/provider/factory.py +++ b/apps/backend/hub_platform/ai/provider/factory.py @@ -3,7 +3,7 @@ from django.core.exceptions import ImproperlyConfigured from hub_platform.ai.models import CredentialMode from hub_platform.ai.provider import routing -from hub_platform.ai.provider.base import LLMProvider +from hub_platform.ai.provider.base import LLMProvider, ProviderError from hub_platform.ai.provider.custoai import CustoAIProvider from hub_platform.ai.provider.local import LocalProvider @@ -15,15 +15,15 @@ def _test_provider() -> LLMProvider: def _custoai_provider() -> CustoAIProvider: - if not settings.HUB_CUSTOAI_API_KEY: - raise ImproperlyConfigured("HUB_CUSTOAI_API_KEY is required for CustoAI") - if not settings.HUB_CUSTOAI_MODEL: - raise ImproperlyConfigured("HUB_CUSTOAI_MODEL is required for CustoAI") + if not settings.CUS_CUSTOAI_API_KEY: + raise ProviderError("CUS_CUSTOAI_API_KEY is required for CustoAI") + if not settings.CUS_CUSTOAI_MODEL: + raise ProviderError("CUS_CUSTOAI_MODEL is required for CustoAI") return CustoAIProvider( - api_key=settings.HUB_CUSTOAI_API_KEY, - base_url=settings.HUB_CUSTOAI_BASE_URL, - model=settings.HUB_CUSTOAI_MODEL, - timeout=settings.HUB_AI_REQUEST_TIMEOUT, + api_key=settings.CUS_CUSTOAI_API_KEY, + base_url=settings.CUS_CUSTOAI_BASE_URL, + model=settings.CUS_CUSTOAI_MODEL, + timeout=settings.CUS_AI_REQUEST_TIMEOUT, ) @@ -34,7 +34,7 @@ def get_provider(*, channel=None) -> LLMProvider: requests always use CustoAI's platform credential; BYOK requests always use the integration linked to the channel. There is no fallback between modes. """ - if settings.HUB_AI_PROVIDER == "test": + if settings.CUS_AI_PROVIDER == "test": return _test_provider() if channel is None: @@ -45,4 +45,4 @@ def get_provider(*, channel=None) -> LLMProvider: return _custoai_provider() if mode == CredentialMode.BYOK: return routing.resolve_provider(channel) - raise ImproperlyConfigured(f"Unknown AI credential mode: {mode}") + raise ProviderError(f"Unknown AI credential mode: {mode}") diff --git a/apps/backend/hub_platform/ai/provider/routing.py b/apps/backend/hub_platform/ai/provider/routing.py index a76b4a9..3a99e1d 100644 --- a/apps/backend/hub_platform/ai/provider/routing.py +++ b/apps/backend/hub_platform/ai/provider/routing.py @@ -68,15 +68,15 @@ def _provider_from_integration(integration: Integration) -> LLMProvider: if integration.provider == IntegrationProvider.OPENROUTER: return OpenRouterProvider( api_key=integration.secret, - base_url=integration.config.get("base_url") or settings.HUB_OPENROUTER_BASE_URL, - timeout=settings.HUB_AI_REQUEST_TIMEOUT, + base_url=integration.config.get("base_url") or settings.CUS_OPENROUTER_BASE_URL, + timeout=settings.CUS_AI_REQUEST_TIMEOUT, proxy_url=integration.config.get("proxy_url", ""), ) if integration.provider == IntegrationProvider.CUSTOM: return CustomProvider( api_key=integration.secret, base_url=integration.config["base_url"], - timeout=settings.HUB_AI_REQUEST_TIMEOUT, + timeout=settings.CUS_AI_REQUEST_TIMEOUT, proxy_url=integration.config.get("proxy_url", ""), ) raise IntegrationNotConfigured( diff --git a/apps/backend/hub_platform/ai/provider_selection.py b/apps/backend/hub_platform/ai/provider_selection.py index 95b67c0..1ccc007 100644 --- a/apps/backend/hub_platform/ai/provider_selection.py +++ b/apps/backend/hub_platform/ai/provider_selection.py @@ -17,7 +17,7 @@ def configure_agent_provider( if mode not in CredentialMode.values: raise ValidationError({"credentialMode": "Unknown credential mode"}) if mode == CredentialMode.CUSTOAI: - return mode, settings.HUB_CUSTOAI_MODEL + return mode, settings.CUS_CUSTOAI_MODEL if integration_id is None: raise ValidationError({"providerIntegrationId": "BYOK integration is required"}) try: diff --git a/apps/backend/hub_platform/ai/retrieval.py b/apps/backend/hub_platform/ai/retrieval.py index 87c6881..b8059a2 100644 --- a/apps/backend/hub_platform/ai/retrieval.py +++ b/apps/backend/hub_platform/ai/retrieval.py @@ -43,7 +43,7 @@ class KnowledgeRetriever: query_vector = embed_texts( channel=agent.channel, texts=[query], - model=settings.HUB_AI_EMBEDDING_MODEL, + model=settings.CUS_AI_EMBEDDING_MODEL, purpose="retrieval_query", )[0].vector semantic = semantic_search(agent, query_vector, limit=limit) diff --git a/apps/backend/hub_platform/ai/test_provider_modes.py b/apps/backend/hub_platform/ai/test_provider_modes.py index 28dd5d5..0442089 100644 --- a/apps/backend/hub_platform/ai/test_provider_modes.py +++ b/apps/backend/hub_platform/ai/test_provider_modes.py @@ -110,7 +110,7 @@ class ProviderModeTests(TestCase): self.assertIsInstance(provider, provider_type) self.assertEqual(model, "runtime-model") - @override_settings(HUB_AI_PROVIDER="") + @override_settings(CUS_AI_PROVIDER="") def test_byok_uses_channel_integration(self) -> None: self._set_mode(CredentialMode.BYOK) integration = self._link_integration() @@ -121,10 +121,10 @@ class ProviderModeTests(TestCase): self.assertEqual(provider.api_key, integration.secret) @override_settings( - HUB_AI_PROVIDER="", - HUB_CUSTOAI_API_KEY="platform-key", - HUB_CUSTOAI_BASE_URL="https://ai.api.cloud.yandex.net/v1", - HUB_CUSTOAI_MODEL="gpt://folder/yandexgpt-5.1/latest", + CUS_AI_PROVIDER="", + CUS_CUSTOAI_API_KEY="platform-key", + CUS_CUSTOAI_BASE_URL="https://ai.api.cloud.yandex.net/v1", + CUS_CUSTOAI_MODEL="gpt://folder/yandexgpt-5.1/latest", ) def test_custoai_uses_platform_credential(self) -> None: from hub_platform.ai.provider.factory import get_provider @@ -135,7 +135,7 @@ class ProviderModeTests(TestCase): self.assertEqual(provider.base_url, "https://ai.api.cloud.yandex.net/v1") self.assertEqual(provider.model, "gpt://folder/yandexgpt-5.1/latest") - @override_settings(HUB_AI_PROVIDER="", HUB_CUSTOAI_API_KEY="platform-key") + @override_settings(CUS_AI_PROVIDER="", CUS_CUSTOAI_API_KEY="platform-key") def test_no_implicit_fallback(self) -> None: from hub_platform.ai.provider.factory import get_provider @@ -166,7 +166,7 @@ class ProviderModeTests(TestCase): chat.call_args.kwargs["model"], "gpt://folder/yandexgpt-5.1/latest" ) - @override_settings(HUB_AI_PROVIDER="") + @override_settings(CUS_AI_PROVIDER="") def test_default_model_read_in_runtime(self) -> None: self._set_mode(CredentialMode.BYOK) self._link_integration(default_model="integration-model") diff --git a/apps/backend/hub_platform/ai/tests.py b/apps/backend/hub_platform/ai/tests.py index f6ad3dc..1607d34 100644 --- a/apps/backend/hub_platform/ai/tests.py +++ b/apps/backend/hub_platform/ai/tests.py @@ -432,7 +432,7 @@ class ProviderFactoryTests(TestCase): self.assertIsInstance(get_provider(), LocalProvider) - @override_settings(DEBUG=False, TESTING=False, HUB_AI_PROVIDER="test") + @override_settings(DEBUG=False, TESTING=False, CUS_AI_PROVIDER="test") def test_local_provider_forbidden_in_production(self) -> None: from django.core.exceptions import ImproperlyConfigured diff --git a/apps/backend/hub_platform/calls/event_handlers.py b/apps/backend/hub_platform/calls/event_handlers.py index 221bee2..ddf1eb2 100644 --- a/apps/backend/hub_platform/calls/event_handlers.py +++ b/apps/backend/hub_platform/calls/event_handlers.py @@ -52,7 +52,7 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non token, token_hash = issue_invite_token() invite.token_hash = token_hash invite.save(update_fields=["token_hash"]) - url = f"{settings.HUB_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}" + url = f"{settings.CUS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}" sent = transports.send_call_invite( call.delivery_connection, chat_id=call.conversation.external_chat_id, diff --git a/apps/backend/hub_platform/calls/maintenance.py b/apps/backend/hub_platform/calls/maintenance.py index e70978c..a762293 100644 --- a/apps/backend/hub_platform/calls/maintenance.py +++ b/apps/backend/hub_platform/calls/maintenance.py @@ -31,7 +31,7 @@ def expire_stale_calls(context) -> int: continue # состояние сменилось между выборкой и переходом # Принятый звонок без установленного соединения дольше grace period — FAILED. - connect_deadline = now - timedelta(seconds=settings.HUB_CALL_CONNECT_GRACE_SECONDS) + connect_deadline = now - timedelta(seconds=settings.CUS_CALL_CONNECT_GRACE_SECONDS) stuck = CallSession.objects.filter( organization=context.organization, status__in=[CallStatus.ACCEPTED, CallStatus.CONNECTING], @@ -51,7 +51,7 @@ def expire_stale_calls(context) -> int: # Активный звонок с участником, не восстановившимся после обрыва (SPEC §5: # временный обрыв → reconnecting, после grace period — FAILED). - reconnect_deadline = now - timedelta(seconds=settings.HUB_CALL_RECONNECT_GRACE_SECONDS) + reconnect_deadline = now - timedelta(seconds=settings.CUS_CALL_RECONNECT_GRACE_SECONDS) dropped = ( CallSession.objects.filter( organization=context.organization, diff --git a/apps/backend/hub_platform/calls/serializers.py b/apps/backend/hub_platform/calls/serializers.py index c68a508..9730e25 100644 --- a/apps/backend/hub_platform/calls/serializers.py +++ b/apps/backend/hub_platform/calls/serializers.py @@ -65,13 +65,13 @@ def ice_servers_payload() -> list[dict]: # fallback с краткоживущими credentials. Генерируется на каждый запрос токена, # поэтому клиент всегда получает не истёкшие TURN credentials. servers: list[dict] = [] - if settings.HUB_CALL_STUN_URLS: - servers.append({"urls": list(settings.HUB_CALL_STUN_URLS)}) - if settings.HUB_CALL_TURN_URLS and settings.HUB_CALL_TURN_SECRET: + if settings.CUS_CALL_STUN_URLS: + servers.append({"urls": list(settings.CUS_CALL_STUN_URLS)}) + if settings.CUS_CALL_TURN_URLS and settings.CUS_CALL_TURN_SECRET: username, credential = turn_credentials() servers.append( { - "urls": list(settings.HUB_CALL_TURN_URLS), + "urls": list(settings.CUS_CALL_TURN_URLS), "username": username, "credential": credential, } diff --git a/apps/backend/hub_platform/calls/services.py b/apps/backend/hub_platform/calls/services.py index c2ab2e3..2a95232 100644 --- a/apps/backend/hub_platform/calls/services.py +++ b/apps/backend/hub_platform/calls/services.py @@ -149,7 +149,7 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea call_session=call, connection_identity=identity, token_hash=token_hash, - expires_at=timezone.now() + timedelta(seconds=settings.HUB_CALL_INVITE_TTL_SECONDS), + expires_at=timezone.now() + timedelta(seconds=settings.CUS_CALL_INVITE_TTL_SECONDS), ) CallParticipant.objects.bulk_create( [ @@ -174,7 +174,7 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea context=context, quota_key=QuotaKey.CONCURRENT_P2P_CALLS, idempotency_key=f"p2p:{call.id}", - lease_seconds=settings.HUB_CONCURRENT_CALL_LEASE_SECONDS, + lease_seconds=settings.CUS_CONCURRENT_CALL_LEASE_SECONDS, source="calls.session_created", aggregate_type="CallSession", aggregate_id=str(call.id), diff --git a/apps/backend/hub_platform/calls/tests/test_tokens.py b/apps/backend/hub_platform/calls/tests/test_tokens.py index d7c4c5f..6e8318d 100644 --- a/apps/backend/hub_platform/calls/tests/test_tokens.py +++ b/apps/backend/hub_platform/calls/tests/test_tokens.py @@ -78,7 +78,7 @@ class AccessTokenTests(CallTestCase): with self.assertRaises(CallTokenError): verify_call_access_token("%%%.$$$") - @override_settings(HUB_CALL_ACCESS_TTL_SECONDS=-1) + @override_settings(CUS_CALL_ACCESS_TTL_SECONDS=-1) def test_expired_access_token_is_rejected(self) -> None: token = issue_call_access_token( call_session_id=uuid.uuid4(), diff --git a/apps/backend/hub_platform/calls/tests/test_turn.py b/apps/backend/hub_platform/calls/tests/test_turn.py index 8162a53..19d3859 100644 --- a/apps/backend/hub_platform/calls/tests/test_turn.py +++ b/apps/backend/hub_platform/calls/tests/test_turn.py @@ -18,7 +18,7 @@ def _expected_credential(username: str, secret: str = SECRET) -> str: return base64.b64encode(digest).decode("ascii") -@override_settings(HUB_CALL_TURN_SECRET=SECRET, HUB_CALL_TURN_TTL_SECONDS=3600) +@override_settings(CUS_CALL_TURN_SECRET=SECRET, CUS_CALL_TURN_TTL_SECONDS=3600) class TurnCredentialsTests(SimpleTestCase): def test_username_encodes_expiry_and_label(self) -> None: before = int(time.time()) @@ -36,26 +36,26 @@ class TurnCredentialsTests(SimpleTestCase): def test_credential_rotates_with_secret(self) -> None: _u, credential = turn_credentials(now=1_700_000_000) - with override_settings(HUB_CALL_TURN_SECRET="other-secret"): + with override_settings(CUS_CALL_TURN_SECRET="other-secret"): _u2, other = turn_credentials(now=1_700_000_000) self.assertNotEqual(credential, other) class IceServersPayloadTests(SimpleTestCase): - @override_settings(HUB_CALL_STUN_URLS=[], HUB_CALL_TURN_URLS=[], HUB_CALL_TURN_SECRET="") + @override_settings(CUS_CALL_STUN_URLS=[], CUS_CALL_TURN_URLS=[], CUS_CALL_TURN_SECRET="") def test_empty_without_configuration(self) -> None: self.assertEqual(ice_servers_payload(), []) - @override_settings(HUB_CALL_STUN_URLS=STUN_URLS, HUB_CALL_TURN_URLS=[], HUB_CALL_TURN_SECRET="") + @override_settings(CUS_CALL_STUN_URLS=STUN_URLS, CUS_CALL_TURN_URLS=[], CUS_CALL_TURN_SECRET="") def test_stun_only(self) -> None: servers = ice_servers_payload() self.assertEqual(servers, [{"urls": STUN_URLS}]) @override_settings( - HUB_CALL_STUN_URLS=STUN_URLS, - HUB_CALL_TURN_URLS=TURN_URLS, - HUB_CALL_TURN_SECRET=SECRET, - HUB_CALL_TURN_TTL_SECONDS=3600, + CUS_CALL_STUN_URLS=STUN_URLS, + CUS_CALL_TURN_URLS=TURN_URLS, + CUS_CALL_TURN_SECRET=SECRET, + CUS_CALL_TURN_TTL_SECONDS=3600, ) def test_direct_first_then_turn_fallback(self) -> None: servers = ice_servers_payload() @@ -67,7 +67,7 @@ class IceServersPayloadTests(SimpleTestCase): self.assertIn(":hub", turn["username"]) self.assertEqual(turn["credential"], _expected_credential(turn["username"])) - @override_settings(HUB_CALL_STUN_URLS=[], HUB_CALL_TURN_URLS=TURN_URLS, HUB_CALL_TURN_SECRET="") + @override_settings(CUS_CALL_STUN_URLS=[], CUS_CALL_TURN_URLS=TURN_URLS, CUS_CALL_TURN_SECRET="") def test_turn_urls_without_secret_are_not_exposed(self) -> None: # Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе. self.assertEqual(ice_servers_payload(), []) diff --git a/apps/backend/hub_platform/calls/tokens.py b/apps/backend/hub_platform/calls/tokens.py index 91af63a..b78ecdf 100644 --- a/apps/backend/hub_platform/calls/tokens.py +++ b/apps/backend/hub_platform/calls/tokens.py @@ -74,7 +74,7 @@ def issue_call_access_token(*, call_session_id: uuid.UUID, side: str, subject_id "side": side, "subject_id": str(subject_id), "iat": now, - "exp": now + settings.HUB_CALL_ACCESS_TTL_SECONDS, + "exp": now + settings.CUS_CALL_ACCESS_TTL_SECONDS, "jti": secrets.token_urlsafe(16), } encoded = _b64encode(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")) diff --git a/apps/backend/hub_platform/calls/turn.py b/apps/backend/hub_platform/calls/turn.py index 8027c29..f91ed3c 100644 --- a/apps/backend/hub_platform/calls/turn.py +++ b/apps/backend/hub_platform/calls/turn.py @@ -16,14 +16,14 @@ def turn_credentials(*, label: str = "hub", now: int | None = None) -> tuple[str credential = base64(HMAC-SHA1(static-auth-secret, username)) Coturn принимает пару, пока не истёк expiry в username и подпись совпадает с - его `static-auth-secret`. Секрет (`HUB_CALL_TURN_SECRET`) общий с сервисом + его `static-auth-secret`. Секрет (`CUS_CALL_TURN_SECRET`) общий с сервисом coturn и наружу не отдаётся — клиент получает только производные credentials. """ moment = int(time.time()) if now is None else int(now) - expiry = moment + settings.HUB_CALL_TURN_TTL_SECONDS + expiry = moment + settings.CUS_CALL_TURN_TTL_SECONDS username = f"{expiry}:{label}" digest = hmac.new( - settings.HUB_CALL_TURN_SECRET.encode("utf-8"), + settings.CUS_CALL_TURN_SECRET.encode("utf-8"), username.encode("utf-8"), hashlib.sha1, ).digest() diff --git a/apps/backend/hub_platform/conversations/command.py b/apps/backend/hub_platform/conversations/command.py index 2ca3ac5..73bdf02 100644 --- a/apps/backend/hub_platform/conversations/command.py +++ b/apps/backend/hub_platform/conversations/command.py @@ -158,7 +158,7 @@ def command_center_overview(context: TenantContext, period: str) -> dict: "spendMicros": ai_totals["cost"] or 0, # Дневной лимит стоимости (USD micros); 0 = не задан. Прогресс-бар # осмыслен только для периода «Сегодня». - "dailyLimitMicros": int(getattr(settings, "HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", 0) or 0), + "dailyLimitMicros": int(getattr(settings, "CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", 0) or 0), "tokens": ai_totals["tokens"] or 0, "dialogs": period_dialogs, }, diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index b467e1c..654a3f8 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -12,6 +12,7 @@ import logging from django.db import IntegrityError, transaction from django.utils import timezone +from hub_platform.ai.credits import ManagedAiQuotaExceeded from hub_platform.ai.limits import LimitExceeded from hub_platform.ai.provider.base import ProviderError from hub_platform.ai.runtime import HANDOFF_TOKEN @@ -31,7 +32,7 @@ from hub_platform.conversations.models import ( from hub_platform.conversations.transports.base import InboundMessage from hub_platform.events.models import EventOwnership, InboxEvent from hub_platform.notifications.models import NotificationAudience, NotificationType -from hub_platform.notifications.services import notify +from hub_platform.notifications.services import notify, notify_management from hub_platform.subscriptions.errors import EntitlementRequired from hub_platform.subscriptions.keys import QuotaKey from hub_platform.subscriptions.usage_service import record_usage @@ -196,7 +197,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: try: result = run_channel_turn(channel=channel, message=inbound.text, history=_history(conversation)) - except (ProviderError, LimitExceeded, EntitlementRequired) as error: + except ( + ProviderError, + ManagedAiQuotaExceeded, + LimitExceeded, + EntitlementRequired, + ) as error: # Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны # «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и # отвечаем клиенту понятным fallback. @@ -220,6 +226,16 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: source_id=conversation.id, dedup_key=f"aifail:{conversation.id}", ) + notify_management( + context=context, + type=NotificationType.INTEGRATION_ERROR, + title=f"Ошибка AI · {channel.name}", + body="AI временно недоступен, диалог передан оператору", + target_id=conversation.id, + source_type="Conversation", + source_id=conversation.id, + dedup_key=f"aierror:{conversation.id}", + ) transports.send_reply(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=fallback) return diff --git a/apps/backend/hub_platform/conversations/tests.py b/apps/backend/hub_platform/conversations/tests.py index dc1b6c9..b30e9e7 100644 --- a/apps/backend/hub_platform/conversations/tests.py +++ b/apps/backend/hub_platform/conversations/tests.py @@ -2,9 +2,10 @@ import json from unittest import mock -from django.test import TestCase +from django.test import TestCase, override_settings from hub_platform.testing import TenantAPIClient as APIClient +from hub_platform.ai.credits import ManagedAiQuotaExceeded from hub_platform.ai.limits import LimitExceeded from hub_platform.ai.models import AIAgent, AIAgentStatus from hub_platform.channels.models import Channel @@ -22,8 +23,14 @@ from hub_platform.conversations.transports.base import InboundMessage from hub_platform.conversations.transports import max as max_transport from hub_platform.conversations.transports import telegram as telegram_transport from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.identity.models import Organization +from hub_platform.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider +from hub_platform.notifications.models import Notification, NotificationAudience, NotificationType def _messenger_connection(channel): @@ -366,6 +373,14 @@ class WebchatContactTests(TestCase): HTTP_AUTHORIZATION=f"Bearer {self.token}", ) + def _post_message(self, text: str): + return self.client.post( + "/api/v1/webchat/messages/", + data=json.dumps({"text": text}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + def test_contact_saved_and_ack_visible_in_poll(self) -> None: response = self._post_contact("+7 (999) 123-45-67") self.assertEqual(response.status_code, 201) @@ -379,3 +394,63 @@ class WebchatContactTests(TestCase): def test_invalid_phone_rejected(self) -> None: response = self._post_contact("12345") self.assertEqual(response.status_code, 400) + + @override_settings(CUS_AI_PROVIDER="", CUS_CUSTOAI_API_KEY="") + def test_missing_managed_provider_hands_off_without_500_and_notifies_management( + self, + ) -> None: + admin = HumanUser.objects.create_user(email="admin@edevs.tech", password="temporary") + OrganizationMembership.objects.create( + user=admin, + organization=self.organization, + role=EmployeeRole.ADMIN, + position_title="Администратор", + ) + + response = self._post_message("Здравствуйте") + + self.assertEqual(response.status_code, 201) + conversation = Conversation.objects.get(channel=self.channel) + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR) + self.assertTrue( + conversation.messages.filter( + author_type=MessageAuthor.AI, + text__contains="специалисту", + ).exists() + ) + self.assertTrue( + Notification.objects.filter( + type=NotificationType.DIALOG_WAITING, + audience=NotificationAudience.OPERATORS, + target_id=str(conversation.id), + ).exists() + ) + self.assertEqual( + set( + Notification.objects.filter( + type=NotificationType.INTEGRATION_ERROR, + audience=NotificationAudience.USER, + target_id=str(conversation.id), + ).values_list("recipient_user__email", flat=True) + ), + {"owner@edevs.tech", "admin@edevs.tech"}, + ) + + def test_managed_quota_exhaustion_hands_off_without_500(self) -> None: + with mock.patch( + "hub_platform.conversations.ingest.run_channel_turn", + side_effect=ManagedAiQuotaExceeded(), + ): + response = self._post_message("Здравствуйте") + + self.assertEqual(response.status_code, 201) + conversation = Conversation.objects.get(channel=self.channel) + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR) + self.assertTrue( + conversation.messages.filter( + author_type=MessageAuthor.AI, + text__contains="специалисту", + ).exists() + ) diff --git a/apps/backend/hub_platform/conversations/transports/base.py b/apps/backend/hub_platform/conversations/transports/base.py index 88f3315..dc873bc 100644 --- a/apps/backend/hub_platform/conversations/transports/base.py +++ b/apps/backend/hub_platform/conversations/transports/base.py @@ -29,7 +29,7 @@ class InboundMessage: def request_json(url: str, *, headers: dict | None = None, method: str = "GET", body: dict | None = None, proxy_url: str = "") -> dict: data = json.dumps(body).encode("utf-8") if body is not None else None request = urllib.request.Request(url, data=data, headers=headers or {}, method=method) - with build_opener(proxy_url).open(request, timeout=settings.HUB_AI_REQUEST_TIMEOUT) as response: + with build_opener(proxy_url).open(request, timeout=settings.CUS_AI_REQUEST_TIMEOUT) as response: raw = response.read().decode("utf-8") return json.loads(raw) if raw else {} diff --git a/apps/backend/hub_platform/conversations/transports/max.py b/apps/backend/hub_platform/conversations/transports/max.py index 22cbdda..6e55d46 100644 --- a/apps/backend/hub_platform/conversations/transports/max.py +++ b/apps/backend/hub_platform/conversations/transports/max.py @@ -102,7 +102,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]: token = integration.secret if not token: return [], integration.poll_marker - params = {"timeout": settings.HUB_MESSENGER_POLL_TIMEOUT_SECONDS, "limit": 100} + params = {"timeout": settings.CUS_MESSENGER_POLL_TIMEOUT_SECONDS, "limit": 100} if integration.poll_marker: params["marker"] = integration.poll_marker url = f"{_base(integration)}/updates?{urllib.parse.urlencode(params)}" diff --git a/apps/backend/hub_platform/conversations/transports/telegram.py b/apps/backend/hub_platform/conversations/transports/telegram.py index 349b4d3..35a46b9 100644 --- a/apps/backend/hub_platform/conversations/transports/telegram.py +++ b/apps/backend/hub_platform/conversations/transports/telegram.py @@ -54,7 +54,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]: if not token: return [], integration.poll_marker offset = integration.poll_marker or "" - url = f"{_base(integration)}/bot{token}/getUpdates?timeout={settings.HUB_MESSENGER_POLL_TIMEOUT_SECONDS}&limit=100" + url = f"{_base(integration)}/bot{token}/getUpdates?timeout={settings.CUS_MESSENGER_POLL_TIMEOUT_SECONDS}&limit=100" if offset: url += f"&offset={offset}" try: diff --git a/apps/backend/hub_platform/health/views.py b/apps/backend/hub_platform/health/views.py index 0465932..4b71e07 100644 --- a/apps/backend/hub_platform/health/views.py +++ b/apps/backend/hub_platform/health/views.py @@ -5,7 +5,7 @@ from django.http import JsonResponse def live(request): - surface = settings.HUB_RUNTIME_SURFACE + surface = settings.CUS_RUNTIME_SURFACE return JsonResponse( {"status": "ok", "service": f"custocrm-{surface}", "surface": surface} ) diff --git a/apps/backend/hub_platform/http/middleware.py b/apps/backend/hub_platform/http/middleware.py index d65db9c..68b50c2 100644 --- a/apps/backend/hub_platform/http/middleware.py +++ b/apps/backend/hub_platform/http/middleware.py @@ -35,7 +35,7 @@ class ContentSecurityPolicyMiddleware: def __call__(self, request: HttpRequest) -> HttpResponse: response = self.get_response(request) - policy = settings.HUB_CONTENT_SECURITY_POLICY + policy = settings.CUS_CONTENT_SECURITY_POLICY if policy and not response.has_header("Content-Security-Policy"): response["Content-Security-Policy"] = policy return response diff --git a/apps/backend/hub_platform/http/tests.py b/apps/backend/hub_platform/http/tests.py index 6be7651..268564a 100644 --- a/apps/backend/hub_platform/http/tests.py +++ b/apps/backend/hub_platform/http/tests.py @@ -22,13 +22,13 @@ class LocalCorsMiddlewareTests(TestCase): class ContentSecurityPolicyMiddlewareTests(TestCase): - @override_settings(HUB_CONTENT_SECURITY_POLICY="default-src 'none'") + @override_settings(CUS_CONTENT_SECURITY_POLICY="default-src 'none'") def test_surface_policy_is_applied(self) -> None: response = self.client.get("/api/v1/health/live/") self.assertEqual(response["Content-Security-Policy"], "default-src 'none'") - @override_settings(HUB_CONTENT_SECURITY_POLICY="") + @override_settings(CUS_CONTENT_SECURITY_POLICY="") def test_empty_policy_does_not_add_header(self) -> None: response = self.client.get("/api/v1/health/live/") diff --git a/apps/backend/hub_platform/identity/crypto.py b/apps/backend/hub_platform/identity/crypto.py index 9b75910..1e01513 100644 --- a/apps/backend/hub_platform/identity/crypto.py +++ b/apps/backend/hub_platform/identity/crypto.py @@ -9,7 +9,7 @@ from django.db import models @lru_cache(maxsize=1) def _fernet() -> Fernet: - configured = getattr(settings, "HUB_FIELD_ENCRYPTION_KEY", "") + configured = getattr(settings, "CUS_FIELD_ENCRYPTION_KEY", "") if configured: return Fernet(configured.encode() if isinstance(configured, str) else configured) # Dev/тестовый фолбэк: детерминированный ключ из SECRET_KEY (в production задаётся отдельно). diff --git a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py index fb4781c..0da11e5 100644 --- a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py +++ b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py @@ -111,8 +111,8 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor owner = existing_owner.user else: raise CommandError( - "OWNER is required for the first production seed. Set HUB_SEED_OWNER_EMAIL and " - "HUB_SEED_OWNER_PASSWORD, or pass --owner-email and --owner-password." + "OWNER is required for the first production seed. Set CUS_SEED_OWNER_EMAIL and " + "CUS_SEED_OWNER_PASSWORD, or pass --owner-email and --owner-password." ) record_audit_event( @@ -196,15 +196,15 @@ class Command(BaseCommand): )) return - owner_email = _option_or_env(options, "owner_email", "HUB_SEED_OWNER_EMAIL") - owner_password = _option_or_env(options, "owner_password", "HUB_SEED_OWNER_PASSWORD") - owner_name = _option_or_env(options, "owner_name", "HUB_SEED_OWNER_NAME") + owner_email = _option_or_env(options, "owner_email", "CUS_SEED_OWNER_EMAIL") + owner_password = _option_or_env(options, "owner_password", "CUS_SEED_OWNER_PASSWORD") + owner_name = _option_or_env(options, "owner_name", "CUS_SEED_OWNER_NAME") if ( owner_email and not owner_password and not Organization.objects.filter(memberships__role=EmployeeRole.OWNER).exists() ): - raise CommandError("HUB_SEED_OWNER_PASSWORD is required when creating the first OWNER.") + raise CommandError("CUS_SEED_OWNER_PASSWORD is required when creating the first OWNER.") core = _seed_core( owner_email=owner_email, owner_password=owner_password, owner_name=owner_name diff --git a/apps/backend/hub_platform/integrations/checks.py b/apps/backend/hub_platform/integrations/checks.py index 2e2a134..004c39a 100644 --- a/apps/backend/hub_platform/integrations/checks.py +++ b/apps/backend/hub_platform/integrations/checks.py @@ -34,7 +34,7 @@ CheckResult = tuple[bool, str, dict] def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = "") -> tuple[int, dict]: opener = build_opener(proxy_url) request = urllib.request.Request(url, headers=headers or {}, method="GET") - with opener.open(request, timeout=settings.HUB_AI_REQUEST_TIMEOUT) as response: + with opener.open(request, timeout=settings.CUS_AI_REQUEST_TIMEOUT) as response: body = response.read().decode("utf-8") try: data = json.loads(body) if body else {} diff --git a/apps/backend/hub_platform/notifications/services.py b/apps/backend/hub_platform/notifications/services.py index f13a490..f8dd9dc 100644 --- a/apps/backend/hub_platform/notifications/services.py +++ b/apps/backend/hub_platform/notifications/services.py @@ -3,9 +3,11 @@ from datetime import timedelta from django.utils import timezone from hub_platform.events.services import DomainEvent, enqueue_event +from hub_platform.identity.models import EmployeeRole, OrganizationMembership from hub_platform.notifications.delivery import NOTIFICATION_CREATED from hub_platform.notifications.models import ( Notification, + NotificationAudience, NotificationLevel, NotificationRead, NotificationType, @@ -77,6 +79,28 @@ def notify( return notification +def notify_management(*, context, dedup_key: str = "", **notification_data) -> int: + """Create direct notifications for every active OWNER and ADMIN.""" + + memberships = OrganizationMembership.objects.filter( + organization=context.organization, + role__in=[EmployeeRole.OWNER, EmployeeRole.ADMIN], + blocked_at__isnull=True, + user__is_active=True, + ).select_related("user") + created = 0 + for membership in memberships: + notification = notify( + context=context, + audience=NotificationAudience.USER, + recipient_user=membership.user, + dedup_key=(f"{dedup_key}:user:{membership.user_id}" if dedup_key else ""), + **notification_data, + ) + created += notification is not None + return created + + def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False) -> int: queryset = unread_for(context) if not all_unread: diff --git a/apps/backend/hub_platform/support/messages.py b/apps/backend/hub_platform/support/messages.py index 0d0223e..ec30cd4 100644 --- a/apps/backend/hub_platform/support/messages.py +++ b/apps/backend/hub_platform/support/messages.py @@ -12,6 +12,8 @@ import logging from django.db import transaction from django.utils import timezone +from hub_platform.ai.credits import ManagedAiQuotaExceeded +from hub_platform.ai.limits import LimitExceeded from hub_platform.ai.provider.base import ProviderError from hub_platform.ai.runtime import HANDOFF_TOKEN from hub_platform.channels.runtime import run_channel_turn @@ -23,7 +25,8 @@ from hub_platform.conversations.models import ( MessageAuthor, ) from hub_platform.notifications.models import NotificationAudience, NotificationType -from hub_platform.notifications.services import notify +from hub_platform.notifications.services import notify, notify_management +from hub_platform.subscriptions.errors import EntitlementRequired from hub_platform.tenancy.context import TenantContext logger = logging.getLogger(__name__) @@ -96,7 +99,12 @@ def post_support_message( result = run_channel_turn( channel=conversation.channel, message=text, history=_history(conversation) ) - except ProviderError as error: + except ( + ProviderError, + ManagedAiQuotaExceeded, + LimitExceeded, + EntitlementRequired, + ) as error: logger.warning("AI turn failed for support conversation %s: %s", conversation.id, error) conversation.control_mode = ControlMode.PAUSED conversation.expected_responder = ExpectedResponder.OPERATOR @@ -128,6 +136,16 @@ def post_support_message( source_id=conversation.id, dedup_key=f"aifail:{conversation.id}", ) + notify_management( + context=context, + type=NotificationType.INTEGRATION_ERROR, + title=f"Ошибка AI · {conversation.channel.name}", + body="AI временно недоступен, диалог передан оператору", + target_id=conversation.id, + source_type="Conversation", + source_id=conversation.id, + dedup_key=f"aierror:{conversation.id}", + ) return reply = result.text diff --git a/compose.yaml b/compose.yaml index b4a5721..74aa2f4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -57,7 +57,7 @@ services: - ${CUSTOCRM_INSTANCE_DIR:-.}/.env command: ["python", "manage.py", "migrate", "--noinput"] environment: - HUB_DB_ROLE: migration + CUS_DB_ROLE: migration restart: "no" depends_on: postgres: @@ -74,10 +74,10 @@ services: sh -c "gunicorn hub_backend.asgi_app:application --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 - --workers $${HUB_GUNICORN_WORKERS:-3} - --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + --workers $${CUS_GUNICORN_WORKERS:-3} + --timeout $${CUS_GUNICORN_TIMEOUT:-60}" environment: - HUB_DB_ROLE: app + CUS_DB_ROLE: app volumes: # Legacy source media retained for the separately approved copy/hash # migration. Production writes use the required S3 backend in C04. @@ -111,10 +111,10 @@ services: sh -c "gunicorn hub_backend.asgi_platform:application --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 - --workers $${HUB_GUNICORN_WORKERS:-3} - --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + --workers $${CUS_GUNICORN_WORKERS:-3} + --timeout $${CUS_GUNICORN_TIMEOUT:-60}" environment: - HUB_DB_ROLE: platform + CUS_DB_ROLE: platform depends_on: init: condition: service_completed_successfully @@ -146,12 +146,12 @@ services: sh -c "gunicorn hub_backend.asgi_admin:application --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 - --workers $${HUB_GUNICORN_WORKERS:-2} - --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + --workers $${CUS_GUNICORN_WORKERS:-2} + --timeout $${CUS_GUNICORN_TIMEOUT:-60}" # Break-glass technical surface only: schema credentials are never used by # public app/platform runtimes and this service remains loopback-only. environment: - HUB_DB_ROLE: migration + CUS_DB_ROLE: migration ports: - "127.0.0.1:${CUSTOCRM_ADMIN_PORT:-18001}:8000" depends_on: @@ -169,7 +169,7 @@ services: - ${CUSTOCRM_INSTANCE_DIR:-.}/.env command: ["python", "manage.py", "run_worker"] environment: - HUB_DB_ROLE: app + CUS_DB_ROLE: app volumes: - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: @@ -217,7 +217,7 @@ services: # Опциональный profile `calls` (ADR-HUB-0028): включается через COMPOSE_PROFILES=calls. # Host networking: relay использует широкий UDP-диапазон и реальный внешний IP, # публикуется напрямую и не проходит через HTTP reverse proxy. credentials - # выдаёт backend по общему HUB_CALL_TURN_SECRET (static-auth-secret). + # выдаёт backend по общему CUS_CALL_TURN_SECRET (static-auth-secret). coturn: profiles: ["calls"] image: ${CUSTOCRM_COTURN_IMAGE:-coturn/coturn:4.6} @@ -229,20 +229,20 @@ services: - --no-cli - --fingerprint - --use-auth-secret - - --static-auth-secret=${HUB_CALL_TURN_SECRET} - - --realm=${HUB_CALL_TURN_REALM} + - --static-auth-secret=${CUS_CALL_TURN_SECRET} + - --realm=${CUS_CALL_TURN_REALM} # Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на # основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web. - - --listening-ip=${HUB_TURN_LISTENING_IP} - - --relay-ip=${HUB_TURN_EXTERNAL_IP} - - --external-ip=${HUB_TURN_EXTERNAL_IP} - - --listening-port=${HUB_TURN_LISTENING_PORT:-3478} + - --listening-ip=${CUS_TURN_LISTENING_IP} + - --relay-ip=${CUS_TURN_EXTERNAL_IP} + - --external-ip=${CUS_TURN_EXTERNAL_IP} + - --listening-port=${CUS_TURN_LISTENING_PORT:-3478} # TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут. - - --tls-listening-port=${HUB_TURN_TLS_PORT:-443} + - --tls-listening-port=${CUS_TURN_TLS_PORT:-443} - --cert=/etc/coturn/certs/fullchain.pem - --pkey=/etc/coturn/certs/privkey.pem - - --min-port=${HUB_TURN_MIN_PORT:-49160} - - --max-port=${HUB_TURN_MAX_PORT:-49200} + - --min-port=${CUS_TURN_MIN_PORT:-49160} + - --max-port=${CUS_TURN_MAX_PORT:-49200} # Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом). - --no-multicast-peers - --no-tcp-relay @@ -257,7 +257,7 @@ services: - ${CUSTOCRM_INSTANCE_DIR:-.}/data/coturn-certs:/etc/coturn/certs:ro healthcheck: # Allocation smoke: STUN binding к собственному listener на выделенном IP. - test: ["CMD", "turnutils_stunclient", "-p", "${HUB_TURN_LISTENING_PORT:-3478}", "${HUB_TURN_LISTENING_IP}"] + test: ["CMD", "turnutils_stunclient", "-p", "${CUS_TURN_LISTENING_PORT:-3478}", "${CUS_TURN_LISTENING_IP}"] interval: 30s timeout: 5s retries: 5 diff --git a/deploy/cli/lib/common.sh b/deploy/cli/lib/common.sh index 1ec5d76..8dd9657 100644 --- a/deploy/cli/lib/common.sh +++ b/deploy/cli/lib/common.sh @@ -101,7 +101,7 @@ verify_release_checksums() { validate_calls_network_boundary() { local web_ip turn_ip web_ip="$(env_get "$(instance_env_file)" CUSTOCRM_WEB_LISTENING_IP)" - turn_ip="$(env_get "$(instance_env_file)" HUB_TURN_LISTENING_IP)" + turn_ip="$(env_get "$(instance_env_file)" CUS_TURN_LISTENING_IP)" [[ -n "$web_ip" ]] || { log_err "CUSTOCRM_WEB_LISTENING_IP is required for calls profile" diff --git a/deploy/cli/lib/doctor.sh b/deploy/cli/lib/doctor.sh index fa853ba..c474240 100644 --- a/deploy/cli/lib/doctor.sh +++ b/deploy/cli/lib/doctor.sh @@ -121,16 +121,16 @@ cmd_doctor() { fi local secret - secret="$(env_get "$(instance_env_file)" HUB_SECRET_KEY)" + secret="$(env_get "$(instance_env_file)" CUS_SECRET_KEY)" if [[ -n "$secret" ]] && [[ "$secret" != "change-me-long-random-secret" ]]; then - _doctor_report 1 "HUB_SECRET_KEY set" + _doctor_report 1 "CUS_SECRET_KEY set" else - _doctor_report 0 "HUB_SECRET_KEY default/empty" + _doctor_report 0 "CUS_SECRET_KEY default/empty" fi if profile_enabled calls; then local missing=0 k - for k in HUB_CALL_TURN_SECRET HUB_CALL_TURN_REALM HUB_TURN_EXTERNAL_IP HUB_TURN_LISTENING_IP; do + for k in CUS_CALL_TURN_SECRET CUS_CALL_TURN_REALM CUS_TURN_EXTERNAL_IP CUS_TURN_LISTENING_IP; do if [[ -z "$(env_get "$(instance_env_file)" "$k")" ]]; then _doctor_report 0 "$k required for calls profile" missing=1 diff --git a/env.example b/env.example index df113dc..fb757fb 100644 --- a/env.example +++ b/env.example @@ -11,38 +11,38 @@ CUSTOCRM_PLATFORM_DOMAIN=platform.example.com CUSTOCRM_ACME_EMAIL=admin@example.com CUSTOCRM_ADMIN_PORT=18001 # IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите -# отдельный публичный IP, отличный от HUB_TURN_LISTENING_IP. +# отдельный публичный IP, отличный от CUS_TURN_LISTENING_IP. CUSTOCRM_WEB_LISTENING_IP=0.0.0.0 # Опциональные profiles: calls (coturn), и в будущем voice. # COMPOSE_PROFILES=calls # --- Django core --- -HUB_ENV=production -HUB_DEBUG=false -HUB_SECRET_KEY=change-me-long-random-secret +CUS_ENV=production +CUS_DEBUG=false +CUS_SECRET_KEY=change-me-long-random-secret # Шифрование секретов в БД (Fernet-ключ): Fernet.generate_key(). -HUB_FIELD_ENCRYPTION_KEY= +CUS_FIELD_ENCRYPTION_KEY= CUSTOCRM_APP_ALLOWED_HOSTS=app.example.com CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=https://app.example.com CUSTOCRM_PLATFORM_ALLOWED_HOSTS=platform.example.com CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS=https://platform.example.com -HUB_CORS_ALLOWED_ORIGINS=https://app.example.com +CUS_CORS_ALLOWED_ORIGINS=https://app.example.com INTERNAL_UI_BASE_URL=https://app.example.com # Host headers для Docker healthchecks должны входить в surface ALLOWED_HOSTS. CUSTOCRM_APP_HEALTHCHECK_HOST=app.example.com CUSTOCRM_PLATFORM_HEALTHCHECK_HOST=platform.example.com -# Транспортная безопасность (вне HUB_DEBUG включается автоматически). -HUB_COOKIE_SECURE=true -HUB_SSL_REDIRECT=true -HUB_HSTS_SECONDS=31536000 -HUB_COOKIE_SAMESITE=Lax +# Транспортная безопасность (вне CUS_DEBUG включается автоматически). +CUS_COOKIE_SECURE=true +CUS_SSL_REDIRECT=true +CUS_HSTS_SECONDS=31536000 +CUS_COOKIE_SAMESITE=Lax # --- PostgreSQL / Redis --- # C04 RLS: три раздельные DB-roles (app/platform/migration). Runtime-роли app и # platform — NOBYPASSRLS, не владельцы tenant-tables; миграции выполняются под -# migration-role (через HUB_DB_ROLE=migration в compose). Пароли СГЕНЕРИРОВАТЬ, +# migration-role (через CUS_DB_ROLE=migration в compose). Пароли СГЕНЕРИРОВАТЬ, # не использовать значения по умолчанию. См. deploy/postgres/init-runtime-roles.sh. POSTGRES_DB=custocrm POSTGRES_USER=custocrm @@ -60,47 +60,47 @@ REDIS_URL=redis://redis:6379/0 # --- Tenant-owned object storage (C04) --- # Production ТРЕБУЕТ S3-compatible backend (settings hard-fails без него вне # debug/testing). Ключи хранятся как organizations/{organization_public_id}/... -# HUB_S3_BUCKET обязателен; endpoint/region/credentials — для провайдера +# CUS_S3_BUCKET обязателен; endpoint/region/credentials — для провайдера # (Yandex Object Storage, MinIO, AWS S3 и т.п.). -HUB_STORAGE_BACKEND=s3 -HUB_S3_BUCKET= -HUB_S3_ENDPOINT_URL= -HUB_S3_REGION= -HUB_S3_ACCESS_KEY= -HUB_S3_SECRET_KEY= -HUB_S3_ADDRESSING_STYLE=path -HUB_S3_URL_EXPIRY_SECONDS=900 +CUS_STORAGE_BACKEND=s3 +CUS_S3_BUCKET= +CUS_S3_ENDPOINT_URL= +CUS_S3_REGION= +CUS_S3_ACCESS_KEY= +CUS_S3_SECRET_KEY= +CUS_S3_ADDRESSING_STYLE=path +CUS_S3_URL_EXPIRY_SECONDS=900 # --- P2P calls (profile calls) --- # Включается только при COMPOSE_PROFILES=calls. Параметры coturn обязательны, # если profile активен. -HUB_CALL_INVITE_TTL_SECONDS=300 -HUB_CALL_ACCESS_TTL_SECONDS=3600 -HUB_CALL_CONNECT_GRACE_SECONDS=120 -HUB_CALL_RECONNECT_GRACE_SECONDS=60 -HUB_CALL_STUN_URLS= +CUS_CALL_INVITE_TTL_SECONDS=300 +CUS_CALL_ACCESS_TTL_SECONDS=3600 +CUS_CALL_CONNECT_GRACE_SECONDS=120 +CUS_CALL_RECONNECT_GRACE_SECONDS=60 +CUS_CALL_STUN_URLS= # Публичные TURN endpoints (через запятую). Пусто -> только STUN/direct. -HUB_CALL_TURN_URLS= +CUS_CALL_TURN_URLS= # Общий static-auth-secret между backend и coturn. -HUB_CALL_TURN_SECRET= -HUB_CALL_TURN_TTL_SECONDS=3600 -HUB_CALL_TURN_REALM=app.example.com +CUS_CALL_TURN_SECRET= +CUS_CALL_TURN_TTL_SECONDS=3600 +CUS_CALL_TURN_REALM=app.example.com # Публичный IP coturn listener/relay. ОТДЕЛЬНЫЙ от web IP, чтобы TURN занял 443. -HUB_TURN_EXTERNAL_IP= -HUB_TURN_LISTENING_IP= -HUB_TURN_LISTENING_PORT=3478 -HUB_TURN_TLS_PORT=443 -HUB_TURN_MIN_PORT=49160 -HUB_TURN_MAX_PORT=49200 +CUS_TURN_EXTERNAL_IP= +CUS_TURN_LISTENING_IP= +CUS_TURN_LISTENING_PORT=3478 +CUS_TURN_TLS_PORT=443 +CUS_TURN_MIN_PORT=49160 +CUS_TURN_MAX_PORT=49200 # --- AI provider --- -HUB_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 -HUB_CUSTOAI_API_KEY= -HUB_CUSTOAI_BASE_URL=https://ai.api.cloud.yandex.net/v1 -HUB_CUSTOAI_MODEL=gpt://b1g89tr9t8iedhnl8pgg/yandexgpt-5.1/latest -HUB_AI_REQUEST_TIMEOUT=30 -HUB_AI_MAX_RETRIES=2 -HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 +CUS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +CUS_CUSTOAI_API_KEY= +CUS_CUSTOAI_BASE_URL=https://ai.api.cloud.yandex.net/v1 +CUS_CUSTOAI_MODEL=gpt://b1g89tr9t8iedhnl8pgg/yandexgpt-5.1/latest +CUS_AI_REQUEST_TIMEOUT=30 +CUS_AI_MAX_RETRIES=2 +CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 # --- Email --- EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend @@ -112,5 +112,5 @@ EMAIL_USE_TLS=true DEFAULT_FROM_EMAIL=CustoCRM # --- Gunicorn --- -HUB_GUNICORN_WORKERS=3 -HUB_GUNICORN_TIMEOUT=60 +CUS_GUNICORN_WORKERS=3 +CUS_GUNICORN_TIMEOUT=60 diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 35babde..e8fdaf7 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -147,8 +147,8 @@ def fake_env(tmp_path: Path): "CUSTOCRM_APP_DOMAIN": "app.test", "CUSTOCRM_PLATFORM_DOMAIN": "platform.test", "CUSTOCRM_ACME_EMAIL": "admin@test", - "HUB_SECRET_KEY": "test-secret-not-default", - "HUB_FIELD_ENCRYPTION_KEY": "", + "CUS_SECRET_KEY": "test-secret-not-default", + "CUS_FIELD_ENCRYPTION_KEY": "", "POSTGRES_DB": "custocrm", "POSTGRES_USER": "custocrm", "POSTGRES_PASSWORD": "pg-secret", diff --git a/tests/cli/test_custocrm_cli.py b/tests/cli/test_custocrm_cli.py index 71b596c..f7bbc2d 100644 --- a/tests/cli/test_custocrm_cli.py +++ b/tests/cli/test_custocrm_cli.py @@ -81,10 +81,10 @@ def test_deploy_includes_coturn_when_calls_profile_active(fake_env): fake_env.write_env( COMPOSE_PROFILES="calls", CUSTOCRM_WEB_LISTENING_IP="203.0.113.10", - HUB_CALL_TURN_SECRET="turn-secret", - HUB_CALL_TURN_REALM="turn.hub.test", - HUB_TURN_EXTERNAL_IP="203.0.113.11", - HUB_TURN_LISTENING_IP="203.0.113.11", + CUS_CALL_TURN_SECRET="turn-secret", + CUS_CALL_TURN_REALM="turn.hub.test", + CUS_TURN_EXTERNAL_IP="203.0.113.11", + CUS_TURN_LISTENING_IP="203.0.113.11", ) fake_env.install_docker() fake_env.install_flock(held=False) @@ -102,10 +102,10 @@ def test_deploy_rejects_shared_web_and_turn_ip(fake_env): fake_env.write_env( COMPOSE_PROFILES="calls", CUSTOCRM_WEB_LISTENING_IP="203.0.113.10", - HUB_CALL_TURN_SECRET="turn-secret", - HUB_CALL_TURN_REALM="turn.hub.test", - HUB_TURN_EXTERNAL_IP="203.0.113.10", - HUB_TURN_LISTENING_IP="203.0.113.10", + CUS_CALL_TURN_SECRET="turn-secret", + CUS_CALL_TURN_REALM="turn.hub.test", + CUS_TURN_EXTERNAL_IP="203.0.113.10", + CUS_TURN_LISTENING_IP="203.0.113.10", ) fake_env.install_docker() fake_env.install_flock(held=False) @@ -191,7 +191,7 @@ def test_doctor_passes_on_valid_env(fake_env): def test_doctor_fails_on_default_secret_and_missing_password(fake_env): fake_env.write_env( - HUB_SECRET_KEY="change-me-long-random-secret", # default -> должно провалиться + CUS_SECRET_KEY="change-me-long-random-secret", # default -> должно провалиться POSTGRES_PASSWORD="", # пусто -> должно провалиться ) fake_env.install_docker() @@ -200,7 +200,7 @@ def test_doctor_fails_on_default_secret_and_missing_password(fake_env): r = _run(fake_env, "doctor") assert r.returncode == 1 assert "POSTGRES_PASSWORD" in r.stderr - assert "HUB_SECRET_KEY" in r.stderr + assert "CUS_SECRET_KEY" in r.stderr def test_doctor_fails_when_acme_email_missing(fake_env):