Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
+132
View File
@@ -0,0 +1,132 @@
# Copy to .env, replace every placeholder, then run: ./scripts/validate-env .env
APP_ENV=production-like
RELEASE_VERSION=change-me-release
LOG_LEVEL=INFO
COMPOSE_PROJECT_NAME=han-chat
# Immutable service images built by their owning modules.
API_BACKEND_IMAGE=han-chat-api-backend:local
MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local
BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local
BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local
KEYCLOAK_IMAGE=han-chat-keycloak:local
# Managed PostgreSQL is external to Compose. All production DSNs must verify TLS.
HAN_PG_HOST=managed-pg.private.example
HAN_PG_PORT=6432
HAN_PG_DATABASE=han_chat
PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem
DATABASE_URL=postgresql+asyncpg://han_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dhan_app
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_local
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync%2Chan_app
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dmessage_safety
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:6432/han_chat?currentSchema=keycloak&sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_DB_USERNAME=keycloak_user
KEYCLOAK_DB_PASSWORD=change-me
PUBLIC_HOST=chat.example.ru
PUBLIC_WEB_URL=https://chat.example.ru
PUBLIC_API_URL=https://chat.example.ru/api
PUBLIC_AUTH_URL=https://chat.example.ru/auth
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
NGINX_TLS_ENABLED=true
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
NGINX_HSTS_MAX_AGE=0
NGINX_CLIENT_MAX_BODY_SIZE=8m
NGINX_RATE_LIMIT_API=60r/m
NGINX_RATE_LIMIT_AUTH=10r/m
NGINX_RATE_LIMIT_PUBLIC=60r/m
NGINX_RATE_LIMIT_POLLING=60r/m
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
NGINX_RATE_LIMIT_BITRIX=120r/m
NGINX_RATE_LIMIT_WS=30r/m
NGINX_MESSAGE_READ_TIMEOUT_SEC=330
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
TRUSTED_PROXY_CIDRS=172.16.0.0/12
BITRIX_FRAME_ANCESTORS=https://*.bitrix24.ru
S3_CONNECT_SRC=https://s3.storage.selcloud.ru
FRONTEND_DEV_PROXY_ENABLED=false
EXPO_DEV_SERVER_URL=http://host.docker.internal:8081
ACME_EMAIL=ops@example.ru
KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth
KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth
KEYCLOAK_REALM=han-chat
KEYCLOAK_AUDIENCE=han-chat-api
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=change-me
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
KEYCLOAK_OTP_HMAC_KEY=change-me
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_ADMIN=bootstrap-admin
KEYCLOAK_ADMIN_PASSWORD=change-me
CURSOR_HMAC_SECRET=change-me
REDIS_API_PASSWORD=change-me
REDIS_SAFETY_PASSWORD=change-me
REDIS_HEALTH_PASSWORD=change-me
REDIS_URL=redis://api_backend:change-me@redis:6379/0
REDIS_REALTIME_URL=redis://api_backend:change-me@redis:6379/1
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:change-me@redis:6379/2
REDIS_MAXMEMORY=384mb
REDIS_EVICTION_POLICY=volatile-lru
MESSAGE_SAFETY_SERVICE_TOKEN=change-me
BITRIX_LOCAL_APP_INTERNAL_TOKEN=change-me
BITRIX_INTERNAL_API_TOKEN=change-me
BITRIX_API_FORWARD_TOKEN=change-me
BITRIX_API_INBOX_TOKEN=change-me
BITRIX_SYNC_SERVICE_TOKEN=change-me
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
MESSAGE_SAFETY_URL=http://message-safety:8080
MESSAGE_SAFETY_POST_TIMEOUT_SEC=5
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2
MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300
MESSAGE_SAFETY_TASK_TTL_SEC=900
MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru
BITRIX_SYNC_CRM_WEBHOOK_URL=change-me
BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC=60
BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC=30
BITRIX_SYNC_CRM_MAX_CONCURRENCY=2
BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50
BITRIX_SYNC_WEBHOOK_TOKEN=change-me
BITRIX_CLIENT_ID=change-me
BITRIX_CLIENT_SECRET=change-me
BITRIX_CONNECTOR_ID=han_mobile_app
BITRIX_CONNECTOR_NAME=HAN Mobile App
BITRIX_OPEN_LINE_ID=8
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
BITRIX_APPLICATION_TOKEN=change-me
# Generate with: python -c "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
BITRIX_TOKEN_ENCRYPTION_KEY=change-me
BITRIX_HTTP_TIMEOUT_SEC=10
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
SELECTEL_S3_ACCESS_KEY=change-me
SELECTEL_S3_SECRET_KEY=change-me
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=change-me
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
OTEL_REMOTE_AUTH_HEADER=change-me
OTEL_TRACES_SAMPLER_ARG=0.10
OTEL_QUEUE_SIZE=10000
+7
View File
@@ -0,0 +1,7 @@
*.sh text eol=lf
scripts/validate-env text eol=lf
Dockerfile text eol=lf
*.conf text eol=lf
*.template text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
+10
View File
@@ -0,0 +1,10 @@
.env
.env.*
!.env.example
*.pem
*.key
*.crt
backups/
__pycache__/
*.pyc
.pytest_cache/
@@ -0,0 +1,8 @@
.env
.git
.mypy_cache
.pytest_cache
.ruff_cache
__pycache__
*.py[cod]
tests
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim AS builder
WORKDIR /build
RUN pip install --no-cache-dir --upgrade pip build
COPY pyproject.toml ./
COPY app ./app
RUN python -m build --wheel
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
WORKDIR /app
COPY --from=builder /build/dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
COPY alembic.ini ./
COPY alembic ./alembic
USER 10001:10001
EXPOSE 8000
ENTRYPOINT ["uvicorn"]
CMD ["app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"]
+68
View File
@@ -0,0 +1,68 @@
# HAN Chat API backend
FastAPI-сервис публичного API, internal Open Lines/settings API, WebSocket realtime,
Message Safety orchestration, S3 lifecycle и PostgreSQL workers.
## Запуск
Python 3.12+:
```bash
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]"
alembic upgrade head
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Миграции выполняются отдельным deployment step. Приложение не применяет DDL при старте.
Workers запускаются независимо:
```bash
han-delivery-worker
han-safety-worker
han-cleanup-worker
```
## Переменные окружения
Сервис читает только инфраструктурные параметры и секреты из корневого `backend/.env`.
Локальный `.env` не коммитится. Business settings создаются миграцией в
`han_app.app_settings`.
Обязательны:
- `APP_ENV`, `API_PORT`, `LOG_LEVEL`, `DATABASE_URL`;
- `REDIS_URL`, `REDIS_REALTIME_URL`;
- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`,
`KEYCLOAK_AUDIENCE`;
- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`,
`MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`,
`MESSAGE_SAFETY_TASK_POLL_MAX_SEC`,
`MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`;
- `BITRIX_LOCAL_APP_BASE_URL`, `BITRIX_LOCAL_APP_INTERNAL_TOKEN`,
`BITRIX_API_INBOX_TOKEN`, `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`,
`BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD`,
`BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC`;
- `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`;
- `SELECTEL_S3_ENDPOINT_URL`, `SELECTEL_S3_BUCKET_DOCUMENTS`,
`SELECTEL_S3_BUCKET_ATTACHMENTS`, `SELECTEL_S3_BUCKET_QUARANTINE`,
`SELECTEL_S3_ACCESS_KEY`, `SELECTEL_S3_SECRET_KEY`;
- `CURSOR_HMAC_SECRET` — случайный секрет не короче 32 байт;
- `OTEL_EXPORTER_OTLP_ENDPOINT` — опциональный endpoint collector.
Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety
не передаются этому контейнеру. В production подключение PostgreSQL должно использовать
TLS, а internal endpoints — быть доступны только из backend-сети.
## Проверки
```bash
ruff check .
ruff format --check .
mypy app
pytest
```
`/health/live` проверяет процесс. `/health/ready` проверяет критические зависимости и
возвращает `503`, если сервис не может безопасно обслуживать protected API.
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
@@ -0,0 +1,52 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.db import Base
from app.settings import get_settings
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True,
version_table_schema="han_app",
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
include_schemas=True,
version_table_schema="han_app",
)
with context.begin_transaction():
context.run_migrations()
else:
asyncio.run(run_async_migrations())
@@ -0,0 +1,153 @@
"""Initial han_app schema, seed and CRM sync triggers.
Revision ID: 0001_initial
Revises:
Create Date: 2026-07-10
"""
from collections.abc import Sequence
from alembic import op
from app.db import Base
revision: str = "0001_initial"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SEED = {
"auth.phone.enabled": ("true", "boolean", True),
"auth.password.enabled": ("false", "boolean", True),
"otp.phone.max_send_attempts_per_24h": ("3", "integer", False),
"otp.phone.min_seconds_between_attempts": ("30", "integer", False),
"operator.call.phone": ("+74999591007", "string", True),
"consent.personal_data.required": ("true", "boolean", True),
"consent.personal_data.document_url": (
"https://www.han0107.ru/privacy/persdata-agree-mobile",
"string",
True,
),
"consent.personal_data.version": ("2026-06-10", "string", True),
"consent.user_agreement.required": ("true", "boolean", True),
"consent.user_agreement.document_url": (
"https://www.han0107.ru/user-agreement",
"string",
True,
),
"consent.user_agreement.version": ("2026-06-10", "string", True),
"consent.marketing.required": ("false", "boolean", True),
"consent.marketing.version": ("2026-06-10", "string", True),
"chat.attachments.allowed_extensions": (
"jpg,jpeg,png,webp,heic,heif,pdf",
"string_list",
True,
),
"chat.attachments.allowed_mime_types": (
"image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf",
"string_list",
True,
),
"chat.attachments.disallowed_extensions": ("svg,doc,docx,xls,xlsx,csv", "string_list", False),
"chat.attachments.max_size_mb": ("5", "integer", True),
"chat.attachments.storage": ("selectel_s3", "string", False),
"chat.attachments.upload_mode": ("presigned_put", "string", False),
"chat.attachments.safety_scan_required": ("true", "boolean", False),
"chat.attachments.presigned_upload_ttl_seconds": ("600", "integer", False),
"rate_limit.message_send.per_user": ("30/minute", "string", False),
"rate_limit.message_send.per_dialog": ("20/minute", "string", False),
"rate_limit.download_url.per_user": ("60/hour", "string", False),
"rate_limit.public_endpoints.per_ip": ("60/minute", "string", False),
"rate_limit.login.per_ip": ("10/minute", "string", False),
"ux.session.idle_timeout_minutes": ("30", "integer", True),
"security.cors.allowed_origins": ("https://tohin.ru", "string_list", False),
"security.public_cache.max_age_seconds": ("3600", "integer", False),
}
def upgrade() -> None:
bind = op.get_bind()
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
op.execute("CREATE SCHEMA IF NOT EXISTS han_app")
Base.metadata.create_all(bind=bind, checkfirst=True)
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user
ON han_app.dialogs(user_id)
WHERE record_status='A'
AND status IN ('open','waiting_for_company','waiting_for_client');
CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact
ON han_app.client_profiles(bitrix_contact_id)
WHERE bitrix_contact_id IS NOT NULL AND record_status='A';
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
RETURNS trigger
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = han_app, pg_temp
AS $$
DECLARE
v_entity_id uuid;
v_task_type text;
v_dedup text;
BEGIN
IF current_setting('han.sync_suppress', true) = 'true' THEN
RETURN NEW;
END IF;
IF TG_TABLE_NAME = 'user_identities' THEN
v_entity_id := NEW.id;
v_task_type := CASE WHEN TG_OP = 'INSERT'
THEN 'contact.map_or_create' ELSE 'contact.update' END;
ELSE
v_entity_id := NEW.user_id;
v_task_type := CASE WHEN TG_OP = 'INSERT'
THEN 'contact.map_or_create' ELSE 'contact.update' END;
END IF;
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
encode(digest(row_to_json(NEW)::text, 'sha256'), 'hex');
INSERT INTO han_app.sync_queue
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
status, attempt_count, next_attempt_at, created_at, updated_at)
VALUES
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
now(), now(), now())
ON CONFLICT (dedup_key) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities;
CREATE TRIGGER trg_identity_contact_sync
AFTER INSERT OR UPDATE OF phone_number, record_status
ON han_app.user_identities
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
CREATE TRIGGER trg_profile_contact_sync
AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone,
foreign_phone, email, record_status
ON han_app.client_profiles
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
"""
)
for key, (value, value_type, public) in SEED.items():
bind.exec_driver_sql(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, record_status, updated_at)
VALUES (%s, %s, %s, %s, 'A', now())
ON CONFLICT (setting_key) DO UPDATE SET
setting_value = EXCLUDED.setting_value,
value_type = EXCLUDED.value_type,
is_public = EXCLUDED.is_public,
record_status = 'A',
updated_at = now()
""",
(key, value, value_type, public),
)
def downgrade() -> None:
raise RuntimeError("Initial data migration is forward-only")
@@ -0,0 +1 @@
"""HAN Chat API backend."""
+106
View File
@@ -0,0 +1,106 @@
import asyncio
import time
from dataclasses import dataclass
from typing import Any
import httpx
import jwt
import phonenumbers
from jwt import ExpiredSignatureError, InvalidTokenError, PyJWK
from app.settings import Settings
class AuthError(Exception):
def __init__(self, code: str = "unauthorized") -> None:
self.code = code
@dataclass(frozen=True, slots=True)
class Principal:
subject: str
phone_number: str | None
claims: dict[str, Any]
class JWKSValidator:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self.settings = settings
self.http = http
self._keys: dict[str, PyJWK] = {}
self._loaded_at = 0.0
self._lock = asyncio.Lock()
async def refresh(self) -> None:
async with self._lock:
discovery_url = (
f"{str(self.settings.keycloak_internal_url).rstrip('/')}"
f"/realms/{self.settings.keycloak_realm}/.well-known/openid-configuration"
)
discovery = (await self.http.get(discovery_url, timeout=3)).raise_for_status().json()
jwks_uri = discovery["jwks_uri"]
public_prefix = str(self.settings.keycloak_public_url).rstrip("/")
internal_prefix = str(self.settings.keycloak_internal_url).rstrip("/")
if jwks_uri.startswith(public_prefix):
jwks_uri = internal_prefix + jwks_uri[len(public_prefix) :]
payload = (await self.http.get(jwks_uri, timeout=3)).raise_for_status().json()
self._keys = {
key["kid"]: PyJWK.from_dict(key)
for key in payload.get("keys", [])
if key.get("kid") and key.get("kty") == "RSA"
}
self._loaded_at = time.monotonic()
async def validate(self, token: str) -> Principal:
try:
header = jwt.get_unverified_header(token)
except InvalidTokenError as exc:
raise AuthError() from exc
if header.get("alg") != "RS256" or not header.get("kid"):
raise AuthError()
kid = str(header["kid"])
stale = time.monotonic() - self._loaded_at > self.settings.jwks_cache_ttl_seconds
if stale or kid not in self._keys:
try:
await self.refresh()
except (httpx.HTTPError, KeyError, ValueError):
if (
kid not in self._keys
or time.monotonic() - self._loaded_at > self.settings.jwks_stale_grace_seconds
):
raise AuthError() from None
key = self._keys.get(kid)
if key is None:
raise AuthError()
try:
claims = jwt.decode(
token,
key.key,
algorithms=["RS256"],
audience=self.settings.keycloak_audience,
issuer=self.settings.issuer,
options={"require": ["exp", "sub"]},
leeway=30,
)
except ExpiredSignatureError as exc:
raise AuthError("token_expired") from exc
except InvalidTokenError as exc:
raise AuthError() from exc
subject = claims.get("sub")
if not isinstance(subject, str) or not subject:
raise AuthError()
return Principal(subject, canonical_phone(claims), claims)
def canonical_phone(claims: dict[str, Any]) -> str | None:
for name in ("phone_number", "preferred_username"):
value = claims.get(name)
if not isinstance(value, str):
continue
try:
number = phonenumbers.parse(value, None)
except phonenumbers.NumberParseException:
continue
if phonenumbers.is_valid_number(number) and value.startswith("+"):
return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
return None
@@ -0,0 +1 @@
"""Operational command-line entry points."""
@@ -0,0 +1,115 @@
from __future__ import annotations
import argparse
import asyncio
from pathlib import Path
from typing import Any
import yaml
from sqlalchemy import func, or_
from sqlalchemy.dialects.postgresql import insert
from app.db import AppSetting, Database
from app.settings import get_settings
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
def load_seed(path: Path) -> list[dict[str, Any]]:
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict) or document.get("schema_version") != 1:
raise ValueError("settings file must have schema_version: 1")
settings = document.get("settings")
if not isinstance(settings, dict) or not settings:
raise ValueError("settings file must contain a non-empty settings mapping")
rows: list[dict[str, Any]] = []
for key, raw in settings.items():
if not isinstance(key, str) or not key.strip():
raise ValueError("setting keys must be non-empty strings")
if not isinstance(raw, dict):
raise ValueError(f"{key}: setting must be a mapping")
value_type = raw.get("type")
if value_type not in VALUE_TYPES:
raise ValueError(f"{key}: unsupported type {value_type!r}")
if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean")
description = raw.get("description")
if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string")
rows.append(
{
"setting_key": key,
"setting_value": serialize_value(key, value_type, raw.get("value")),
"value_type": value_type,
"is_public": raw["public"],
"description": description,
"record_status": "A",
}
)
return rows
def serialize_value(key: str, value_type: str, value: Any) -> str:
if value_type == "boolean":
if not isinstance(value, bool):
raise ValueError(f"{key}: boolean value expected")
return str(value).lower()
if value_type == "integer":
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"{key}: integer value expected")
return str(value)
if value_type == "string_list":
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return ",".join(value)
if isinstance(value, str):
return value
raise ValueError(f"{key}: string or list of strings expected")
if not isinstance(value, str):
raise ValueError(f"{key}: string value expected")
return value
async def seed(path: Path) -> int:
rows = load_seed(path)
database = Database(get_settings().database_url)
try:
async with database.sessions() as session:
for row in rows:
statement = insert(AppSetting).values(**row)
excluded = statement.excluded
statement = statement.on_conflict_do_update(
index_elements=[AppSetting.setting_key],
set_={
"setting_value": excluded.setting_value,
"value_type": excluded.value_type,
"is_public": excluded.is_public,
"description": excluded.description,
"record_status": "A",
"updated_at": func.now(),
},
where=or_(
AppSetting.setting_value.is_distinct_from(excluded.setting_value),
AppSetting.value_type.is_distinct_from(excluded.value_type),
AppSetting.is_public.is_distinct_from(excluded.is_public),
AppSetting.description.is_distinct_from(excluded.description),
AppSetting.record_status != "A",
),
)
await session.execute(statement)
await session.commit()
finally:
await database.close()
return len(rows)
def main() -> None:
parser = argparse.ArgumentParser(description="Idempotently seed application settings")
parser.add_argument("--file", required=True, type=Path)
args = parser.parse_args()
count = asyncio.run(seed(args.file))
print(f"Application settings seeded: {count}")
if __name__ == "__main__":
main()
@@ -0,0 +1,40 @@
from __future__ import annotations
import asyncio
from sqlalchemy import select
from app.db import AppSetting, Database
from app.services import REQUIRED_SETTINGS
from app.settings import get_settings
async def validate() -> int:
database = Database(get_settings().database_url)
try:
async with database.sessions() as session:
active_keys = set(
(
await session.execute(
select(AppSetting.setting_key).where(AppSetting.record_status == "A")
)
)
.scalars()
.all()
)
finally:
await database.close()
missing = sorted(REQUIRED_SETTINGS - active_keys)
if missing:
raise RuntimeError(f"Mandatory application settings are missing: {', '.join(missing)}")
return len(REQUIRED_SETTINGS)
def main() -> None:
count = asyncio.run(validate())
print(f"Mandatory application settings validated: {count}")
if __name__ == "__main__":
main()
+358
View File
@@ -0,0 +1,358 @@
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SCHEMA = "han_app"
class Base(DeclarativeBase):
type_annotation_map = {dict[str, Any]: JSON}
class Common:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
record_status: Mapped[str] = mapped_column(String(1), default="A", server_default="A")
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status_change_reason: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
updater_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
class UserIdentity(Common, Base):
__tablename__ = "user_identities"
__table_args__ = (Index("ix_user_identities_phone", "phone_number"), {"schema": SCHEMA})
keycloak_sub: Mapped[str] = mapped_column(String(255), unique=True)
phone_number: Mapped[str] = mapped_column(String(32))
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class ClientProfile(Common, Base):
__tablename__ = "client_profiles"
__table_args__ = ({"schema": SCHEMA},)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT"), unique=True
)
bitrix_contact_id: Mapped[str | None] = mapped_column(String(64))
full_name: Mapped[str | None] = mapped_column(String(255))
citizenship: Mapped[str | None] = mapped_column(String(128))
russian_phone: Mapped[str | None] = mapped_column(String(32))
foreign_phone: Mapped[str | None] = mapped_column(String(32))
email: Mapped[str | None] = mapped_column(String(320))
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class UserConsent(Common, Base):
__tablename__ = "user_consents"
__table_args__ = (
UniqueConstraint("user_id", "consent_type", "document_version"),
CheckConstraint("consent_type IN ('personal_data','user_agreement','marketing')"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
consent_type: Mapped[str] = mapped_column(String(32))
document_version: Mapped[str] = mapped_column(String(64))
accepted: Mapped[bool] = mapped_column(Boolean)
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
client_ip: Mapped[str | None] = mapped_column(INET)
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
class UxSession(Common, Base):
__tablename__ = "ux_sessions"
__table_args__ = (
CheckConstraint("start_reason IN ('first_launch','cold_start','idle_timeout')"),
Index("ix_ux_sessions_user_started", "user_id", "started_at"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
start_reason: Mapped[str] = mapped_column(String(32))
platform: Mapped[str] = mapped_column(String(32))
app_version: Mapped[str] = mapped_column(String(64))
device_id_hash: Mapped[str | None] = mapped_column(String(64))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class Dialog(Common, Base):
__tablename__ = "dialogs"
__table_args__ = (
CheckConstraint("status IN ('open','waiting_for_company','waiting_for_client','closed')"),
Index("ix_dialogs_user_updated", "user_id", "updated_at", "id"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
status: Mapped[str] = mapped_column(String(32), default="open")
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Message(Common, Base):
__tablename__ = "messages"
__table_args__ = (
CheckConstraint("sender_type IN ('client','company')"),
CheckConstraint("content_kind IN ('text','file')"),
CheckConstraint("safety_status IN ('pending','allowed','blocked','needs_review')"),
CheckConstraint(
"delivery_status IN ('accepted','processing','delivered','rejected','failed')"
),
Index("ix_messages_dialog_created", "dialog_id", "created_at", "id"),
UniqueConstraint("dialog_id", "client_idempotency_key"),
{"schema": SCHEMA},
)
dialog_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.dialogs.id", ondelete="RESTRICT")
)
sender_type: Mapped[str] = mapped_column(String(16))
content_kind: Mapped[str] = mapped_column(String(16))
text: Mapped[str] = mapped_column(Text, default="")
safety_status: Mapped[str] = mapped_column(String(16))
delivery_status: Mapped[str] = mapped_column(String(16))
external_message_id: Mapped[str | None] = mapped_column(String(255))
client_idempotency_key: Mapped[str | None] = mapped_column(String(128))
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class MessageAttachment(Common, Base):
__tablename__ = "message_attachments"
__table_args__ = (
CheckConstraint("direction IN ('client_upload','company_inbound')"),
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
CheckConstraint("size_bytes > 0"),
{"schema": SCHEMA},
)
dialog_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.dialogs.id"))
message_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey(f"{SCHEMA}.messages.id"), unique=True
)
owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
direction: Mapped[str] = mapped_column(String(32))
original_file_name: Mapped[str] = mapped_column(String(255))
safe_file_name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Document(Common, Base):
__tablename__ = "documents"
__table_args__ = (UniqueConstraint("storage_bucket", "object_key"), {"schema": SCHEMA})
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64))
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class SafetyTask(Base):
__tablename__ = "safety_tasks"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_id: Mapped[str] = mapped_column(String(255), unique=True)
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
status: Mapped[str] = mapped_column(String(16))
deadline_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
next_poll_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
last_error_code: Mapped[str | None] = mapped_column(String(64))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DeliveryOutbox(Base):
__tablename__ = "delivery_outbox"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(16), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class IdempotencyRecord(Base):
__tablename__ = "idempotency_records"
__table_args__ = (
UniqueConstraint("scope", "user_id", "idempotency_key"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
scope: Mapped[str] = mapped_column(String(128))
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
idempotency_key: Mapped[str] = mapped_column(String(128))
request_fingerprint: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16))
response_status: Mapped[int | None] = mapped_column(Integer)
response_body_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
resource_type: Mapped[str | None] = mapped_column(String(64))
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class OpenLinesInboxReceipt(Base):
__tablename__ = "openlines_inbox_receipts"
__table_args__ = (
UniqueConstraint("event_id"),
UniqueConstraint("external_chat_id", "bitrix_message_id"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id: Mapped[str] = mapped_column(String(255))
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
event_type: Mapped[str] = mapped_column(String(32))
payload_fingerprint: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16))
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AuditEvent(Base):
__tablename__ = "audit_events"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_type: Mapped[str] = mapped_column(String(128))
actor_type: Mapped[str] = mapped_column(String(32))
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
request_id: Mapped[str] = mapped_column(String(64))
trace_id: Mapped[str | None] = mapped_column(String(64))
resource_type: Mapped[str | None] = mapped_column(String(64))
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
ip: Mapped[str | None] = mapped_column(INET)
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
outcome: Mapped[str] = mapped_column(String(32))
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AppSetting(Base):
__tablename__ = "app_settings"
__table_args__ = ({"schema": SCHEMA},)
setting_key: Mapped[str] = mapped_column(String(255), primary_key=True)
setting_value: Mapped[str] = mapped_column(Text)
value_type: Mapped[str] = mapped_column(String(32))
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str | None] = mapped_column(Text)
record_status: Mapped[str] = mapped_column(String(1), default="A")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TextResource(Common, Base):
__tablename__ = "text_resources"
__table_args__ = (
UniqueConstraint("mnemonic", "locale"),
{"schema": SCHEMA},
)
mnemonic: Mapped[str] = mapped_column(String(255))
locale: Mapped[str] = mapped_column(String(16), default="ru")
text_value: Mapped[str] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class PopularQuestion(Common, Base):
__tablename__ = "popular_questions"
__table_args__ = (
UniqueConstraint("mnemonic", "locale"),
{"schema": SCHEMA},
)
mnemonic: Mapped[str] = mapped_column(String(255))
locale: Mapped[str] = mapped_column(String(16), default="ru")
question_text: Mapped[str] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class SyncQueue(Base):
__tablename__ = "sync_queue"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_type: Mapped[str] = mapped_column(String(64))
entity_type: Mapped[str] = mapped_column(String(64))
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
dedup_key: Mapped[str] = mapped_column(String(255), unique=True)
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(16), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class EntityExternalMapping(Base):
__tablename__ = "entity_external_mapping"
__table_args__ = (UniqueConstraint("entity_type", "entity_id"), {"schema": SCHEMA})
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entity_type: Mapped[str] = mapped_column(String(64))
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
external_id: Mapped[str] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Database:
def __init__(self, url: str) -> None:
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
async with self.sessions() as session:
yield session
async def close(self) -> None:
await self.engine.dispose()
@@ -0,0 +1,338 @@
import asyncio
import hashlib
import ipaddress
import json
import socket
import time
import uuid
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import boto3
import httpx
from botocore.config import Config
from redis.asyncio import Redis
from app.settings import Settings
RATE_LIMIT_LUA = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
local ttl = redis.call('TTL', KEYS[1])
return {current, ttl}
"""
class DependencyFailure(Exception):
def __init__(self, code: str = "dependency_unavailable", timeout: bool = False) -> None:
self.code = code
self.timeout = timeout
@dataclass(slots=True)
class CircuitBreaker:
threshold: int
open_seconds: float
failures: int = 0
opened_at: float | None = None
def allow(self) -> bool:
if self.opened_at is None:
return True
if time.monotonic() - self.opened_at >= self.open_seconds:
self.opened_at = None
self.failures = max(0, self.threshold - 1)
return True
return False
def success(self) -> None:
self.failures = 0
self.opened_at = None
def failure(self) -> None:
self.failures += 1
if self.failures >= self.threshold:
self.opened_at = time.monotonic()
class RateLimiter:
def __init__(self, redis: Redis) -> None:
self.redis = redis
async def consume(self, key: str, limit: int, window: int) -> int:
try:
count, ttl = await self.redis.eval(RATE_LIMIT_LUA, 1, key, window)
except Exception as exc:
raise DependencyFailure() from exc
if int(count) > limit:
return max(1, int(ttl))
return 0
@staticmethod
def key(identity_type: str, identity: str, route: str, window: int) -> str:
safe_identity = hashlib.sha256(identity.encode()).hexdigest()[:32]
bucket = int(time.time()) // window
return f"han:api:rl:{identity_type}:{safe_identity}:{route}:{bucket}"
class RedisIdempotency:
def __init__(self, redis: Redis) -> None:
self.redis = redis
async def get(self, scope: str, user_id: uuid.UUID, key: str) -> dict[str, Any] | None:
key_hash = hashlib.sha256(key.encode()).hexdigest()
raw = await self.redis.get(f"han:api:idem:{scope}:{user_id}:{key_hash}")
return json.loads(raw) if raw else None
async def put(self, scope: str, user_id: uuid.UUID, key: str, value: dict[str, Any]) -> None:
key_hash = hashlib.sha256(key.encode()).hexdigest()
await self.redis.set(
f"han:api:idem:{scope}:{user_id}:{key_hash}",
json.dumps(value, separators=(",", ":"), default=str),
ex=86400,
)
class SafetyClient:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self.settings = settings
self.http = http
self.breaker = CircuitBreaker(
settings.message_safety_circuit_failure_threshold,
settings.message_safety_circuit_open_sec,
)
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
return await self._call(
"POST",
"/internal/safety/v1/messages/check",
request_id,
json=payload,
timeout=self.settings.message_safety_post_timeout_sec,
)
async def poll(self, task_id: str, request_id: str) -> dict[str, Any]:
return await self._call(
"GET",
f"/internal/safety/v1/messages/tasks/{task_id}",
request_id,
timeout=2,
)
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
if not self.breaker.allow():
raise DependencyFailure()
headers = {
"X-Service-Token": self.settings.message_safety_service_token.get_secret_value(),
"X-Request-ID": request_id,
}
try:
response = await self.http.request(
method,
f"{str(self.settings.message_safety_url).rstrip('/')}{path}",
headers=headers,
**kwargs,
)
except httpx.TimeoutException as exc:
self.breaker.failure()
raise DependencyFailure(timeout=True) from exc
except httpx.HTTPError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
if response.status_code == 401 or response.status_code >= 500:
self.breaker.failure()
raise DependencyFailure()
try:
body = response.json()
except ValueError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
if not isinstance(body, dict):
self.breaker.failure()
raise DependencyFailure()
self.breaker.success()
body["_status"] = response.status_code
return body
class OpenLinesClient:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self.settings = settings
self.http = http
self.breaker = CircuitBreaker(
settings.bitrix_local_app_circuit_failure_threshold,
settings.bitrix_local_app_circuit_open_sec,
)
async def send(
self, message_id: uuid.UUID, payload: dict[str, Any], request_id: str
) -> dict[str, Any]:
if not self.breaker.allow():
raise DependencyFailure()
try:
response = await self.http.post(
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
"/internal/openlines/v1/messages",
json=payload,
headers={
"Authorization": "Bearer "
+ self.settings.bitrix_local_app_internal_token.get_secret_value(),
"Idempotency-Key": str(message_id),
"X-Request-ID": request_id,
},
timeout=self.settings.bitrix_local_app_http_timeout_sec,
)
response.raise_for_status()
except httpx.TimeoutException as exc:
self.breaker.failure()
raise DependencyFailure(timeout=True) from exc
except httpx.HTTPError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
self.breaker.success()
return response.json()
async def ready(self) -> bool:
try:
response = await self.http.get(
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
"/internal/openlines/v1/status",
headers={
"Authorization": "Bearer "
+ self.settings.bitrix_local_app_internal_token.get_secret_value()
},
timeout=2,
)
return response.is_success
except httpx.HTTPError:
return False
async def fresh_openlines_payload(
payload: dict[str, Any], s3: "S3Client"
) -> dict[str, Any]:
result = json.loads(json.dumps(payload, default=str))
for file in result.get("message", {}).get("files", []):
bucket = file.pop("_storage_bucket")
key = file.pop("_object_key")
file["download_url"] = await s3.presign_get(bucket, key)
return result
class S3Client:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.client = boto3.client(
"s3",
endpoint_url=str(settings.selectel_s3_endpoint_url),
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
config=Config(connect_timeout=3, read_timeout=10, retries={"max_attempts": 2}),
)
async def ready(self) -> bool:
try:
for bucket in (
self.settings.selectel_s3_bucket_quarantine,
self.settings.selectel_s3_bucket_attachments,
self.settings.selectel_s3_bucket_documents,
):
await asyncio.to_thread(self.client.head_bucket, Bucket=bucket)
return True
except Exception:
return False
async def presign_put(self, key: str, mime: str, ttl: int) -> str:
return await asyncio.to_thread(
self.client.generate_presigned_url,
"put_object",
Params={
"Bucket": self.settings.selectel_s3_bucket_quarantine,
"Key": key,
"ContentType": mime,
},
ExpiresIn=ttl,
)
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
return await asyncio.to_thread(
self.client.generate_presigned_url,
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=ttl,
)
async def head(self, bucket: str, key: str) -> dict[str, Any]:
return await asyncio.to_thread(self.client.head_object, Bucket=bucket, Key=key)
async def promote(self, source_key: str, destination_key: str) -> None:
await asyncio.to_thread(
self.client.copy_object,
Bucket=self.settings.selectel_s3_bucket_attachments,
Key=destination_key,
CopySource={
"Bucket": self.settings.selectel_s3_bucket_quarantine,
"Key": source_key,
},
)
await asyncio.to_thread(
self.client.delete_object,
Bucket=self.settings.selectel_s3_bucket_quarantine,
Key=source_key,
)
async def delete_quarantine(self, key: str) -> None:
await asyncio.to_thread(
self.client.delete_object,
Bucket=self.settings.selectel_s3_bucket_quarantine,
Key=key,
)
async def upload_inbound(
self,
http: httpx.AsyncClient,
download_url: str,
destination_key: str,
mime_type: str,
max_bytes: int,
) -> tuple[int, str]:
parsed = urlparse(download_url)
if parsed.scheme != "https" or not parsed.hostname:
raise DependencyFailure("unsafe_inbound_url")
try:
addresses = await asyncio.to_thread(
socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM
)
except OSError as exc:
raise DependencyFailure("unsafe_inbound_url") from exc
if any(
ipaddress.ip_address(address[4][0]).is_private
or ipaddress.ip_address(address[4][0]).is_loopback
or ipaddress.ip_address(address[4][0]).is_link_local
or ipaddress.ip_address(address[4][0]).is_reserved
for address in addresses
):
raise DependencyFailure("unsafe_inbound_url")
data = bytearray()
try:
async with http.stream(
"GET", download_url, timeout=10, follow_redirects=False
) as response:
response.raise_for_status()
if response.headers.get("content-type", "").split(";")[0] != mime_type:
raise DependencyFailure("inbound_mime_mismatch")
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > max_bytes:
raise DependencyFailure("inbound_file_too_large")
except httpx.HTTPError as exc:
raise DependencyFailure() from exc
await asyncio.to_thread(
self.client.put_object,
Bucket=self.settings.selectel_s3_bucket_attachments,
Key=destination_key,
Body=bytes(data),
ContentType=mime_type,
)
return len(data), hashlib.sha256(data).hexdigest()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
import asyncio
import json
import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from typing import Any
from redis.asyncio import Redis
CHANNEL_PREFIX = "han:realtime:dialog:"
class LocalFanout:
def __init__(self) -> None:
self._queues: set[asyncio.Queue[dict[str, Any]]] = set()
async def publish(self, event: dict[str, Any]) -> None:
for queue in tuple(self._queues):
with suppress(asyncio.QueueFull):
queue.put_nowait(event)
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
self._queues.add(queue)
try:
while True:
yield await queue.get()
finally:
self._queues.discard(queue)
class RealtimeFanout:
def __init__(self, redis: Redis, local: LocalFanout | None = None) -> None:
self.redis = redis
self.local = local or LocalFanout()
async def publish(self, event: dict[str, Any]) -> None:
event = {"event_id": str(uuid.uuid4()), **event}
channel = CHANNEL_PREFIX + str(event["dialog_id"])
try:
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
except Exception:
await self.local.publish(event)
async def events(self, dialog_ids: set[uuid.UUID]) -> AsyncIterator[dict[str, Any]]:
channels = [CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
pubsub = self.redis.pubsub()
try:
await pubsub.subscribe(*channels)
except Exception:
await pubsub.aclose()
async for event in self.local.subscribe():
if uuid.UUID(str(event["dialog_id"])) in dialog_ids:
yield event
return
try:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1)
if message:
yield json.loads(message["data"])
else:
await asyncio.sleep(0)
finally:
await pubsub.unsubscribe(*channels)
await pubsub.aclose()
+148
View File
@@ -0,0 +1,148 @@
import hashlib
import hmac
import json
import uuid
from base64 import urlsafe_b64decode, urlsafe_b64encode
from datetime import datetime
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class Device(StrictModel):
platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64)
device_id: str | None = Field(default=None, max_length=255)
class ConsentChoice(StrictModel):
accepted: bool
version: str = Field(min_length=1, max_length=64)
class ConsentSet(StrictModel):
personal_data: ConsentChoice
user_agreement: ConsentChoice
marketing: ConsentChoice
class BootstrapRequest(StrictModel):
consents: ConsentSet
device: Device
class ConsentsRequest(StrictModel):
consents: ConsentSet
class SessionStartRequest(StrictModel):
start_reason: Literal["first_launch", "cold_start", "idle_timeout"]
device: Device
class TextMessageRequest(StrictModel):
content_kind: Literal["text"]
text: str = Field(min_length=1, max_length=4000)
class FileMessageRequest(StrictModel):
content_kind: Literal["file"]
attachment_id: uuid.UUID
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
MessageRequest = Annotated[
TextMessageRequest | FileMessageRequest, Field(discriminator="content_kind")
]
class AttachmentInitRequest(StrictModel):
file_name: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
class AttachmentCompleteRequest(StrictModel):
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
class OpenLinesFile(StrictModel):
name: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
download_url: HttpUrl
class OpenLinesMessage(StrictModel):
text: str = Field(default="", max_length=4000)
files: list[OpenLinesFile] = Field(default_factory=list, max_length=1)
@model_validator(mode="after")
def non_empty(self) -> "OpenLinesMessage":
if not self.text.strip() and not self.files:
raise ValueError("message must contain text or file")
return self
class OpenLinesInbox(StrictModel):
event_id: str = Field(min_length=1, max_length=255)
event_type: Literal["message.new", "dialog.closed"]
external_chat_id: uuid.UUID
bitrix_message_id: str | None = Field(default=None, max_length=255)
occurred_at: datetime
message: OpenLinesMessage | None = None
@model_validator(mode="after")
def event_shape(self) -> "OpenLinesInbox":
if self.event_type == "message.new" and (
not self.bitrix_message_id or self.message is None
):
raise ValueError("message.new requires bitrix_message_id and message")
return self
class DialogStatus(StrEnum):
OPEN = "open"
WAITING_COMPANY = "waiting_for_company"
WAITING_CLIENT = "waiting_for_client"
CLOSED = "closed"
def canonical_fingerprint(
method: str, route: str, path_params: dict[str, str], body: object, user_id: uuid.UUID
) -> str:
value = {
"method": method.upper(),
"route": route,
"path": dict(sorted(path_params.items())),
"body": body,
"user_id": str(user_id),
}
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
).hexdigest()
def encode_cursor(data: dict[str, str], secret: bytes) -> str:
payload = json.dumps({"v": 1, **data}, sort_keys=True, separators=(",", ":")).encode()
signature = hmac.digest(secret, payload, "sha256")
return urlsafe_b64encode(payload + signature).decode().rstrip("=")
def decode_cursor(value: str, secret: bytes) -> dict[str, str]:
try:
raw = urlsafe_b64decode(value + "=" * (-len(value) % 4))
payload, signature = raw[:-32], raw[-32:]
if not hmac.compare_digest(signature, hmac.digest(secret, payload, "sha256")):
raise ValueError("invalid cursor")
decoded = json.loads(payload)
if decoded.pop("v") != 1:
raise ValueError("unsupported cursor")
return decoded
except (ValueError, KeyError, json.JSONDecodeError) as exc:
raise ValueError("invalid cursor") from exc
@@ -0,0 +1,930 @@
import hashlib
import json
import re
import unicodedata
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import PurePath
from typing import Any
import httpx
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import Principal
from app.db import (
AppSetting,
AuditEvent,
ClientProfile,
DeliveryOutbox,
Dialog,
Document,
IdempotencyRecord,
Message,
MessageAttachment,
OpenLinesInboxReceipt,
SafetyTask,
UserConsent,
UserIdentity,
UxSession,
)
from app.integrations import (
DependencyFailure,
OpenLinesClient,
S3Client,
SafetyClient,
fresh_openlines_payload,
)
from app.realtime import RealtimeFanout
from app.schemas import (
AttachmentCompleteRequest,
AttachmentInitRequest,
BootstrapRequest,
ConsentsRequest,
FileMessageRequest,
MessageRequest,
OpenLinesInbox,
SessionStartRequest,
encode_cursor,
)
from app.settings import Settings
class DomainError(Exception):
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
self.code, self.status, self.message = code, status, message
self.details = details or {}
REQUIRED_SETTINGS = {
"auth.phone.enabled",
"auth.password.enabled",
"otp.phone.max_send_attempts_per_24h",
"otp.phone.min_seconds_between_attempts",
"operator.call.phone",
"consent.personal_data.required",
"consent.personal_data.document_url",
"consent.personal_data.version",
"consent.user_agreement.required",
"consent.user_agreement.document_url",
"consent.user_agreement.version",
"consent.marketing.required",
"consent.marketing.version",
"chat.attachments.allowed_extensions",
"chat.attachments.allowed_mime_types",
"chat.attachments.max_size_mb",
"chat.attachments.presigned_upload_ttl_seconds",
"rate_limit.message_send.per_user",
"rate_limit.message_send.per_dialog",
"rate_limit.download_url.per_user",
"rate_limit.public_endpoints.per_ip",
"ux.session.idle_timeout_minutes",
"security.cors.allowed_origins",
"security.public_cache.max_age_seconds",
}
@dataclass(frozen=True, slots=True)
class SettingsSnapshot:
values: dict[str, str]
version: str
def boolean(self, key: str) -> bool:
return self.values[key].lower() == "true"
def integer(self, key: str) -> int:
return int(self.values[key])
def strings(self, key: str) -> list[str]:
return [item.strip() for item in self.values[key].split(",") if item.strip()]
def limit(self, key: str) -> tuple[int, int]:
amount, period = self.values[key].split("/", 1)
windows = {"second": 1, "minute": 60, "hour": 3600, "day": 86400}
return int(amount), windows[period]
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
rows = (
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
values = {row.setting_key: row.setting_value for row in rows}
missing = REQUIRED_SETTINGS - values.keys()
if missing:
raise DomainError(
"dependency_unavailable",
503,
"Required settings are unavailable",
{"missing": sorted(missing)},
)
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
return SettingsSnapshot(values, version)
async def resolve_user(session: AsyncSession, principal: Principal) -> UserIdentity:
user = (
await session.execute(
select(UserIdentity).where(
UserIdentity.keycloak_sub == principal.subject, UserIdentity.record_status == "A"
)
)
).scalar_one_or_none()
if user is None:
raise DomainError("resource_state_conflict", 409, "Bootstrap required")
return user
def audit(
event_type: str,
request_id: str,
user_id: uuid.UUID | None,
resource_type: str | None = None,
resource_id: uuid.UUID | None = None,
ux_session_id: uuid.UUID | None = None,
metadata: dict[str, Any] | None = None,
) -> AuditEvent:
return AuditEvent(
event_type=event_type,
actor_type="user" if user_id else "service",
user_id=user_id,
ux_session_id=ux_session_id,
request_id=request_id,
resource_type=resource_type,
resource_id=resource_id,
outcome="success",
metadata_json=metadata or {},
)
def validate_consents(consents: Any, snapshot: SettingsSnapshot) -> None:
for name in ("personal_data", "user_agreement", "marketing"):
choice = getattr(consents, name)
if choice.version != snapshot.values[f"consent.{name}.version"]:
raise DomainError(
"validation_error", 400, "Consent version is not current", {"field": name}
)
if snapshot.boolean(f"consent.{name}.required") and not choice.accepted:
raise DomainError("consents_required", 403, "Required consents must be accepted")
async def bootstrap(
session: AsyncSession,
principal: Principal,
body: BootstrapRequest,
snapshot: SettingsSnapshot,
request_id: str,
) -> dict[str, Any]:
if not principal.phone_number:
raise DomainError("phone_claim_missing", 400, "Verified phone claim is missing")
validate_consents(body.consents, snapshot)
now = datetime.now(UTC)
statement = (
insert(UserIdentity)
.values(
id=uuid.uuid4(),
keycloak_sub=principal.subject,
phone_number=principal.phone_number,
last_login_at=now,
)
.on_conflict_do_update(
index_elements=[UserIdentity.keycloak_sub],
set_={"phone_number": principal.phone_number, "last_login_at": now, "updated_at": now},
)
.returning(UserIdentity.id)
)
user_id = (await session.execute(statement)).scalar_one()
await session.execute(
insert(ClientProfile)
.values(id=uuid.uuid4(), user_id=user_id)
.on_conflict_do_nothing(index_elements=[ClientProfile.user_id])
)
for consent_type in ("personal_data", "user_agreement", "marketing"):
choice = getattr(body.consents, consent_type)
await session.execute(
insert(UserConsent)
.values(
id=uuid.uuid4(),
user_id=user_id,
consent_type=consent_type,
document_version=choice.version,
accepted=choice.accepted,
accepted_at=now,
)
.on_conflict_do_nothing(
index_elements=[
UserConsent.user_id,
UserConsent.consent_type,
UserConsent.document_version,
]
)
)
session.add(audit("auth.bootstrap", request_id, user_id))
await session.commit()
return {"user_id": user_id, "profile_ready": True}
async def record_consents(
session: AsyncSession,
user: UserIdentity,
body: ConsentsRequest,
snapshot: SettingsSnapshot,
request_id: str,
ux_session_id: uuid.UUID | None,
) -> dict[str, Any]:
validate_consents(body.consents, snapshot)
now = datetime.now(UTC)
versions: dict[str, str] = {}
for consent_type in ("personal_data", "user_agreement", "marketing"):
choice = getattr(body.consents, consent_type)
versions[consent_type] = choice.version
await session.execute(
insert(UserConsent)
.values(
id=uuid.uuid4(),
user_id=user.id,
ux_session_id=ux_session_id,
consent_type=consent_type,
document_version=choice.version,
accepted=choice.accepted,
accepted_at=now,
)
.on_conflict_do_nothing()
)
session.add(audit("consent.recorded", request_id, user.id, ux_session_id=ux_session_id))
await session.commit()
return {"recorded_at": now, "versions": versions}
async def start_session(
session: AsyncSession, user: UserIdentity, body: SessionStartRequest, request_id: str
) -> dict[str, Any]:
now, session_id = datetime.now(UTC), uuid.uuid4()
device_hash = (
hashlib.sha256(body.device.device_id.encode()).hexdigest()
if body.device.device_id
else None
)
session.add(
UxSession(
id=session_id,
user_id=user.id,
start_reason=body.start_reason,
platform=body.device.platform,
app_version=body.device.app_version,
device_id_hash=device_hash,
started_at=now,
)
)
session.add(audit("session_start", request_id, user.id, ux_session_id=session_id))
await session.commit()
return {"ux_session_id": session_id, "started_at": now}
async def get_profile(session: AsyncSession, user: UserIdentity) -> dict[str, Any]:
profile = (
await session.execute(
select(ClientProfile).where(
ClientProfile.user_id == user.id, ClientProfile.record_status == "A"
)
)
).scalar_one()
document_count = (
await session.scalar(
select(func.count(Document.id)).where(
Document.user_id == user.id, Document.record_status == "A"
)
)
or 0
)
return {
"user_id": user.id,
"profile": {
"personal_data": {
"full_name": profile.full_name,
"citizenship": profile.citizenship,
"russian_phone": profile.russian_phone,
"foreign_phone": profile.foreign_phone,
"email": profile.email,
},
"documents": {"count": document_count},
},
}
def dialog_dto(dialog: Dialog) -> dict[str, Any]:
return {
"dialog_id": dialog.id,
"status": dialog.status,
"last_message_preview": None,
"unread_count": 0,
"created_at": dialog.created_at,
"updated_at": dialog.updated_at,
}
def message_dto(
message: Message, attachments: list[MessageAttachment] | None = None
) -> dict[str, Any]:
return {
"message_id": message.id,
"dialog_id": message.dialog_id,
"sender_type": message.sender_type,
"content_kind": message.content_kind,
"text": message.text,
"attachments": [
{
"attachment_id": item.id,
"file_name": item.safe_file_name,
"mime_type": item.mime_type,
"size_bytes": item.size_bytes,
"scan_status": item.scan_status,
}
for item in (attachments or [])
],
"safety_status": message.safety_status,
"delivery_status": message.delivery_status,
"created_at": message.created_at,
}
def message_cursor(message: Message, settings: Settings) -> str:
return encode_cursor(
{"created_at": message.created_at.isoformat(), "id": str(message.id)},
settings.cursor_hmac_secret.get_secret_value().encode(),
)
async def publish_message(
fanout: RealtimeFanout,
message: Message,
settings: Settings,
attachments: list[MessageAttachment] | None = None,
) -> None:
await fanout.publish(
{
"type": "message.new",
"dialog_id": str(message.dialog_id),
"message": message_dto(message, attachments),
"cursor": message_cursor(message, settings),
}
)
async def publish_message_status(
fanout: RealtimeFanout, message: Message, settings: Settings
) -> None:
await fanout.publish(
{
"type": "message.status",
"dialog_id": str(message.dialog_id),
"message_id": str(message.id),
"safety_status": message.safety_status,
"delivery_status": message.delivery_status,
"cursor": message_cursor(message, settings),
}
)
async def publish_dialog_status(fanout: RealtimeFanout, dialog: Dialog) -> None:
await fanout.publish(
{"type": "dialog.status", "dialog_id": str(dialog.id), "status": dialog.status}
)
async def owned_dialog(session: AsyncSession, user_id: uuid.UUID, dialog_id: uuid.UUID) -> Dialog:
dialog = (
await session.execute(
select(Dialog).where(
Dialog.id == dialog_id, Dialog.user_id == user_id, Dialog.record_status == "A"
)
)
).scalar_one_or_none()
if dialog is None:
raise DomainError("not_found", 404, "Resource was not found")
return dialog
async def create_dialog(
session: AsyncSession, user: UserIdentity, request_id: str, idempotency_key: str
) -> tuple[dict[str, Any], int]:
scope = "dialogs.create"
fingerprint = hashlib.sha256(f"POST:/api/v1/dialogs:{user.id}".encode()).hexdigest()
record = (
await session.execute(
select(IdempotencyRecord).where(
IdempotencyRecord.scope == scope,
IdempotencyRecord.user_id == user.id,
IdempotencyRecord.idempotency_key == idempotency_key,
)
)
).scalar_one_or_none()
if record:
if record.request_fingerprint != fingerprint:
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
if record.status == "completed" and record.response_body_json:
return record.response_body_json, record.response_status or 200
existing = (
await session.execute(
select(Dialog).where(
Dialog.user_id == user.id,
Dialog.record_status == "A",
Dialog.status.in_(["open", "waiting_for_company", "waiting_for_client"]),
)
)
).scalar_one_or_none()
if existing:
result = dialog_dto(existing)
session.add(
IdempotencyRecord(
scope=scope,
user_id=user.id,
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
status="completed",
response_status=200,
response_body_json=json.loads(json.dumps(result, default=str)),
resource_type="dialog",
resource_id=existing.id,
expires_at=datetime.now(UTC) + timedelta(hours=24),
)
)
await session.commit()
return result, 200
dialog = Dialog(id=uuid.uuid4(), user_id=user.id, status="open")
session.add(dialog)
session.add(audit("dialog.created", request_id, user.id, "dialog", dialog.id))
result = dialog_dto(dialog)
session.add(
IdempotencyRecord(
scope=scope,
user_id=user.id,
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
status="completed",
response_status=201,
response_body_json=json.loads(json.dumps(result, default=str)),
resource_type="dialog",
resource_id=dialog.id,
expires_at=datetime.now(UTC) + timedelta(hours=24),
)
)
await session.commit()
await session.refresh(dialog)
return dialog_dto(dialog), 201
async def init_attachment(
session: AsyncSession,
user: UserIdentity,
dialog_id: uuid.UUID,
body: AttachmentInitRequest,
snapshot: SettingsSnapshot,
s3: S3Client,
request_id: str,
) -> dict[str, Any]:
await owned_dialog(session, user.id, dialog_id)
extension = PurePath(body.file_name).suffix.lower().lstrip(".")
if (
extension not in snapshot.strings("chat.attachments.allowed_extensions")
or body.mime_type not in snapshot.strings("chat.attachments.allowed_mime_types")
or body.size_bytes > snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024
):
raise DomainError("validation_error", 400, "File type or size is not allowed")
attachment_id = uuid.uuid4()
key = f"quarantine/users/{user.id}/dialogs/{dialog_id}/{attachment_id}"
ttl = snapshot.integer("chat.attachments.presigned_upload_ttl_seconds")
expires = datetime.now(UTC) + timedelta(seconds=ttl)
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", unicodedata.normalize("NFKC", body.file_name))
item = MessageAttachment(
id=attachment_id,
dialog_id=dialog_id,
owner_user_id=user.id,
direction="client_upload",
original_file_name=body.file_name,
safe_file_name=safe_name,
mime_type=body.mime_type,
size_bytes=body.size_bytes,
scan_status="pending",
storage_bucket=s3.settings.selectel_s3_bucket_quarantine,
object_key=key,
quarantine_object_key=key,
upload_expires_at=expires,
)
session.add(item)
session.add(
audit("attachment.upload_initialized", request_id, user.id, "attachment", attachment_id)
)
await session.commit()
url = await s3.presign_put(key, body.mime_type, ttl)
return {
"attachment_id": attachment_id,
"upload_url": url,
"upload_headers": {"Content-Type": body.mime_type},
"expires_at": expires,
}
async def complete_attachment(
session: AsyncSession,
user: UserIdentity,
dialog_id: uuid.UUID,
attachment_id: uuid.UUID,
body: AttachmentCompleteRequest,
s3: S3Client,
request_id: str,
) -> dict[str, Any]:
item = (
await session.execute(
select(MessageAttachment).where(
MessageAttachment.id == attachment_id,
MessageAttachment.dialog_id == dialog_id,
MessageAttachment.owner_user_id == user.id,
MessageAttachment.record_status == "A",
)
)
).scalar_one_or_none()
if item is None:
raise DomainError("not_found", 404, "Resource was not found")
checksum = body.checksum.removeprefix("sha256:")
if item.completed_at:
if item.checksum_sha256 != checksum:
raise DomainError("resource_state_conflict", 409, "Attachment checksum changed")
return attachment_dto(item)
try:
head = await s3.head(item.storage_bucket, item.object_key)
except Exception as exc:
raise DomainError("dependency_unavailable", 503, "Object storage is unavailable") from exc
if int(head["ContentLength"]) != item.size_bytes or head.get("ContentType") != item.mime_type:
raise DomainError("attachment_checksum_mismatch", 400, "Uploaded metadata does not match")
item.checksum_sha256 = checksum
item.completed_at = datetime.now(UTC)
session.add(audit("attachment.upload_completed", request_id, user.id, "attachment", item.id))
await session.commit()
return attachment_dto(item)
def attachment_dto(item: MessageAttachment) -> dict[str, Any]:
return {
"attachment_id": item.id,
"file_name": item.safe_file_name,
"mime_type": item.mime_type,
"size_bytes": item.size_bytes,
"checksum": f"sha256:{item.checksum_sha256}" if item.checksum_sha256 else None,
"scan_status": item.scan_status,
"completed_at": item.completed_at,
}
async def send_message(
session: AsyncSession,
user: UserIdentity,
dialog_id: uuid.UUID,
body: MessageRequest,
idem_key: str,
request_id: str,
settings: Settings,
safety: SafetyClient,
openlines: OpenLinesClient,
s3: S3Client,
fanout: RealtimeFanout,
) -> dict[str, Any]:
scope = f"dialogs.{dialog_id}.messages.create"
fingerprint = hashlib.sha256(
json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
idem = (
await session.execute(
select(IdempotencyRecord).where(
IdempotencyRecord.scope == scope,
IdempotencyRecord.user_id == user.id,
IdempotencyRecord.idempotency_key == idem_key,
)
)
).scalar_one_or_none()
if idem:
if idem.request_fingerprint != fingerprint:
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
if idem.status == "completed" and idem.response_body_json:
if idem.response_status == 422:
raise DomainError("message_blocked", 422, "Message was blocked by safety policy")
return idem.response_body_json
prior = (
await session.execute(
select(Message).where(
Message.dialog_id == dialog_id,
Message.client_idempotency_key == idem_key,
)
)
).scalar_one_or_none()
if prior and prior.delivery_status == "delivered":
return message_dto(prior)
raise DomainError(
"dependency_unavailable",
503,
"Previous request is still being recovered",
{"retry_after": 2},
)
dialog = await owned_dialog(session, user.id, dialog_id)
now, message_id = datetime.now(UTC), uuid.uuid4()
idem = IdempotencyRecord(
scope=scope,
user_id=user.id,
idempotency_key=idem_key,
request_fingerprint=fingerprint,
status="in_progress",
resource_type="message",
resource_id=message_id,
expires_at=now + timedelta(hours=24),
)
session.add(idem)
attachment: MessageAttachment | None = None
if isinstance(body, FileMessageRequest):
attachment = (
await session.execute(
select(MessageAttachment).where(
MessageAttachment.id == body.attachment_id,
MessageAttachment.owner_user_id == user.id,
MessageAttachment.dialog_id == dialog_id,
MessageAttachment.record_status == "A",
)
)
).scalar_one_or_none()
if not attachment or not attachment.completed_at:
raise DomainError("attachment_not_completed", 400, "Attachment upload is incomplete")
if attachment.checksum_sha256 != body.checksum.removeprefix("sha256:"):
raise DomainError("attachment_checksum_mismatch", 400, "Attachment checksum differs")
text, kind = "", "file"
else:
text, kind = unicodedata.normalize("NFKC", body.text).strip(), "text"
message = Message(
id=message_id,
dialog_id=dialog_id,
sender_type="client",
content_kind=kind,
text=text,
safety_status="pending",
delivery_status="accepted",
client_idempotency_key=idem_key,
occurred_at=now,
)
session.add(message)
if attachment:
attachment.message_id = message_id
session.add(audit("message.submitted", request_id, user.id, "message", message_id))
await session.commit()
await publish_message(fanout, message, settings, [attachment] if attachment else [])
payload: dict[str, Any] = {"message_id": str(message_id), "content_kind": kind, "text": text}
if attachment:
payload["attachment"] = {
"attachment_id": str(attachment.id),
"quarantine_object_key": attachment.quarantine_object_key,
"checksum": f"sha256:{attachment.checksum_sha256}",
"mime_type": attachment.mime_type,
"size_bytes": attachment.size_bytes,
}
try:
verdict = await safety.check(payload, request_id)
if verdict["_status"] == 203:
task_id = verdict["task_id"]
task = SafetyTask(
task_id=task_id,
message_id=message.id,
attachment_id=attachment.id if attachment else None,
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
status="polling",
deadline_at=now
+ timedelta(seconds=settings.message_safety_task_poll_max_sec + 900),
next_poll_at=now,
)
session.add(task)
await session.commit()
deadline = time_monotonic() + settings.message_safety_task_poll_max_sec
while time_monotonic() < deadline:
await sleep(settings.message_safety_task_poll_interval_sec)
verdict = await safety.poll(task_id, request_id)
if verdict["_status"] != 203:
break
else:
raise DependencyFailure(timeout=True)
if verdict["_status"] == 403 or (
verdict["_status"] == 400
and verdict.get("error", {}).get("code") == "stub_final_error"
and verdict.get("verdict") == "deny"
):
message.text = ""
message.safety_status = "blocked"
message.delivery_status = "rejected"
if attachment and attachment.quarantine_object_key:
attachment.scan_status = "infected"
await s3.delete_quarantine(attachment.quarantine_object_key)
session.add(audit("message.blocked", request_id, user.id, "message", message.id))
idem.status = "completed"
idem.response_status = 422
idem.response_body_json = {
"error": {"code": "message_blocked", "message_id": str(message.id)}
}
await session.commit()
await publish_message_status(fanout, message, settings)
raise DomainError("message_blocked", 422, "Message was blocked by safety policy")
if verdict["_status"] != 200:
raise DependencyFailure()
if attachment and attachment.quarantine_object_key:
destination = f"attachments/dialogs/{dialog_id}/{attachment.id}"
await s3.promote(attachment.quarantine_object_key, destination)
attachment.storage_bucket = settings.selectel_s3_bucket_attachments
attachment.object_key = destination
attachment.quarantine_object_key = None
attachment.scan_status = "clean"
message.safety_status = "allowed"
outbox = DeliveryOutbox(
message_id=message.id,
external_chat_id=dialog_id,
payload_json={
"message_id": str(message.id),
"external_chat_id": str(dialog_id),
"occurred_at": message.occurred_at.isoformat(),
"user": {"id": str(user.id), "display_name": user.phone_number},
"message": {
"content_kind": message.content_kind,
"text": message.text,
"files": (
[
{
"attachment_id": str(attachment.id),
"name": attachment.safe_file_name,
"mime_type": attachment.mime_type,
"size_bytes": attachment.size_bytes,
"_storage_bucket": attachment.storage_bucket,
"_object_key": attachment.object_key,
}
]
if attachment
else []
),
},
},
next_attempt_at=datetime.now(UTC),
)
session.add(outbox)
await session.commit()
await publish_message_status(fanout, message, settings)
delivery_payload = await fresh_openlines_payload(outbox.payload_json, s3)
await openlines.send(message.id, delivery_payload, request_id)
message.delivery_status = "delivered"
dialog.status = "waiting_for_company"
dialog.last_message_at = datetime.now(UTC)
outbox.status = "delivered"
session.add(audit("message.delivered", request_id, user.id, "message", message.id))
result = message_dto(message, [attachment] if attachment else [])
idem.status = "completed"
idem.response_status = 201
idem.response_body_json = json.loads(json.dumps(result, default=str))
await session.commit()
await publish_message_status(fanout, message, settings)
await publish_dialog_status(fanout, dialog)
return result
except DomainError:
raise
except DependencyFailure as exc:
message.delivery_status = "failed"
session.add(audit("message.failed", request_id, user.id, "message", message.id))
await session.commit()
await publish_message_status(fanout, message, settings)
raise DomainError(
"dependency_timeout" if exc.timeout else "dependency_unavailable",
504 if exc.timeout else 503,
"A required dependency did not complete the request",
) from exc
async def apply_inbox(
session: AsyncSession,
event: OpenLinesInbox,
request_id: str,
snapshot: SettingsSnapshot,
s3: S3Client,
http: httpx.AsyncClient,
settings: Settings,
fanout: RealtimeFanout,
) -> tuple[dict[str, Any], int]:
fingerprint = hashlib.sha256(event.model_dump_json().encode()).hexdigest()
existing = (
await session.execute(
select(OpenLinesInboxReceipt).where(OpenLinesInboxReceipt.event_id == event.event_id)
)
).scalar_one_or_none()
if existing:
if existing.payload_fingerprint != fingerprint:
raise DomainError("idempotency_key_reused", 409, "Event id was reused")
return {"status": "duplicate"}, 200
dialog = (
await session.execute(
select(Dialog).where(Dialog.id == event.external_chat_id, Dialog.record_status == "A")
)
).scalar_one_or_none()
if not dialog:
raise DomainError("not_found", 404, "Resource was not found")
receipt = OpenLinesInboxReceipt(
event_id=event.event_id,
external_chat_id=event.external_chat_id,
bitrix_message_id=event.bitrix_message_id,
event_type=event.event_type,
payload_fingerprint=fingerprint,
status="processing",
)
session.add(receipt)
message: Message | None = None
attachment: MessageAttachment | None = None
if event.event_type == "dialog.closed":
dialog.status = "closed"
dialog.closed_at = event.occurred_at
else:
assert event.message is not None
inbound_file = event.message.files[0] if event.message.files else None
attachment_data: tuple[str, int, str] | None = None
if inbound_file:
extension = PurePath(inbound_file.name).suffix.lower().lstrip(".")
max_bytes = snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024
if (
extension not in snapshot.strings("chat.attachments.allowed_extensions")
or inbound_file.mime_type
not in snapshot.strings("chat.attachments.allowed_mime_types")
or inbound_file.size_bytes > max_bytes
):
raise DomainError("validation_error", 400, "Inbound file is not allowed")
attachment_id = uuid.uuid4()
object_key = f"attachments/dialogs/{dialog.id}/{attachment_id}"
try:
actual_size, checksum = await s3.upload_inbound(
http,
str(inbound_file.download_url),
object_key,
inbound_file.mime_type,
max_bytes,
)
except DependencyFailure as exc:
raise DomainError(
"dependency_unavailable", 503, "Inbound file transfer failed"
) from exc
if actual_size != inbound_file.size_bytes:
raise DomainError("validation_error", 400, "Inbound file size differs")
attachment_data = object_key, actual_size, checksum
message = Message(
dialog_id=dialog.id,
sender_type="company",
content_kind="file" if inbound_file else "text",
text=event.message.text,
safety_status="allowed",
delivery_status="delivered",
external_message_id=event.bitrix_message_id,
occurred_at=event.occurred_at,
)
session.add(message)
await session.flush()
if inbound_file and attachment_data:
object_key, actual_size, checksum = attachment_data
safe_name = re.sub(
r"[^A-Za-z0-9._-]",
"_",
unicodedata.normalize("NFKC", inbound_file.name),
)
attachment = MessageAttachment(
dialog_id=dialog.id,
message_id=message.id,
owner_user_id=dialog.user_id,
direction="company_inbound",
original_file_name=inbound_file.name,
safe_file_name=safe_name,
mime_type=inbound_file.mime_type,
size_bytes=actual_size,
checksum_sha256=checksum,
scan_status="clean",
storage_bucket=s3.settings.selectel_s3_bucket_attachments,
object_key=object_key,
completed_at=datetime.now(UTC),
)
session.add(attachment)
receipt.message_id = message.id
dialog.status = "waiting_for_client"
dialog.last_message_at = event.occurred_at
receipt.status = "applied"
session.add(audit("openlines.inbox_applied", request_id, dialog.user_id, "dialog", dialog.id))
await session.commit()
if message is not None:
await publish_message(fanout, message, settings, [attachment] if attachment else [])
await publish_dialog_status(fanout, dialog)
return {"status": "applied"}, 201
async def sleep(seconds: float) -> None:
import asyncio
await asyncio.sleep(seconds)
def time_monotonic() -> float:
import time
return time.monotonic()
@@ -0,0 +1,78 @@
from functools import lru_cache
from pydantic import AnyHttpUrl, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore"
)
app_env: str = Field(alias="APP_ENV")
api_port: int = Field(default=8000, alias="API_PORT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
database_url: str = Field(alias="DATABASE_URL")
redis_url: str = Field(alias="REDIS_URL")
redis_realtime_url: str = Field(alias="REDIS_REALTIME_URL")
keycloak_public_url: AnyHttpUrl = Field(alias="KEYCLOAK_PUBLIC_URL")
keycloak_internal_url: AnyHttpUrl = Field(alias="KEYCLOAK_INTERNAL_URL")
keycloak_realm: str = Field(alias="KEYCLOAK_REALM")
keycloak_audience: str = Field(alias="KEYCLOAK_AUDIENCE")
jwks_cache_ttl_seconds: int = 300
jwks_stale_grace_seconds: int = 900
message_safety_url: AnyHttpUrl = Field(alias="MESSAGE_SAFETY_URL")
message_safety_service_token: SecretStr = Field(alias="MESSAGE_SAFETY_SERVICE_TOKEN")
message_safety_post_timeout_sec: float = Field(
default=5, alias="MESSAGE_SAFETY_POST_TIMEOUT_SEC"
)
message_safety_task_poll_interval_sec: float = Field(
default=2, alias="MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC"
)
message_safety_task_poll_max_sec: float = Field(
default=300, alias="MESSAGE_SAFETY_TASK_POLL_MAX_SEC"
)
message_safety_circuit_failure_threshold: int = Field(
default=5, alias="MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD"
)
message_safety_circuit_open_sec: int = Field(
default=30, alias="MESSAGE_SAFETY_CIRCUIT_OPEN_SEC"
)
bitrix_local_app_base_url: AnyHttpUrl = Field(alias="BITRIX_LOCAL_APP_BASE_URL")
bitrix_local_app_internal_token: SecretStr = Field(alias="BITRIX_LOCAL_APP_INTERNAL_TOKEN")
bitrix_api_inbox_token: SecretStr = Field(alias="BITRIX_API_INBOX_TOKEN")
bitrix_local_app_http_timeout_sec: float = Field(
default=10, alias="BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC"
)
bitrix_local_app_circuit_failure_threshold: int = Field(
default=5, alias="BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD"
)
bitrix_local_app_circuit_open_sec: int = Field(
default=30, alias="BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC"
)
keycloak_settings_bridge_token: SecretStr = Field(alias="KEYCLOAK_SETTINGS_BRIDGE_TOKEN")
selectel_s3_endpoint_url: AnyHttpUrl = Field(alias="SELECTEL_S3_ENDPOINT_URL")
selectel_s3_bucket_documents: str = Field(alias="SELECTEL_S3_BUCKET_DOCUMENTS")
selectel_s3_bucket_attachments: str = Field(alias="SELECTEL_S3_BUCKET_ATTACHMENTS")
selectel_s3_bucket_quarantine: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
selectel_s3_access_key: SecretStr = Field(alias="SELECTEL_S3_ACCESS_KEY")
selectel_s3_secret_key: SecretStr = Field(alias="SELECTEL_S3_SECRET_KEY")
otel_exporter_otlp_endpoint: str | None = Field(
default=None, alias="OTEL_EXPORTER_OTLP_ENDPOINT"
)
cursor_hmac_secret: SecretStr = Field(alias="CURSOR_HMAC_SECRET")
trusted_proxy_cidrs: str = Field(default="127.0.0.1/32", alias="TRUSTED_PROXY_CIDRS")
worker_poll_interval_sec: float = Field(default=2, alias="WORKER_POLL_INTERVAL_SEC")
@property
def issuer(self) -> str:
return f"{str(self.keycloak_public_url).rstrip('/')}/realms/{self.keycloak_realm}"
@lru_cache
def get_settings() -> Settings:
return Settings()
+234
View File
@@ -0,0 +1,234 @@
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
import httpx
import redis.asyncio as redis
import structlog
from sqlalchemy import select
from app.db import Database, DeliveryOutbox, Dialog, Message, MessageAttachment, SafetyTask
from app.integrations import (
DependencyFailure,
OpenLinesClient,
S3Client,
SafetyClient,
fresh_openlines_payload,
)
from app.realtime import RealtimeFanout
from app.services import publish_dialog_status, publish_message_status
from app.settings import Settings, get_settings
log = structlog.get_logger()
async def delivery_once(
db: Database,
client: OpenLinesClient,
s3: S3Client,
fanout: RealtimeFanout,
settings: Settings,
worker_id: str,
batch_size: int = 20,
) -> int:
async with db.sessions() as session:
rows = (
(
await session.execute(
select(DeliveryOutbox)
.where(
DeliveryOutbox.status.in_(["pending", "retry"]),
DeliveryOutbox.next_attempt_at <= datetime.now(UTC),
)
.with_for_update(skip_locked=True)
.limit(batch_size)
)
)
.scalars()
.all()
)
ids = [row.id for row in rows]
for row in rows:
row.status = "processing"
row.locked_at = datetime.now(UTC)
row.locked_by = worker_id
await session.commit()
for row_id in ids:
async with db.sessions() as session:
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
if row is None:
continue
message = None
dialog = None
try:
payload = await fresh_openlines_payload(row.payload_json, s3)
await client.send(row.message_id, payload, f"worker-{worker_id}")
row.status = "delivered"
message = await session.get(Message, row.message_id)
if message:
message.delivery_status = "delivered"
dialog = await session.get(Dialog, message.dialog_id)
if dialog:
dialog.status = "waiting_for_company"
dialog.last_message_at = datetime.now(UTC)
except DependencyFailure:
row.attempt_count += 1
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
row.next_attempt_at = datetime.now(UTC) + timedelta(
seconds=min(3600, 2**row.attempt_count)
)
row.last_error_code = "dependency_unavailable"
row.locked_at = None
row.locked_by = None
await session.commit()
if message:
await publish_message_status(fanout, message, settings)
if dialog:
await publish_dialog_status(fanout, dialog)
return len(ids)
async def safety_once(
db: Database,
safety: SafetyClient,
s3: S3Client,
fanout: RealtimeFanout,
settings: Settings,
worker_id: str,
batch_size: int = 20,
) -> int:
async with db.sessions() as session:
rows = (
(
await session.execute(
select(SafetyTask)
.where(
SafetyTask.status.in_(["polling", "failed"]),
SafetyTask.next_poll_at <= datetime.now(UTC),
SafetyTask.deadline_at > datetime.now(UTC),
)
.with_for_update(skip_locked=True)
.limit(batch_size)
)
)
.scalars()
.all()
)
ids = [row.id for row in rows]
for row in rows:
row.locked_at, row.locked_by = datetime.now(UTC), worker_id
await session.commit()
for task_id in ids:
async with db.sessions() as session:
task = await session.get(SafetyTask, task_id, with_for_update=True)
if task is None:
continue
message = None
try:
verdict = await safety.poll(task.task_id, f"worker-{worker_id}")
message = await session.get(Message, task.message_id)
attachment = (
await session.get(MessageAttachment, task.attachment_id)
if task.attachment_id
else None
)
if verdict["_status"] == 200 and message:
if attachment and attachment.quarantine_object_key:
destination = f"attachments/dialogs/{message.dialog_id}/{attachment.id}"
await s3.promote(attachment.quarantine_object_key, destination)
attachment.storage_bucket = s3.settings.selectel_s3_bucket_attachments
attachment.object_key = destination
attachment.quarantine_object_key = None
attachment.scan_status = "clean"
message.safety_status = "allowed"
task.status = "completed"
elif verdict["_status"] == 403 and message:
message.text = ""
message.safety_status = "blocked"
message.delivery_status = "rejected"
if attachment and attachment.quarantine_object_key:
await s3.delete_quarantine(attachment.quarantine_object_key)
attachment.scan_status = "infected"
task.status = "completed"
else:
task.next_poll_at = datetime.now(UTC) + timedelta(seconds=2)
except DependencyFailure:
task.attempt_count += 1
task.status = "failed"
task.next_poll_at = datetime.now(UTC) + timedelta(
seconds=min(300, 2**task.attempt_count)
)
task.locked_at = None
task.locked_by = None
await session.commit()
if message:
await publish_message_status(fanout, message, settings)
return len(ids)
async def cleanup_once(db: Database, s3: S3Client, batch_size: int = 100) -> int:
async with db.sessions() as session:
rows = (
(
await session.execute(
select(MessageAttachment)
.where(
MessageAttachment.record_status == "A",
MessageAttachment.quarantine_object_key.is_not(None),
MessageAttachment.upload_expires_at < datetime.now(UTC),
MessageAttachment.message_id.is_(None),
)
.with_for_update(skip_locked=True)
.limit(batch_size)
)
)
.scalars()
.all()
)
for row in rows:
if row.quarantine_object_key:
await s3.delete_quarantine(row.quarantine_object_key)
row.record_status = "D"
row.status_changed_at = datetime.now(UTC)
row.status_change_reason = "expired_quarantine_cleanup"
await session.commit()
return len(rows)
async def loop(kind: str) -> None:
settings = get_settings()
db = Database(settings.database_url)
http = httpx.AsyncClient()
safety = SafetyClient(settings, http)
openlines = OpenLinesClient(settings, http)
s3 = S3Client(settings)
redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
fanout = RealtimeFanout(redis_rt)
worker_id = f"{kind}-{uuid.uuid4()}"
try:
while True:
count = 0
if kind == "delivery":
count = await delivery_once(db, openlines, s3, fanout, settings, worker_id)
elif kind == "safety":
count = await safety_once(db, safety, s3, fanout, settings, worker_id)
else:
count = await cleanup_once(db, s3)
if not count:
await asyncio.sleep(settings.worker_poll_interval_sec)
finally:
await http.aclose()
await redis_rt.aclose()
await db.close()
def delivery_main() -> None:
asyncio.run(loop("delivery"))
def safety_main() -> None:
asyncio.run(loop("safety"))
def cleanup_main() -> None:
asyncio.run(loop("cleanup"))
@@ -0,0 +1,56 @@
services:
api-backend:
build: .
image: han-chat/api-backend:local
expose:
- "8000"
environment:
APP_ENV: ${APP_ENV}
API_PORT: ${API_PORT:-8000}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
REDIS_REALTIME_URL: ${REDIS_REALTIME_URL}
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL}
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE}
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
MESSAGE_SAFETY_SERVICE_TOKEN: ${MESSAGE_SAFETY_SERVICE_TOKEN}
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD: ${MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD:-5}
MESSAGE_SAFETY_CIRCUIT_OPEN_SEC: ${MESSAGE_SAFETY_CIRCUIT_OPEN_SEC:-30}
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL}
BITRIX_LOCAL_APP_INTERNAL_TOKEN: ${BITRIX_LOCAL_APP_INTERNAL_TOKEN}
BITRIX_API_INBOX_TOKEN: ${BITRIX_API_INBOX_TOKEN}
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-10}
BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD: ${BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD:-5}
BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC: ${BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC:-30}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
SELECTEL_S3_ACCESS_KEY: ${SELECTEL_S3_ACCESS_KEY}
SELECTEL_S3_SECRET_KEY: ${SELECTEL_S3_SECRET_KEY}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
CURSOR_HMAC_SECRET: ${CURSOR_HMAC_SECRET}
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=2)"
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- backend
- observability
networks:
backend:
observability:
+314
View File
@@ -0,0 +1,314 @@
openapi: 3.1.0
info:
title: HAN Chat API
version: 1.0.0
servers:
- url: /
paths:
/health/live:
get:
operationId: healthLive
responses:
"200": {description: Process is live}
/health/ready:
get:
operationId: healthReady
responses:
"200": {description: Ready or partially degraded}
"503": {$ref: "#/components/responses/DependencyUnavailable"}
/api/v1/public/app-config:
get:
operationId: getPublicAppConfig
responses:
"200": {description: Public application configuration}
/api/v1/public/content:
get:
operationId: getPublicContent
parameters:
- {name: locale, in: query, schema: {type: string, default: ru}}
responses:
"200": {description: Active UI content}
/api/v1/auth/bootstrap:
post:
operationId: bootstrap
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/BootstrapRequest"}
responses:
"200": {description: Local user resolved}
"401": {$ref: "#/components/responses/Unauthorized"}
/api/v1/consents:
post:
operationId: recordConsents
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/ConsentsRequest"}
responses:
"201": {description: Immutable consent records saved}
/api/v1/analytics/session-start:
post:
operationId: startUxSession
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/SessionStartRequest"}
responses:
"201": {description: UX session created}
/api/v1/me:
get:
operationId: getCurrentProfile
security: [{bearerAuth: []}]
responses:
"200": {description: Block profile}
/api/v1/me/documents:
get:
operationId: listDocuments
security: [{bearerAuth: []}]
responses:
"200": {description: Cursor-paginated documents}
/api/v1/documents/{document_id}:
parameters:
- {$ref: "#/components/parameters/DocumentId"}
get:
operationId: getDocument
security: [{bearerAuth: []}]
responses:
"200": {description: Document metadata}
"404": {$ref: "#/components/responses/NotFound"}
/api/v1/documents/{document_id}/download-url:
parameters:
- {$ref: "#/components/parameters/DocumentId"}
get:
operationId: getDocumentDownloadUrl
security: [{bearerAuth: []}]
responses:
"200": {description: Short-lived presigned GET}
/api/v1/dialogs:
post:
operationId: createDialog
security: [{bearerAuth: []}]
parameters:
- {$ref: "#/components/parameters/IdempotencyKey"}
responses:
"200": {description: Existing active dialog}
"201": {description: New active dialog}
get:
operationId: listDialogs
security: [{bearerAuth: []}]
responses:
"200": {description: Cursor-paginated dialogs}
/api/v1/dialogs/{dialog_id}:
parameters:
- {$ref: "#/components/parameters/DialogId"}
get:
operationId: getDialog
security: [{bearerAuth: []}]
responses:
"200": {description: Dialog summary}
"404": {$ref: "#/components/responses/NotFound"}
/api/v1/dialogs/{dialog_id}/messages:
parameters:
- {$ref: "#/components/parameters/DialogId"}
get:
operationId: listMessages
security: [{bearerAuth: []}]
parameters:
- {name: after, in: query, schema: {type: string}}
- {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 50}}
responses:
"200": {description: Message history or polling delta}
post:
operationId: sendMessage
security: [{bearerAuth: []}]
parameters:
- {$ref: "#/components/parameters/IdempotencyKey"}
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- {$ref: "#/components/schemas/TextMessageRequest"}
- {$ref: "#/components/schemas/FileMessageRequest"}
discriminator: {propertyName: content_kind}
responses:
"201": {description: Safety-allowed and delivered message}
"422": {description: Message blocked, content redacted}
"503": {$ref: "#/components/responses/DependencyUnavailable"}
/api/v1/dialogs/{dialog_id}/attachments/init:
parameters:
- {$ref: "#/components/parameters/DialogId"}
post:
operationId: initAttachment
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/AttachmentInitRequest"}
responses:
"201": {description: Upload metadata and presigned PUT}
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete:
parameters:
- {$ref: "#/components/parameters/DialogId"}
- {$ref: "#/components/parameters/AttachmentId"}
post:
operationId: completeAttachment
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/ChecksumRequest"}
responses:
"200": {description: Upload metadata verified}
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url:
parameters:
- {$ref: "#/components/parameters/DialogId"}
- {$ref: "#/components/parameters/AttachmentId"}
get:
operationId: getAttachmentDownloadUrl
security: [{bearerAuth: []}]
responses:
"200": {description: Audited short-lived presigned GET}
/internal/openlines/v1/inbox:
post:
operationId: applyOpenLinesInbox
security: [{serviceBearer: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/OpenLinesInbox"}
responses:
"200": {description: Duplicate acknowledged}
"201": {description: Event applied}
/internal/settings/v1/otp:
get:
operationId: getOtpSettings
security: [{serviceBearer: []}]
responses:
"200": {description: Product OTP limits and cache metadata}
"503": {$ref: "#/components/responses/DependencyUnavailable"}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
serviceBearer: {type: http, scheme: bearer}
parameters:
DialogId: {name: dialog_id, in: path, required: true, schema: {type: string, format: uuid}}
AttachmentId: {name: attachment_id, in: path, required: true, schema: {type: string, format: uuid}}
DocumentId: {name: document_id, in: path, required: true, schema: {type: string, format: uuid}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
responses:
Unauthorized:
description: JWT is absent, invalid or expired
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
NotFound:
description: Resource absent or owned by another user
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
DependencyUnavailable:
description: Required dependency is unavailable
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, request_id, details]
properties:
code: {type: string}
message: {type: string}
request_id: {type: string}
details: {type: object}
ConsentChoice:
type: object
additionalProperties: false
required: [accepted, version]
properties:
accepted: {type: boolean}
version: {type: string, maxLength: 64}
ConsentSet:
type: object
additionalProperties: false
required: [personal_data, user_agreement, marketing]
properties:
personal_data: {$ref: "#/components/schemas/ConsentChoice"}
user_agreement: {$ref: "#/components/schemas/ConsentChoice"}
marketing: {$ref: "#/components/schemas/ConsentChoice"}
Device:
type: object
additionalProperties: false
required: [platform, app_version]
properties:
platform: {type: string, enum: [ios, android, web]}
app_version: {type: string, maxLength: 64}
device_id: {type: [string, "null"], maxLength: 255}
BootstrapRequest:
type: object
additionalProperties: false
required: [consents, device]
properties:
consents: {$ref: "#/components/schemas/ConsentSet"}
device: {$ref: "#/components/schemas/Device"}
ConsentsRequest:
type: object
additionalProperties: false
required: [consents]
properties: {consents: {$ref: "#/components/schemas/ConsentSet"}}
SessionStartRequest:
type: object
additionalProperties: false
required: [start_reason, device]
properties:
start_reason: {type: string, enum: [first_launch, cold_start, idle_timeout]}
device: {$ref: "#/components/schemas/Device"}
TextMessageRequest:
type: object
additionalProperties: false
required: [content_kind, text]
properties:
content_kind: {const: text}
text: {type: string, minLength: 1, maxLength: 4000}
FileMessageRequest:
type: object
additionalProperties: false
required: [content_kind, attachment_id, checksum]
properties:
content_kind: {const: file}
attachment_id: {type: string, format: uuid}
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
AttachmentInitRequest:
type: object
additionalProperties: false
required: [file_name, mime_type, size_bytes]
properties:
file_name: {type: string, minLength: 1, maxLength: 255}
mime_type: {type: string, minLength: 1, maxLength: 128}
size_bytes: {type: integer, minimum: 1}
ChecksumRequest:
type: object
additionalProperties: false
required: [checksum]
properties:
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
OpenLinesInbox:
type: object
additionalProperties: false
required: [event_id, event_type, external_chat_id, occurred_at]
properties:
event_id: {type: string, minLength: 1, maxLength: 255}
event_type: {type: string, enum: [message.new, dialog.closed]}
external_chat_id: {type: string, format: uuid}
bitrix_message_id: {type: [string, "null"], maxLength: 255}
occurred_at: {type: string, format: date-time}
message: {type: [object, "null"]}
@@ -0,0 +1,67 @@
[project]
name = "han-api-backend"
version = "0.1.0"
description = "HAN Chat FastAPI backend"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
"boto3>=1.39,<2",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"phonenumbers>=9,<10",
"prometheus-client>=0.22,<1",
"pydantic-settings>=2.10,<3",
"pyyaml>=6,<7",
"pyjwt[crypto]>=2.10,<3",
"redis>=6,<7",
"sqlalchemy[asyncio]>=2.0.41,<3",
"structlog>=25,<26",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = [
"aiosqlite>=0.21,<1",
"mypy>=1.16,<2",
"pytest>=8.4,<9",
"pytest-asyncio>=1.0,<2",
"ruff>=0.12,<1",
"types-boto3-s3>=1.39,<2",
"types-PyYAML>=6,<7",
]
[project.scripts]
han-api = "app.main:run"
han-delivery-worker = "app.workers:delivery_main"
han-safety-worker = "app.workers:safety_main"
han-cleanup-worker = "app.workers:cleanup_main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
ignore = ["S101"]
[tool.mypy]
python_version = "3.12"
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
ignore_missing_imports = true
disable_error_code = ["no-untyped-def", "misc", "arg-type", "assignment"]
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
exclude = ["alembic/"]
@@ -0,0 +1,155 @@
import uuid
import httpx
import pytest
from app.integrations import (
DependencyFailure,
OpenLinesClient,
SafetyClient,
fresh_openlines_payload,
)
from app.settings import Settings
def settings() -> Settings:
common = {
"APP_ENV": "test",
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"REDIS_URL": "redis://localhost/0",
"REDIS_REALTIME_URL": "redis://localhost/1",
"KEYCLOAK_PUBLIC_URL": "https://auth.example",
"KEYCLOAK_INTERNAL_URL": "http://keycloak:8080",
"KEYCLOAK_REALM": "han",
"KEYCLOAK_AUDIENCE": "api",
"MESSAGE_SAFETY_URL": "http://safety:8080",
"MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token",
"BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080",
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token",
"BITRIX_API_INBOX_TOKEN": "inbox-token",
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": "settings-token",
"SELECTEL_S3_ENDPOINT_URL": "https://s3.example",
"SELECTEL_S3_BUCKET_DOCUMENTS": "documents",
"SELECTEL_S3_BUCKET_ATTACHMENTS": "attachments",
"SELECTEL_S3_BUCKET_QUARANTINE": "quarantine",
"SELECTEL_S3_ACCESS_KEY": "access",
"SELECTEL_S3_SECRET_KEY": "secret",
"CURSOR_HMAC_SECRET": "x" * 32,
}
return Settings.model_validate(common)
@pytest.mark.asyncio
async def test_safety_contract_status_and_service_token() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["X-Service-Token"] == "safety-token"
assert request.url.path == "/internal/safety/v1/messages/check"
return httpx.Response(203, json={"verdict": "pending", "task_id": "task-1"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
result = await SafetyClient(settings(), http).check(
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
"request-1",
)
assert result == {"verdict": "pending", "task_id": "task-1", "_status": 203}
@pytest.mark.asyncio
async def test_safety_file_body_uses_exact_attachment_schema() -> None:
attachment_id = uuid.uuid4()
async def handler(request: httpx.Request) -> httpx.Response:
body = __import__("json").loads(request.content)
assert body["attachment"] == {
"attachment_id": str(attachment_id),
"quarantine_object_key": "quarantine/users/u/file",
"mime_type": "application/pdf",
"size_bytes": 42,
"checksum": "sha256:" + "a" * 64,
}
assert "file" not in body
return httpx.Response(200, json={"verdict": "allow"})
payload = {
"message_id": str(uuid.uuid4()),
"content_kind": "file",
"text": "",
"attachment": {
"attachment_id": str(attachment_id),
"quarantine_object_key": "quarantine/users/u/file",
"mime_type": "application/pdf",
"size_bytes": 42,
"checksum": "sha256:" + "a" * 64,
},
}
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
await SafetyClient(settings(), http).check(payload, "request-1")
@pytest.mark.asyncio
async def test_safety_auth_failure_is_dependency_failure() -> None:
async def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": {"code": "service_unauthorized"}})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
with pytest.raises(DependencyFailure):
await SafetyClient(settings(), http).check(
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
"request-1",
)
@pytest.mark.asyncio
async def test_openlines_contract_uses_bearer_and_idempotency() -> None:
message_id = uuid.uuid4()
async def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["Authorization"] == "Bearer bitrix-token"
assert request.headers["Idempotency-Key"] == str(message_id)
assert request.url.path == "/internal/openlines/v1/messages"
return httpx.Response(201, json={"status": "accepted"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
result = await OpenLinesClient(settings(), http).send(
message_id, {"message_id": str(message_id)}, "request-1"
)
assert result["status"] == "accepted"
@pytest.mark.asyncio
async def test_openlines_payload_gets_fresh_download_url_without_storage_fields() -> None:
class FakeS3:
calls = 0
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
self.calls += 1
return f"https://download.example/{bucket}/{key}?generation={self.calls}"
payload = {
"message_id": str(uuid.uuid4()),
"external_chat_id": str(uuid.uuid4()),
"occurred_at": "2026-07-10T12:00:00+00:00",
"user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"},
"message": {
"content_kind": "file",
"text": "",
"files": [{
"attachment_id": str(uuid.uuid4()),
"name": "file.pdf",
"mime_type": "application/pdf",
"size_bytes": 42,
"_storage_bucket": "attachments",
"_object_key": "dialogs/d/file",
}],
},
}
s3 = FakeS3()
first = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
second = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
assert first["occurred_at"] and first["user"]["id"]
assert first["message"]["content_kind"] == "file"
assert (
first["message"]["files"][0]["download_url"]
!= second["message"]["files"][0]["download_url"]
)
assert all(not key.startswith("_") for key in first["message"]["files"][0])
@@ -0,0 +1,58 @@
import base64
from pathlib import Path
from types import SimpleNamespace
import yaml
from app.main import app, websocket_token
EXPECTED_PATHS = {
"/health/live",
"/health/ready",
"/api/v1/public/app-config",
"/api/v1/public/content",
"/api/v1/auth/bootstrap",
"/api/v1/consents",
"/api/v1/analytics/session-start",
"/api/v1/me",
"/api/v1/me/documents",
"/api/v1/documents/{document_id}",
"/api/v1/documents/{document_id}/download-url",
"/api/v1/dialogs",
"/api/v1/dialogs/{dialog_id}",
"/api/v1/dialogs/{dialog_id}/messages",
"/api/v1/dialogs/{dialog_id}/attachments/init",
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
"/internal/openlines/v1/inbox",
"/internal/settings/v1/otp",
}
def test_openapi_31_contains_all_http_contracts() -> None:
schema = app.openapi()
assert schema["openapi"].startswith("3.1.")
assert EXPECTED_PATHS <= schema["paths"].keys()
assert all(not path.startswith("/internal/safety") for path in schema["paths"])
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
assert committed["openapi"] == "3.1.0"
assert committed["paths"].keys() == schema["paths"].keys()
def test_websocket_route_is_registered() -> None:
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
jwt = "header.payload.signature"
encoded = base64.urlsafe_b64encode(jwt.encode()).decode().rstrip("=")
websocket = SimpleNamespace(
headers={"sec-websocket-protocol": f"han-chat-v1, han.jwt.{encoded}"},
query_params={},
)
assert websocket_token(websocket) == (jwt, f"han.jwt.{encoded}")
def test_committed_openapi_server_does_not_double_api_prefix() -> None:
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
assert committed["servers"] == [{"url": "/"}]
@@ -0,0 +1,26 @@
from pathlib import Path
import pytest
from app.cli.seed_settings import load_seed
from app.services import REQUIRED_SETTINGS
def test_production_like_seed_contains_all_mandatory_settings() -> None:
path = Path(__file__).resolve().parents[3] / "deployment/app-settings.production-like.yaml"
rows = load_seed(path)
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
assert all(row["record_status"] == "A" for row in rows)
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
" bad.integer: {type: integer, value: nope, public: false}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="integer value expected"):
load_seed(path)
@@ -0,0 +1,80 @@
import uuid
import pytest
from pydantic import TypeAdapter, ValidationError
from app.auth import canonical_phone
from app.integrations import CircuitBreaker, RateLimiter
from app.schemas import (
FileMessageRequest,
MessageRequest,
TextMessageRequest,
canonical_fingerprint,
decode_cursor,
encode_cursor,
)
def test_phone_claim_priority_and_e164_validation() -> None:
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
assert canonical_phone(claims) == "+74999591007"
assert canonical_phone({"phone_number": "89999999999"}) is None
def test_message_discriminated_union() -> None:
adapter = TypeAdapter(MessageRequest)
assert isinstance(
adapter.validate_python({"content_kind": "text", "text": "Здравствуйте"}),
TextMessageRequest,
)
assert isinstance(
adapter.validate_python(
{
"content_kind": "file",
"attachment_id": str(uuid.uuid4()),
"checksum": "sha256:" + "a" * 64,
}
),
FileMessageRequest,
)
with pytest.raises(ValidationError):
adapter.validate_python(
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
)
def test_fingerprint_is_canonical_and_user_scoped() -> None:
user = uuid.uuid4()
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
second = canonical_fingerprint("POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, user)
assert first == second
assert first != canonical_fingerprint(
"POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, uuid.uuid4()
)
def test_cursor_roundtrip_and_tamper_rejection() -> None:
secret = b"test-secret" * 4
cursor = encode_cursor({"id": str(uuid.uuid4()), "created_at": "2026-01-01"}, secret)
assert decode_cursor(cursor, secret)["created_at"] == "2026-01-01"
with pytest.raises(ValueError, match="invalid cursor"):
decode_cursor(cursor[:-2] + "aa", secret)
def test_rate_limit_keys_do_not_expose_identity() -> None:
key = RateLimiter.key("ip", "203.0.113.7", "public", 60)
assert "203.0.113.7" not in key
assert key.startswith("han:api:rl:ip:")
def test_circuit_breaker_opens_and_half_opens(monkeypatch: pytest.MonkeyPatch) -> None:
clock = [10.0]
monkeypatch.setattr("app.integrations.time.monotonic", lambda: clock[0])
breaker = CircuitBreaker(2, 30)
breaker.failure()
breaker.failure()
assert not breaker.allow()
clock[0] = 41.0
assert breaker.allow()
breaker.success()
assert breaker.allow()
@@ -0,0 +1,12 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /service
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini pyproject.toml ./
RUN pip install --no-cache-dir .
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
@@ -0,0 +1,30 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://unused
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
@@ -0,0 +1,42 @@
import asyncio
import os
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.models import Base
config = context.config
url = os.environ["BITRIX_DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
config.set_main_option("sqlalchemy.url", url)
target_metadata = Base.metadata
def run_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
include_schemas=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata, include_schemas=True)
with context.begin_transaction():
context.run_migrations()
async def run_online() -> None:
engine = async_engine_from_config(config.get_section(config.config_ini_section) or {})
async with engine.connect() as connection:
await connection.run_sync(do_run)
await engine.dispose()
if context.is_offline_mode():
run_offline()
else:
asyncio.run(run_online())
@@ -0,0 +1,20 @@
"""Create durable OAuth, mapping, inbox, outbox, setup and audit storage."""
from alembic import op
from app.models import Base
revision = "0001_bitrix_local"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
op.execute("CREATE SCHEMA IF NOT EXISTS bitrix_local")
Base.metadata.create_all(bind=bind, checkfirst=False)
def downgrade() -> None:
Base.metadata.drop_all(bind=op.get_bind(), checkfirst=True)
@@ -0,0 +1 @@
"""HAN Bitrix24 Open Lines adapter."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SCHEMA = "bitrix_local"
def now() -> datetime:
return datetime.now(UTC)
class Base(DeclarativeBase):
pass
class Common:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
record_status: Mapped[str] = mapped_column(String(1), default="A")
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status_change_reason: Mapped[str | None] = mapped_column(String(255))
class PortalInstallation(Common, Base):
__tablename__ = "portal_installations"
__table_args__ = (
Index(
"uq_portal_active_domain",
"domain",
unique=True,
postgresql_where=text("record_status = 'A'"),
),
{"schema": SCHEMA},
)
member_id: Mapped[str] = mapped_column(String(128), unique=True)
domain: Mapped[str] = mapped_column(String(255))
client_endpoint: Mapped[str] = mapped_column(String(1024))
access_ciphertext: Mapped[str] = mapped_column(Text)
access_nonce: Mapped[str] = mapped_column(String(64))
refresh_ciphertext: Mapped[str] = mapped_column(Text)
refresh_nonce: Mapped[str] = mapped_column(String(64))
application_ciphertext: Mapped[str] = mapped_column(Text)
application_nonce: Mapped[str] = mapped_column(String(64))
key_version: Mapped[str] = mapped_column(String(32))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scope: Mapped[str | None] = mapped_column(Text)
install_status: Mapped[str] = mapped_column(String(32), default="installed")
setup_status: Mapped[str] = mapped_column(String(32), default="pending")
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
class ConnectorSetup(Common, Base):
__tablename__ = "connector_setup"
__table_args__ = (
UniqueConstraint("portal_id", "connector_id", "line_id"),
Index("ix_setup_retry", "next_retry_at", "attempt_count"),
{"schema": SCHEMA},
)
portal_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
)
connector_id: Mapped[str] = mapped_column(String(64))
line_id: Mapped[str] = mapped_column(String(32))
registered: Mapped[bool] = mapped_column(default=False)
activated: Mapped[bool] = mapped_column(default=False)
bindings_json: Mapped[dict] = mapped_column(JSON, default=dict)
desired_version: Mapped[str] = mapped_column(String(32), default="1")
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
class DialogSession(Common, Base):
__tablename__ = "dialog_sessions"
__table_args__ = (
Index(
"uq_dialog_active_external_chat",
"external_chat_id",
unique=True,
postgresql_where=text("record_status = 'A'"),
),
Index("ix_dialog_bitrix_chat", "bitrix_chat_id"),
Index("ix_dialog_session", "session_id"),
Index("ix_dialog_status_updated", "status", "updated_at"),
{"schema": SCHEMA},
)
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
bitrix_chat_id: Mapped[int | None] = mapped_column(BigInteger)
session_id: Mapped[str | None] = mapped_column(String(255))
portal_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
)
status: Mapped[str] = mapped_column(String(16), default="open")
class InboxEvent(Common, Base):
__tablename__ = "inbox_events"
__table_args__ = (
UniqueConstraint("event_id"),
Index(
"uq_inbox_chat_message",
"external_chat_id",
"bitrix_message_id",
unique=True,
postgresql_where=text("bitrix_message_id IS NOT NULL"),
),
Index("ix_inbox_worker", "status", "next_attempt_at"),
{"schema": SCHEMA},
)
event_id: Mapped[str] = mapped_column(String(255))
event_type: Mapped[str] = mapped_column(String(64))
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
payload_fingerprint: Mapped[str] = mapped_column(String(64))
normalized_json: Mapped[dict] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(32), default="received")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
api_ack_status: Mapped[str | None] = mapped_column(String(32))
delivery_ack_status: Mapped[str | None] = mapped_column(String(32))
last_error_code: Mapped[str | None] = mapped_column(String(64))
class OutboundMessage(Common, Base):
__tablename__ = "outbound_messages"
__table_args__ = (Index("ix_outbound_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), unique=True)
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
request_fingerprint: Mapped[str] = mapped_column(String(64))
payload_json: Mapped[dict] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(32), default="received")
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
response_json: Mapped[dict | None] = mapped_column(JSON)
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
class DeliveryAckOutbox(Common, Base):
__tablename__ = "delivery_ack_outbox"
__table_args__ = (Index("ix_ack_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
inbox_event_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
)
payload_json: Mapped[dict] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(32), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
class InstallRun(Common, Base):
__tablename__ = "install_runs"
__table_args__ = ({"schema": SCHEMA},)
portal_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
status: Mapped[str] = mapped_column(String(32))
result_json: Mapped[dict] = mapped_column(JSON, default=dict)
class AuditEvent(Common, Base):
__tablename__ = "audit_events"
__table_args__ = (Index("ix_audit_created", "created_at"), {"schema": SCHEMA})
event_type: Mapped[str] = mapped_column(String(128))
actor_type: Mapped[str] = mapped_column(String(32), default="system")
safe_details: Mapped[dict] = mapped_column(JSON, default=dict)
@@ -0,0 +1,104 @@
openapi: 3.1.0
info: {title: HAN Bitrix24 Local App, version: 1.0.0}
paths:
/bitrix/handler:
get: {responses: {"200": {description: Callback probe}}}
post:
requestBody:
required: true
content:
application/json: {schema: {type: object}}
application/x-www-form-urlencoded: {schema: {type: object}}
multipart/form-data: {schema: {type: object}}
responses:
"200": {description: Duplicate or ignored callback}
"202": {description: Durably accepted callback}
"400": {description: Invalid callback}
"403": {description: Invalid application token}
/bitrix/install:
get: {responses: {"200": {description: Install probe}}}
post:
responses:
"200": {description: OAuth saved and setup attempted}
"400": {description: Invalid install callback}
/bitrix/placement:
get: {responses: {"200": {description: Connector placement HTML}}}
/health/live:
get: {responses: {"200": {description: Live}}}
/health/ready:
get: {responses: {"200": {description: Ready}, "503": {description: Not ready}}}
/internal/openlines/v1/messages:
post:
security: [{BearerAuth: []}]
parameters:
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
- {$ref: "#/components/parameters/RequestId"}
requestBody:
required: true
content:
application/json: {schema: {$ref: "#/components/schemas/OutboundMessage"}}
responses:
"201": {description: Delivered}
"200": {description: Idempotent duplicate}
"400": {description: Invalid request}
"401": {description: Unauthorized}
"409": {description: Idempotency key reused}
"503": {description: Dependency unavailable or ambiguous delivery}
/internal/openlines/v1/dialogs/{external_chat_id}:
get:
security: [{BearerAuth: []}]
parameters:
- {name: external_chat_id, in: path, required: true, schema: {type: string, format: uuid}}
responses:
"200": {description: Active dialog mapping}
"404": {description: Mapping not found}
/internal/openlines/v1/status:
get:
security: [{BearerAuth: []}]
responses: {"200": {description: Safe adapter status}}
/internal/openlines/v1/setup/retry:
post:
security: [{BearerAuth: []}]
responses: {"200": {description: Idempotent setup reconcile result}}
components:
securitySchemes:
BearerAuth: {type: http, scheme: bearer}
parameters:
RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}}
schemas:
OutboundFile:
type: object
additionalProperties: false
required: [attachment_id, name, mime_type, size_bytes, download_url]
properties:
attachment_id: {type: string, format: uuid}
name: {type: string, maxLength: 255}
mime_type: {type: string, maxLength: 255}
size_bytes: {type: integer, minimum: 1, maximum: 5242880}
download_url: {type: string, maxLength: 4096}
OutboundMessage:
type: object
additionalProperties: false
required: [message_id, external_chat_id, occurred_at, user, message]
properties:
message_id: {type: string, format: uuid}
external_chat_id: {type: string, format: uuid}
occurred_at: {type: string, format: date-time}
user:
type: object
additionalProperties: false
required: [id, display_name]
properties:
id: {type: string, format: uuid}
display_name: {type: string, maxLength: 255}
message:
type: object
additionalProperties: false
required: [content_kind, text, files]
properties:
content_kind: {type: string, enum: [text, file]}
text: {type: string, maxLength: 10000}
files:
type: array
maxItems: 1
items: {$ref: "#/components/schemas/OutboundFile"}
@@ -0,0 +1,33 @@
[project]
name = "han-bitrix-local-app"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
"cryptography>=45,<46",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"pydantic-settings>=2.10,<3",
"python-multipart>=0.0.20,<1",
"sqlalchemy[asyncio]>=2.0.41,<3",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = ["pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
@@ -0,0 +1,70 @@
import base64
import os
import uuid
os.environ.setdefault("BITRIX_DATABASE_URL", "postgresql://unused/unused")
os.environ.setdefault("BITRIX_CLIENT_ID", "client")
os.environ.setdefault("BITRIX_CLIENT_SECRET", "secret")
os.environ.setdefault("BITRIX_APPLICATION_TOKEN", "application-token")
os.environ.setdefault("BITRIX_INTERNAL_API_TOKEN", "internal-token-32-characters-long")
os.environ.setdefault("BITRIX_API_FORWARD_URL", "http://api/internal/openlines/v1/inbox")
os.environ.setdefault("BITRIX_API_FORWARD_TOKEN", "forward-token-32-characters-long")
os.environ.setdefault(
"BITRIX_TOKEN_ENCRYPTION_KEY",
base64.urlsafe_b64encode(b"k" * 32).decode().rstrip("="),
)
import pytest
from app.main import (
TokenCipher,
canonical_fingerprint,
normalize_event,
retry_delay,
validate_portal,
)
def test_token_cipher_binds_aad():
cipher = TokenCipher(os.environ["BITRIX_TOKEN_ENCRYPTION_KEY"], "v1")
ciphertext, nonce = cipher.encrypt("secret", "member", "han0107.bitrix24.ru", "access")
assert cipher.decrypt(ciphertext, nonce, "member", "han0107.bitrix24.ru", "access") == "secret"
with pytest.raises(Exception):
cipher.decrypt(ciphertext, nonce, "other", "han0107.bitrix24.ru", "access")
def test_normalize_message_and_finish():
external = str(uuid.uuid4())
message = normalize_event(
{
"event": "ONIMCONNECTORMESSAGEADD",
"data": {
"MESSAGES": [
{
"chat": {"id": external},
"message": {"id": "b-1", "text": "Ответ", "files": []},
}
]
},
}
)
assert message["event_type"] == "message.new"
assert message["external_chat_id"] == external
closed = normalize_event(
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
)
assert closed["event_type"] == "dialog.closed"
def test_fingerprint_ignores_signed_query_and_portal_validation():
payload = {"message": {"files": [{"download_url": "https://s3/object?sig=one"}]}}
other = {"message": {"files": [{"download_url": "https://s3/object?sig=two"}]}}
assert canonical_fingerprint(payload) == canonical_fingerprint(other)
validate_portal(
"han0107.bitrix24.ru",
"https://han0107.bitrix24.ru/rest/",
"han0107.bitrix24.ru",
)
with pytest.raises(ValueError):
validate_portal("evil.example", "https://evil.example/rest/", "han0107.bitrix24.ru")
assert 0 <= retry_delay(4, 300) <= 8
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /service
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini pyproject.toml ./
RUN pip install --no-cache-dir .
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+30
View File
@@ -0,0 +1,30 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://unused
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
@@ -0,0 +1,34 @@
import asyncio
import os
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
config = context.config
config.set_main_option("sqlalchemy.url", os.environ["BITRIX_SYNC_DATABASE_URL"])
target_metadata = None
def run_offline() -> None:
context.configure(url=config.get_main_option("sqlalchemy.url"), literal_binds=True)
with context.begin_transaction():
context.run_migrations()
async def run_online() -> None:
engine = async_engine_from_config(config.get_section(config.config_ini_section) or {})
async with engine.connect() as connection:
await connection.run_sync(do_run)
await engine.dispose()
def do_run(connection) -> None:
context.configure(connection=connection)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_offline()
else:
asyncio.run(run_online())
@@ -0,0 +1,15 @@
"""Establish the bitrix-sync connectivity-stub migration baseline."""
revision = "0001_sync_baseline"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# The connectivity stub deliberately owns no runtime tables.
pass
def downgrade() -> None:
pass
@@ -0,0 +1 @@
"""HAN bitrix-sync DB connectivity stub."""
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import asyncio
import hmac
import random
import time
import uuid
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Annotated, Protocol
import uvicorn
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
def utcnow() -> datetime:
return datetime.now(UTC)
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
app_env: str = "production-like"
bitrix_sync_enabled: bool = True
bitrix_sync_database_url: str | None = None
bitrix_sync_service_token: str = Field(min_length=16)
bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05)
bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05)
bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5)
bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05)
bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05)
bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1)
bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5)
bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30)
@model_validator(mode="after")
def validate_enabled(self) -> Settings:
if self.bitrix_sync_enabled and not self.bitrix_sync_database_url:
raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled")
return self
@dataclass
class Snapshot:
state: str
started_at: datetime
last_started_at: datetime | None = None
last_finished_at: datetime | None = None
last_success_at: datetime | None = None
success: bool | None = None
duration_ms: int | None = None
error_code: str | None = None
consecutive_failures: int = 0
next_check_at: datetime | None = None
worker_running: bool = False
class Probe(Protocol):
async def check(self) -> None: ...
async def close(self) -> None: ...
class DatabaseProbe:
def __init__(self, engine: AsyncEngine) -> None:
self.engine = engine
async def check(self) -> None:
async with self.engine.connect() as connection:
result = await connection.scalar(text("SELECT 1"))
if result != 1:
raise RuntimeError("unexpected_result")
async def close(self) -> None:
await self.engine.dispose()
def database_url(value: str) -> str:
if value.startswith("postgresql://"):
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
return value
def classify_error(exc: Exception) -> str:
name = type(exc).__name__.lower()
text_value = str(exc).lower()
if isinstance(exc, TimeoutError):
return "db_query_timeout"
if "auth" in name or "password" in text_value:
return "db_auth_failed"
if "ssl" in name or "tls" in text_value or "certificate" in text_value:
return "db_tls_failed"
if str(exc) == "unexpected_result":
return "unexpected_result"
return "db_unavailable"
def jsonable(value):
if isinstance(value, datetime):
return value.isoformat().replace("+00:00", "Z")
return value
def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI:
cfg = settings or Settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.snapshot = Snapshot(
state="disabled" if not cfg.bitrix_sync_enabled else "starting",
started_at=utcnow(),
)
app.state.stop = asyncio.Event()
app.state.probe = probe
app.state.loop_task = None
if cfg.bitrix_sync_enabled:
if app.state.probe is None:
engine = create_async_engine(
database_url(cfg.bitrix_sync_database_url or ""),
pool_size=cfg.bitrix_sync_db_pool_size,
max_overflow=0,
pool_pre_ping=True,
pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec,
connect_args={"server_settings": {"application_name": "han-bitrix-sync"}},
)
app.state.probe = DatabaseProbe(engine)
await run_probe(app)
app.state.snapshot.worker_running = True
app.state.loop_task = asyncio.create_task(
periodic_loop(app), name="db-connectivity-probe"
)
yield
app.state.snapshot.state = "stopping"
app.state.stop.set()
if app.state.loop_task:
app.state.loop_task.cancel()
with suppress(asyncio.CancelledError):
await app.state.loop_task
if app.state.probe:
await app.state.probe.close()
app = FastAPI(
title="HAN Bitrix Sync Connectivity Stub",
version="1.0.0",
lifespan=lifespan,
docs_url=None if cfg.app_env != "test" else "/docs",
)
app.state.settings = cfg
@app.middleware("http")
async def request_id(request: Request, call_next):
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
return response
@app.exception_handler(HTTPException)
async def http_error(_: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content=exc.detail)
@app.get("/health/live")
async def live():
return {"status": "live"}
@app.get("/health/ready")
async def ready(request: Request):
snapshot: Snapshot = request.app.state.snapshot
if not cfg.bitrix_sync_enabled:
return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503)
age = (
(utcnow() - snapshot.last_success_at).total_seconds()
if snapshot.last_success_at
else None
)
max_age = max(
cfg.bitrix_sync_ready_max_staleness_sec,
2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio),
)
is_ready = (
snapshot.state == "healthy"
and snapshot.worker_running
and age is not None
and age <= max_age
)
if is_ready:
return {
"status": "ready",
"mode": "db_connectivity_stub",
"database": {
"status": "ok",
"last_success_at": jsonable(snapshot.last_success_at),
"age_seconds": round(age or 0, 3),
},
}
reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable"
return JSONResponse(
{
"status": "not_ready",
"reason": reason,
"database": {
"status": "down",
"last_success_at": jsonable(snapshot.last_success_at),
"consecutive_failures": snapshot.consecutive_failures,
},
},
status_code=503,
)
@app.get("/internal/sync/v1/status")
async def status(
request: Request,
authorization: Annotated[str | None, Header()] = None,
):
candidate = (
authorization[7:] if authorization and authorization.startswith("Bearer ") else ""
)
if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token):
raise HTTPException(
401,
{
"error": {
"code": "service_unauthorized",
"message": "Service authentication failed",
"request_id": request.state.request_id,
"details": {},
}
},
)
snapshot: Snapshot = request.app.state.snapshot
last_check = None
if snapshot.last_started_at:
last_check = {
"started_at": jsonable(snapshot.last_started_at),
"finished_at": jsonable(snapshot.last_finished_at),
"success": snapshot.success,
"duration_ms": snapshot.duration_ms,
"error_code": snapshot.error_code,
}
next_in = (
max(0, (snapshot.next_check_at - utcnow()).total_seconds())
if snapshot.next_check_at
else None
)
return {
"service": "bitrix-sync",
"enabled": cfg.bitrix_sync_enabled,
"mode": "db_connectivity_stub",
"crm_sync_implemented": False,
"state": snapshot.state,
"started_at": jsonable(snapshot.started_at),
"last_check": last_check,
"last_success_at": jsonable(snapshot.last_success_at),
"consecutive_failures": snapshot.consecutive_failures,
"next_check_in_seconds": round(next_in, 3) if next_in is not None else None,
}
return app
async def run_probe(app: FastAPI) -> None:
cfg: Settings = app.state.settings
snapshot: Snapshot = app.state.snapshot
snapshot.last_started_at = utcnow()
started = time.monotonic()
try:
async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec):
await app.state.probe.check()
except Exception as exc:
snapshot.success = False
snapshot.error_code = classify_error(exc)
snapshot.consecutive_failures += 1
snapshot.state = "degraded"
else:
snapshot.success = True
snapshot.error_code = None
snapshot.consecutive_failures = 0
snapshot.last_success_at = utcnow()
snapshot.state = "healthy"
finally:
snapshot.last_finished_at = utcnow()
snapshot.duration_ms = round((time.monotonic() - started) * 1000)
async def periodic_loop(app: FastAPI) -> None:
cfg: Settings = app.state.settings
snapshot: Snapshot = app.state.snapshot
try:
while not app.state.stop.is_set():
if snapshot.consecutive_failures:
cap = min(
cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1),
cfg.bitrix_sync_db_retry_max_sec,
)
delay = random.uniform(0, cap)
else:
jitter = (
cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio
)
delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter)
snapshot.next_check_at = utcnow() + timedelta(seconds=delay)
try:
await asyncio.wait_for(app.state.stop.wait(), timeout=delay)
break
except TimeoutError:
await run_probe(app)
finally:
snapshot.worker_running = False
_settings = Settings()
app = create_app(_settings)
def run() -> None:
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
+46
View File
@@ -0,0 +1,46 @@
openapi: 3.1.0
info: {title: HAN Bitrix Sync Connectivity Stub, version: 1.0.0}
paths:
/health/live:
get:
responses:
"200":
description: Process is live
content: {application/json: {schema: {$ref: "#/components/schemas/Live"}}}
/health/ready:
get:
responses:
"200": {description: Latest PostgreSQL probe is fresh and successful}
"503": {description: Disabled, stale, or database unavailable}
/internal/sync/v1/status:
get:
security: [{BearerAuth: []}]
parameters:
- {name: X-Request-ID, in: header, required: false, schema: {type: string}}
responses:
"200":
description: Connectivity-loop status
content: {application/json: {schema: {$ref: "#/components/schemas/Status"}}}
"401": {description: Service authentication failed}
components:
securitySchemes:
BearerAuth: {type: http, scheme: bearer}
schemas:
Live:
type: object
required: [status]
properties: {status: {const: live}}
Status:
type: object
required: [service, enabled, mode, crm_sync_implemented, state, started_at]
properties:
service: {const: bitrix-sync}
enabled: {type: boolean}
mode: {const: db_connectivity_stub}
crm_sync_implemented: {const: false}
state: {type: string, enum: [starting, disabled, healthy, degraded, stopping]}
started_at: {type: string, format: date-time}
last_check: {type: [object, "null"]}
last_success_at: {type: [string, "null"], format: date-time}
consecutive_failures: {type: integer, minimum: 0}
next_check_in_seconds: {type: [number, "null"]}
@@ -0,0 +1,30 @@
[project]
name = "han-bitrix-sync"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
"fastapi>=0.116,<1",
"pydantic-settings>=2.10,<3",
"sqlalchemy[asyncio]>=2.0.41,<3",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
@@ -0,0 +1,65 @@
import os
os.environ.setdefault("BITRIX_SYNC_ENABLED", "false")
os.environ.setdefault("BITRIX_SYNC_SERVICE_TOKEN", "test-sync-token-32-characters")
import httpx
import pytest
from app.main import Settings, create_app
class Probe:
def __init__(self, fail=False):
self.fail = fail
self.calls = 0
async def check(self):
self.calls += 1
if self.fail:
raise OSError("down")
async def close(self):
pass
@pytest.mark.asyncio
async def test_disabled_semantics():
settings = Settings(
bitrix_sync_enabled=False,
bitrix_sync_service_token="test-sync-token-32-characters",
)
app = create_app(settings)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
assert (await client.get("/health/live")).status_code == 200
ready = await client.get("/health/ready")
assert ready.status_code == 503
assert ready.json()["reason"] == "sync_disabled"
status = await client.get(
"/internal/sync/v1/status",
headers={"Authorization": "Bearer test-sync-token-32-characters"},
)
assert status.json()["state"] == "disabled"
assert status.json()["crm_sync_implemented"] is False
@pytest.mark.asyncio
async def test_initial_probe_and_auth():
probe = Probe()
settings = Settings(
bitrix_sync_enabled=True,
bitrix_sync_database_url="postgresql://unused/unused",
bitrix_sync_service_token="test-sync-token-32-characters",
bitrix_sync_db_check_interval_sec=60,
)
app = create_app(settings, probe)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
assert probe.calls == 1
assert (await client.get("/health/ready")).status_code == 200
assert (await client.get("/internal/sync/v1/status")).status_code == 401
+207
View File
@@ -0,0 +1,207 @@
# HAN Chat production-like deployment runbook
This is the executable checklist for the single-VM contour. PostgreSQL and S3
are managed external services. Never use `docker compose down -v`, an Alembic
downgrade, or a mutable image tag during deployment.
## Gate 0 — decisions and ownership
- [ ] Release SHA/digests, maintenance window, on-call and rollback owner recorded.
- [ ] RPO/RTO accepted; initial targets are PG RPO <=15 minutes and RTO <=4 hours.
- [ ] Remote OTLP backend selected, or debug-only acceptance limitation accepted.
- [ ] Mock OTP, Safety stub and bitrix-sync stub risks explicitly accepted.
## Gate 1 — VPC, DNS and security groups
- [ ] Managed PostgreSQL has only a private endpoint and accepts traffic from VM SG.
- [ ] Internet can reach only VM TCP 80/443; SSH is restricted to VPN/ops CIDR.
- [ ] Ports 6379, 4317/4318, 8000, 8080 and 9000 are denied externally.
- [ ] DNS `A` for `PUBLIC_HOST` points at the VM and outbound HTTPS is available.
## Gate 2 — VM hardening
- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active.
- [ ] Key-only deploy account works in a second session; root/password SSH is off.
- [ ] UFW/cloud SG and `DOCKER-USER` policy survive reboot.
- [ ] Docker Engine and Compose support `include` and long-form `env_file`.
## Gate 3 — managed PostgreSQL
- [ ] Daily backup, PITR, deletion protection, encryption and alerts are enabled.
- [ ] Provider CA is installed at `PG_CA_HOST_PATH`; all DSNs use `verify-full`.
- [ ] Schemas `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
have separate migration/runtime roles with tested negative grants.
- [ ] Migration tested against an empty DB and a clone of the previous release.
## Gate 4 — Selectel S3
- [ ] Quarantine, attachments and documents buckets are private and encrypted.
- [ ] API credentials are prefix-scoped; Safety credentials are quarantine read-only.
- [ ] Browser CORS permits exact HTTPS origin and PUT headers only.
- [ ] Quarantine lifecycle exceeds Safety poll/recovery; data retention is approved.
## Gate 5 — immutable release
- [ ] Checkout is detached at the approved SHA and working tree is clean.
- [ ] Service images are immutable and scanned; no unresolved critical/high issue.
- [ ] Root `docker-compose.yml` is the only deployment entry point.
## Gate 6 — environment and secrets
```sh
umask 077
cp .env.example .env
chmod 600 .env
# Replace placeholders using a protected editor/secret manager.
./scripts/validate-env .env
docker compose --env-file .env config --quiet
```
- [ ] Token pairs match, PG verifies TLS, public URLs are HTTPS.
- [ ] Mock OTP risk is accepted and all secrets are unique >=128-bit values.
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
## Gate 7 — images and static frontend
```sh
docker compose --env-file .env pull
docker compose --env-file .env build --pull frontend-static nginx redis
docker compose --env-file .env run --rm frontend-static
```
- [ ] Frontend export was tested/scanned and copied by `frontend-static` into its named volume.
- [ ] Build artifacts contain no secrets or unintended source maps.
- [ ] At least 30% VM disk remains free.
## Gate 8 — topology
```sh
docker compose --env-file .env config --services
python3 -m unittest discover -s tests -v
```
- [ ] Exactly nginx publishes `80:80` and `443:443`; no PostgreSQL service exists.
- [ ] Redis AOF/RDB/ACL and OTEL persistent queue volumes are present.
- [ ] `backend` and `observability` are internal networks.
## Gate 9 — ACME/TLS bootstrap
Set `NGINX_TLS_ENABLED=false` only for this bootstrap command:
```sh
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
docker compose --profile certbot run --rm certbot certonly \
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
docker compose --env-file .env up -d --force-recreate nginx
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
```
First rehearse with Certbot `--staging`. Install a twice-daily systemd timer for
`deployment/scripts/ssl-renew.sh`; test `certbot renew --dry-run`. Enable HSTS
only after chain, hostname, redirect and TLS 1.2/1.3 checks pass.
## Gate 10 — migrations and seed
Create a provider PITR marker, then:
```sh
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
deployment/scripts/seed.sh
```
- [ ] Expected Alembic revisions are active and runtime users did not perform DDL.
- [ ] Seed succeeds twice and mandatory settings contain no secret.
- [ ] Schema remains backward-compatible with the previous images.
## Gate 11 — Keycloak
```sh
docker compose up -d keycloak
docker compose ps keycloak
```
- [ ] Discovery/JWKS issuer is the exact public `/auth` HTTPS URL.
- [ ] Frontend client is public PKCE S256; implicit/password/social flows are off.
- [ ] Wrong/replayed OTP and limits fail safely; settings bridge is fail-closed.
- [ ] Bootstrap admin was removed/rotated and named admin MFA is enabled.
## Gate 12 — ordered startup and readiness
```sh
docker compose up -d redis
docker compose up -d keycloak otel-collector
docker compose up -d message-safety
docker compose up -d api-backend
docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx
docker compose ps
```
- [ ] No restart loop/OOM; critical readiness is green.
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
- [ ] External `/internal/*` is 404 and OTEL accepts telemetry.
## Gate 13 — Bitrix24
- [ ] Install, handler and placement URLs use the exact public HTTPS paths.
- [ ] Connector `han_mobile_app` is active on Open Line 8; events are bound once.
- [ ] OAuth is encrypted; callback/application/service tokens never enter logs.
- [ ] Outbound and operator reply paths are idempotent; internal status is private.
## Gate 14 — smoke and E2E
```sh
deployment/scripts/smoke.sh
```
- [ ] Guest, OTP/PKCE/bootstrap/session, refresh and logout paths pass.
- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass.
- [ ] File quarantine/promote/delete, owner-only download and audit pass.
- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass.
- [ ] Logs contain no PII, message body, token or presigned query.
## Gate 15 — observability
- [ ] Known request ID links nginx, API and downstream trace; UX ID is not a label.
- [ ] Three signals reach the selected backend; SLO queries and alerts are tested.
- [ ] Remote outage fills/drains the bounded persistent queue without business outage.
- [ ] Secret/PII canary is absent. Collector restart/drop/refused metrics are checked.
For local acceptance only, start the redacted debug collector with:
`docker compose --profile observability-local up -d otel-collector-local`.
## Gate 16 — open traffic
- [ ] Gates 015 are signed; fresh backup/PITR evidence and previous images exist.
- [ ] HSTS is enabled, release digests/schema/realm versions are recorded.
- [ ] No active page; on-call and product owner accept stub limitations.
- [ ] Observe 5xx/auth/delivery/DB/Redis/OOM/OTEL queue/Bitrix for 60 minutes.
## Backup and restore
Provider backup/PITR is authoritative. A supplemental verified logical dump:
```sh
PG_BACKUP_DSN='postgresql://...?...sslmode=verify-full&sslrootcert=...' \
deployment/scripts/backup.sh /opt/han-chat/backups
```
Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests,
do not route production DNS/Bitrix callbacks, run smoke, and record measured RPO/RTO.
Redis may be restored empty; its AOF/RDB is not a business backup.
## Rollback
Only roll back to images compatible with the current schema:
```sh
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
deployment/scripts/rollback.sh /secure/path/previous-release.env
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
```
Never run Alembic downgrade. After a backward-incompatible migration choose a
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
@@ -0,0 +1,31 @@
schema_version: 1
settings:
auth.phone.enabled: {type: boolean, value: true, public: true}
auth.password.enabled: {type: boolean, value: false, public: true}
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
operator.call.phone: {type: string, value: "+74999591007", public: true}
consent.personal_data.required: {type: boolean, value: true, public: true}
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
consent.personal_data.version: {type: string, value: "2026-06-10", public: true}
consent.user_agreement.required: {type: boolean, value: true, public: true}
consent.user_agreement.document_url: {type: string, value: "https://www.han0107.ru/user-agreement", public: true}
consent.user_agreement.version: {type: string, value: "2026-06-10", public: true}
consent.marketing.required: {type: boolean, value: false, public: true}
consent.marketing.version: {type: string, value: "2026-06-10", public: true}
chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true}
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
chat.attachments.disallowed_extensions: {type: string_list, value: "svg,doc,docx,xls,xlsx,csv", public: false}
chat.attachments.max_size_mb: {type: integer, value: 5, public: true}
chat.attachments.storage: {type: string, value: selectel_s3, public: false}
chat.attachments.upload_mode: {type: string, value: presigned_put, public: false}
chat.attachments.safety_scan_required: {type: boolean, value: true, public: false}
chat.attachments.presigned_upload_ttl_seconds: {type: integer, value: 600, public: true}
rate_limit.message_send.per_user: {type: string, value: "30/minute", public: true}
rate_limit.message_send.per_dialog: {type: string, value: "20/minute", public: false}
rate_limit.download_url.per_user: {type: string, value: "60/hour", public: false}
rate_limit.public_endpoints.per_ip: {type: string, value: "60/minute", public: true}
rate_limit.login.per_ip: {type: string, value: "10/minute", public: true}
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
@@ -0,0 +1,72 @@
services:
migrate-api:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend]
restart: "no"
security_opt: ["no-new-privileges:true"]
migrate-bitrix-local:
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend]
restart: "no"
security_opt: ["no-new-privileges:true"]
migrate-bitrix-sync:
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend]
restart: "no"
security_opt: ["no-new-privileges:true"]
seed-settings:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
entrypoint: []
command:
- /bin/sh
- -ec
- >-
python -m app.cli.seed_settings
--file /deployment/app-settings.production-like.yaml
&& python -m app.cli.validate_settings
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
networks: [backend]
restart: "no"
security_opt: ["no-new-privileges:true"]
toolbox:
image: curlimages/curl:8.11.1
profiles: ["ops"]
entrypoint: ["sleep", "infinity"]
networks: [backend, observability]
restart: "no"
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
if [ -z "${PG_BACKUP_DSN:-}" ]; then
echo "PG_BACKUP_DSN is required (managed PostgreSQL TLS DSN, supplied via secure environment)." >&2
exit 64
fi
case "$PG_BACKUP_DSN" in
*sslmode=verify-full*sslrootcert=*) ;;
*) echo "PG_BACKUP_DSN must enforce sslmode=verify-full and sslrootcert." >&2; exit 64 ;;
esac
output_dir=${1:-/opt/han-chat/backups}
umask 077
mkdir -p "$output_dir"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
archive="$output_dir/han-chat-$stamp.dump"
pg_dump --dbname="$PG_BACKUP_DSN" --format=custom --no-owner --no-privileges --file="$archive"
pg_restore --list "$archive" >/dev/null
sha256sum "$archive" > "$archive.sha256"
chmod 600 "$archive" "$archive.sha256"
echo "Logical backup verified: $archive"
echo "This supplements, but does not replace, provider backup/PITR and restore rehearsal."
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
if [ "${PITR_MARKER_CONFIRMED:-false}" != "true" ]; then
echo "Refusing migration: create provider PITR marker, then set PITR_MARKER_CONFIRMED=true" >&2
exit 64
fi
./scripts/validate-env "${ENV_FILE:-.env}"
docker compose --env-file "${ENV_FILE:-.env}" config --quiet
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync
echo "Migrations completed; record revisions in release evidence."
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
previous_env=${1:-}
if [ -z "$previous_env" ] || [ ! -r "$previous_env" ]; then
echo "Usage: $0 /secure/path/previous-release.env" >&2
exit 64
fi
if [ "${SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED:-false}" != "true" ]; then
echo "Refusing rollback: set SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true after migration review." >&2
exit 64
fi
./scripts/validate-env "$previous_env"
docker compose --env-file "$previous_env" config --quiet
docker compose --env-file "$previous_env" up -d --remove-orphans
docker compose --env-file "$previous_env" ps
echo "Application images rolled back without Alembic downgrade."
echo "Run deployment/scripts/smoke.sh with ENV_FILE=$previous_env and verify outbox/inbox idempotency."
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
./scripts/validate-env "${ENV_FILE:-.env}"
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm seed-settings
echo "Seed and mandatory-settings validation completed."
@@ -0,0 +1,30 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
ENV_FILE=${ENV_FILE:-.env}
./scripts/validate-env "$ENV_FILE"
set -a
. "./$ENV_FILE"
set +a
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
http_code=$(curl -sS -o /dev/null -w '%{http_code}' "http://${PUBLIC_HOST}/")
[ "$http_code" = "308" ] || { echo "Expected HTTP 308, got $http_code" >&2; exit 1; }
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/app-config" -o "$tmp/app-config.json"
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/content" -o "$tmp/content.json"
curl -fsS "${PUBLIC_WEB_URL}/auth/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration" -o "$tmp/oidc.json"
internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check")
[ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; }
headers=$(curl -fsSI "${PUBLIC_WEB_URL}/")
printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff'
printf '%s' "$headers" | grep -qi '^x-request-id:'
printf '%s' "$headers" | grep -qi '^content-security-policy:'
openssl s_client -connect "${PUBLIC_HOST}:443" -servername "$PUBLIC_HOST" </dev/null 2>/dev/null \
| openssl x509 -noout -checkend 604800
echo "Public edge smoke passed."
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
lock=/tmp/han-chat-cert-renew.lock
exec 9>"$lock"
flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; }
docker compose --profile certbot run --rm certbot renew \
--webroot -w /var/www/certbot --quiet
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose exec -T nginx nginx -s reload
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
+26
View File
@@ -0,0 +1,26 @@
name: han-chat
include:
- path: ./infra/compose/application.yml
- path: ./nginx/docker-compose.yml
- path: ./redis/docker-compose.yml
- path: ./observability/docker-compose.yml
- path: ./deployment/docker-compose.jobs.yml
networks:
public:
name: han-chat-public
backend:
name: han-chat-backend
internal: true
observability:
name: han-chat-observability
internal: true
volumes:
redis-data:
nginx-certs:
nginx-acme-webroot:
nginx-cache:
frontend-static:
otel-queue:
@@ -0,0 +1,7 @@
node_modules
dist
.expo
.git
.env*
playwright-report
test-results
@@ -0,0 +1,7 @@
EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
EXPO_PUBLIC_APP_ENV=production-like
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
@@ -0,0 +1,8 @@
node_modules/
dist/
.expo/
playwright-report/
test-results/
.env
.env.local
*.log
@@ -0,0 +1,23 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG EXPO_PUBLIC_API_BASE_URL
ARG EXPO_PUBLIC_AUTH_BASE_URL
ARG EXPO_PUBLIC_KEYCLOAK_REALM
ARG EXPO_PUBLIC_KEYCLOAK_CLIENT_ID
ARG EXPO_PUBLIC_APP_ENV=production
ENV EXPO_PUBLIC_API_BASE_URL=$EXPO_PUBLIC_API_BASE_URL \
EXPO_PUBLIC_AUTH_BASE_URL=$EXPO_PUBLIC_AUTH_BASE_URL \
EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \
EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV
RUN npm run build
# One-shot Compose init container copies the immutable export to nginx's volume.
FROM alpine:3.22 AS static
COPY --from=build /app/dist /dist
RUN mkdir /output
ENTRYPOINT ["/bin/sh", "-ec"]
CMD ["rm -rf /output/* /output/.[!.]* /output/..?* 2>/dev/null || true; cp -a /dist/. /output/"]
@@ -0,0 +1,31 @@
# HAN Chat frontend test site
Expo / React Native Web SPA для проверки публичных пользовательских потоков HAN Chat.
## Запуск
```bash
cp .env.example .env.local
npm install
npm run web
```
Проверки: `npm test`, `npm run typecheck`, `npm run build`, `npm run test:e2e`.
## Production
`npm run build` создаёт `dist/`. Каталог монтируется в корневой nginx системы; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html` и бессрочно кэшировать hashed assets.
Dockerfile собирает статический OCI-артефакт `/dist` без runtime-сервера:
```bash
docker build --target static \
--build-arg EXPO_PUBLIC_API_BASE_URL=https://tohin.ru \
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth \
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=han-chat \
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend .
```
Во frontend разрешены только публичные URL, realm, client id и имя среды. Service tokens, S3 credentials и mock OTP code добавлять запрещено. На web refresh token хранится в browser storage с известным XSS-риском; production требует строгой CSP и отсутствия сторонних scripts.
WebSocket использует subprotocols `han-chat-v1` и `bearer.<JWT>`. Они должны совпадать с реализацией api-backend. Query-token намеренно не используется.
@@ -0,0 +1,15 @@
import type { ExpoConfig } from "expo/config";
const config: ExpoConfig = {
name: "HAN Chat Test",
slug: "han-chat-test",
version: "1.0.0",
scheme: "han-chat",
orientation: "portrait",
userInterfaceStyle: "light",
experiments: { typedRoutes: true },
plugins: ["expo-router", "expo-secure-store"],
web: { bundler: "metro", output: "static" },
};
export default config;
@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import React from "react";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { AppProvider } from "../src/app-context";
export default function RootLayout() {
return <SafeAreaProvider>
<AppProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: "#f6f8fb" }}>
<StatusBar style="dark" />
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaView>
</AppProvider>
</SafeAreaProvider>;
}
@@ -0,0 +1,40 @@
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import type { Consents } from "../../src/types";
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
export default function AuthCallbackScreen() {
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
const app = useApp();
const [error, setError] = useState<unknown>();
const retry = () => {
if (!params.code || !params.state || typeof window === "undefined") return;
const raw = window.sessionStorage.getItem("han.pending-consents");
if (!raw) return;
setError(undefined);
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
};
useEffect(() => {
if (params.error) {
setError(new Error("Авторизация отменена или отклонена."));
return;
}
if (!params.code || !params.state) return;
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
if (!raw) {
setError(new Error("Не найдены локально принятые согласия. Начните вход заново."));
return;
}
const consents = JSON.parse(raw) as Consents;
void app.finishCallback(params.code, params.state, consents)
.then(() => window.sessionStorage.removeItem("han.pending-consents"))
.catch(setError);
}, [params.code, params.state, params.error]);
return <View style={styles.page}>
<Text accessibilityRole="header" style={styles.title}>Завершение входа</Text>
{!error && <Loading />}
{error && <><ErrorNotice error={error} /><Button title="Повторить bootstrap" onPress={retry} /></>}
</View>;
}
@@ -0,0 +1,46 @@
import React, { useEffect, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { getDiagnostics, sessionMemory } from "../src/api";
import { getTokenInfo } from "../src/auth";
import { useApp } from "../src/app-context";
import { isProduction } from "../src/config";
import { getRealtimeDiagnostics } from "../src/realtime";
import { Header, styles } from "../src/ui";
export default function DiagnosticsScreen() {
const app = useApp();
const [, render] = useState(0);
useEffect(() => {
const timer = setInterval(() => render((value) => value + 1), 1000);
return () => clearInterval(timer);
}, []);
if (isProduction) return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
<Text style={styles.error}>Экран отключён в production.</Text>
</ScrollView>;
const token = getTokenInfo();
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
<View style={styles.card}>
<Text style={styles.text}>Режим: {app.authStatus}</Text>
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
{getRealtimeDiagnostics().map((item) =>
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
)}
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
</View>
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
{getDiagnostics().map((item) => <View key={`${item.at}-${item.requestId}`} style={styles.row}>
<Text style={styles.badge}>{item.status}</Text>
<Text style={styles.text}>{item.method} {item.path}</Text>
<Text style={styles.muted}>request_id: {item.requestId}</Text>
</View>)}
</View>
</ScrollView>;
}
@@ -0,0 +1,134 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useMemo, useState } from "react";
import { Platform, ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
import type { Message } from "../../src/types";
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../../src/ui";
const statusLabel: Record<string, string> = {
open: "Открыт",
waiting_for_company: "Ожидает ответа компании",
waiting_for_client: "Ожидает вашего ответа",
closed: "Закрыт",
accepted: "Принято",
delivered: "Доставлено",
failed: "Ошибка доставки",
rejected: "Отклонено",
};
export default function ChatScreen() {
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
const app = useApp();
const client = useQueryClient();
const [text, setText] = useState("");
const [error, setError] = useState<unknown>();
const [sending, setSending] = useState(false);
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
const realtime = useMemo(() => new RealtimeClient(
dialogId ? [dialogId] : [],
(event) => {
if (event.type === "message.new") merge([event.message]);
if (event.type === "message.status") {
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => old && ({
...old,
items: old.items.map((item) => item.message_id === event.message_id ? { ...item, safety_status: event.safety_status, delivery_status: event.delivery_status } : item),
}));
}
if (event.type === "dialog.status") void dialog.refetch();
},
(_, incoming) => merge(incoming),
app.setRealtimeState,
), [dialogId]);
function merge(incoming: Message[]) {
client.setQueryData(["messages", dialogId], (old: typeof messages.data) =>
old ? { ...old, items: reconcileMessages(old.items, incoming) } : { items: incoming, next_cursor: null },
);
}
useEffect(() => {
if (app.authStatus === "authenticated") realtime.start();
return () => realtime.stop();
}, [realtime, app.authStatus]);
const sendText = async () => {
const normalized = text.trim();
if (!normalized || !dialogId) return;
setSending(true); setError(undefined);
try {
const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID());
merge([message]); setText("");
} catch (reason) { setError(reason); }
finally { setSending(false); }
};
const chooseFile = () => {
if (Platform.OS !== "web") {
setError(new Error("Выбор файла в этой тестовой сборке доступен в web."));
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,application/pdf";
input.onchange = () => { const file = input.files?.[0]; if (file) void sendFile(file); };
input.click();
};
const sendFile = async (file: File) => {
const limits = config.data?.attachments;
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
if (file.size > max || !allowed.includes(file.type)) {
setError(new Error("Недопустимый тип файла или превышен допустимый размер."));
return;
}
setSending(true); setError(undefined);
try {
const uploaded = await uploadAttachment(dialogId, file);
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, crypto.randomUUID());
merge([message]);
} catch (reason) { setError(reason); }
finally { setSending(false); }
};
const downloadAttachment = async (attachmentId: string) => {
try {
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
if (typeof window !== "undefined") window.location.assign(result.download_url);
} catch (reason) { setError(reason); }
};
const closed = dialog.data?.status === "closed";
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
{app.authStatus !== "authenticated" && <Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>}
{(dialog.isLoading || messages.isLoading) && <Loading />}
{(dialog.error || messages.error) && <ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />}
<Text style={styles.badge}>Статус: {statusLabel[dialog.data?.status ?? ""] ?? "—"}</Text>
<View accessibilityLiveRegion="polite" style={{ gap: 10 }}>
{(messages.data?.items ?? []).map((message) => <View key={message.message_id} style={message.sender_type === "client" ? styles.messageClient : styles.messageCompany}>
<Text style={styles.text}>{message.content_kind === "file" ? `Файл: ${message.attachments[0]?.file_name ?? "вложение"}` : message.text}</Text>
{message.attachments.map((attachment) =>
<Button key={attachment.attachment_id} title="Скачать вложение" secondary onPress={() => void downloadAttachment(attachment.attachment_id)} />,
)}
<Text style={styles.muted}>{message.sender_type === "client" ? "Вы" : "Компания"} · {new Date(message.created_at).toLocaleString("ru-RU")} · {statusLabel[message.delivery_status] ?? message.delivery_status}</Text>
</View>)}
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
</View>
{closed ? <Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> : <View style={styles.card}>
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
<View style={styles.row}>
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
<Button title="Прикрепить изображение или PDF" secondary disabled={sending} onPress={chooseFile} />
</View>
{error && <ErrorNotice error={error} />}
</View>}
</ScrollView>;
}
@@ -0,0 +1,46 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import React from "react";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { dialogApi } from "../../src/services";
import { Button, ErrorNotice, Header, Loading, styles } from "../../src/ui";
const statusLabels = {
open: "Открыт",
waiting_for_company: "Ожидает ответа компании",
waiting_for_client: "Ожидает вашего ответа",
closed: "Закрыт",
};
export default function DialogsScreen() {
const app = useApp();
const dialogs = useInfiniteQuery({
queryKey: ["dialogs"],
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.next_cursor ?? undefined,
enabled: app.authStatus === "authenticated",
});
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
<Text style={styles.text}>История доступна после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
<Link href="/" style={styles.link}>На главную</Link>
</ScrollView>;
const items = dialogs.data?.pages.flatMap((page) => page.items) ?? [];
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
{dialogs.isLoading && <Loading />}
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
{items.map((dialog) => <View key={dialog.dialog_id} style={styles.card}>
<Text style={styles.heading}>{statusLabels[dialog.status]}</Text>
<Text style={styles.muted}>Диалог {dialog.dialog_id.slice(0, 8)}</Text>
<Link href={`/dialogs/${dialog.dialog_id}`} style={styles.link}>Открыть диалог</Link>
</View>)}
{dialogs.hasNextPage && <Button title="Показать ещё" secondary disabled={dialogs.isFetchingNextPage} onPress={() => void dialogs.fetchNextPage()} />}
</ScrollView>;
}
@@ -0,0 +1,127 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Linking, ScrollView, Switch, Text, View } from "react-native";
import { z } from "zod";
import { useApp } from "../src/app-context";
import { dialogApi, publicApi } from "../src/services";
import type { Consents } from "../src/types";
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../src/ui";
const schema = z.object({ text: z.string().trim().min(1, "Введите сообщение").max(4000, "Сообщение слишком длинное") });
type Form = z.infer<typeof schema>;
export default function HomeScreen() {
const { authStatus, realtimeState, authorize, signOut } = useApp();
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
const [consentOpen, setConsentOpen] = useState(false);
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false });
const [pending, setPending] = useState<string | null>(null);
const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = useState(false);
const router = useRouter();
const { control, handleSubmit, setValue, reset, formState: { errors } } = useForm<Form>({
defaultValues: { text: "" }, resolver: zodResolver(schema),
});
const sendAuthenticated = async (text: string) => {
setSending(true);
setSendError(undefined);
try {
const dialog = await dialogApi.create(crypto.randomUUID());
await dialogApi.sendText(dialog.dialog_id, text, crypto.randomUUID());
reset();
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
};
const send = async (text: string) => {
if (authStatus !== "authenticated") {
setPending(text);
setConsentOpen(true);
return;
}
await sendAuthenticated(text);
};
const accept = async () => {
if (!required.personal || !required.agreement) return;
const versions = config.data?.consents;
const consents: Consents = {
personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" },
user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" },
marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" },
};
setConsentOpen(false);
try {
const authorized = await authorize(consents);
if (authorized && pending) await sendAuthenticated(pending);
} catch (error) {
setSendError(error);
}
};
const questions = content.data?.popular_questions ?? [];
return <ScrollView contentContainerStyle={styles.page}>
<Header status={authStatus} realtime={realtimeState} onLogout={authStatus === "authenticated" ? () => void signOut() : undefined} />
<Text accessibilityRole="header" style={styles.title}>Помощь мигрантам</Text>
<Text style={styles.text}>{content.data?.texts.welcome ?? "Задайте вопрос — оператор ответит в чате."}</Text>
{(config.isLoading || content.isLoading) && <Loading />}
{(config.error || content.error) && <ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />}
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Популярные вопросы</Text>
<View style={styles.row}>
{questions.map((question, index) => {
const text = question.text;
return <Button key={question.id ?? index} title={text} secondary onPress={() => { setValue("text", text); void send(text); }} />;
})}
{!questions.length && <Text style={styles.muted}>Популярные вопросы пока не опубликованы.</Text>}
</View>
</View>
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Написать оператору</Text>
<Controller control={control} name="text" render={({ field }) =>
<Field label="Сообщение" multiline value={field.value} onChangeText={field.onChange} error={errors.text?.message} />
} />
<Button title={sending ? "Отправляем…" : "Отправить"} disabled={sending} onPress={() => void handleSubmit(({ text }) => send(text))()} />
{sendError && <ErrorNotice error={sendError} />}
</View>
{consentOpen && <View accessibilityViewIsModal style={styles.modalBackdrop}>
<View style={styles.modal}>
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
<Text style={styles.text}>Для отправки сообщения необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
const item = config.data?.consents?.[key];
if (!item) return null;
return item.document_url ? <Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
</Text> : null;
})}
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
<View style={styles.row}>
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
<Button title="Отмена" secondary onPress={() => { setConsentOpen(false); setPending(null); }} />
</View>
</View>
</View>}
</ScrollView>;
}
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
return <View style={[styles.row, { justifyContent: "space-between" }]}>
<Text style={[styles.text, { flex: 1 }]}>{label}</Text>
<Switch accessibilityLabel={label} value={value} onValueChange={onChange} />
</View>;
}
@@ -0,0 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import React, { useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../src/app-context";
import { profileApi } from "../src/services";
import { Button, ErrorNotice, Header, Loading, styles } from "../src/ui";
export default function ProfileScreen() {
const app = useApp();
const enabled = app.authStatus === "authenticated";
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
const [downloadError, setDownloadError] = useState<unknown>();
const download = async (id: string) => {
try {
const result = await profileApi.documentUrl(id);
if (typeof window !== "undefined") window.location.assign(result.download_url);
} catch (error) { setDownloadError(error); }
};
if (!enabled) return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
<Text style={styles.text}>Профиль доступен после авторизации.</Text>
<Link href="/" style={styles.link}>Перейти в чат для входа</Link>
</ScrollView>;
const personal = profile.data?.profile.personal_data;
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
{(profile.isLoading || documents.isLoading) && <Loading />}
{(profile.error || documents.error) && <ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />}
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Личные данные</Text>
<Row label="ФИО" value={personal?.full_name} />
<Row label="Гражданство" value={personal?.citizenship} />
<Row label="Телефон в РФ" value={personal?.russian_phone} />
<Row label="Зарубежный телефон" value={personal?.foreign_phone} />
<Row label="Email" value={personal?.email} />
<Text style={styles.muted}>Редактирование профиля недоступно. Для изменения данных напишите оператору.</Text>
<Link href="/" style={styles.link}>Написать оператору</Link>
</View>
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Документы</Text>
{!documents.data?.items.length && <Text style={styles.muted}>Документов пока нет.</Text>}
{documents.data?.items.map((document) => <View key={document.document_id} style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.text}>{document.name}</Text>
<Text style={styles.muted}>{new Date(document.sent_at).toLocaleDateString("ru-RU")}</Text>
</View>
<Button title="Скачать" secondary onPress={() => void download(document.document_id)} />
</View>)}
{downloadError && <ErrorNotice error={downloadError} />}
</View>
</ScrollView>;
}
function Row({ label, value }: { label: string; value: string | null | undefined }) {
return <View><Text style={styles.muted}>{label}</Text><Text style={styles.text}>{value || "Не указано"}</Text></View>;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="expo/types" />
@@ -0,0 +1,48 @@
{
"name": "han-frontend-test-site",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"web": "expo start --web",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"build": "expo export --platform web",
"serve": "npx serve dist"
},
"dependencies": {
"@expo/metro-runtime": "57.0.3",
"@hookform/resolvers": "5.4.0",
"@tanstack/react-query": "5.101.2",
"expo": "57.0.4",
"expo-auth-session": "57.0.2",
"expo-constants": "57.0.3",
"expo-crypto": "57.0.0",
"expo-linking": "57.0.2",
"expo-router": "57.0.4",
"expo-secure-store": "57.0.0",
"expo-status-bar": "57.0.0",
"expo-web-browser": "57.0.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "7.81.0",
"react-native": "0.86.0",
"react-native-safe-area-context": "5.8.0",
"react-native-screens": "4.26.0",
"react-native-web": "0.21.2",
"zod": "4.4.3"
},
"devDependencies": {
"jsdom": "29.1.1",
"@playwright/test": "1.61.1",
"@testing-library/react": "16.3.2",
"@types/react": "19.2.17",
"@vitejs/plugin-react": "6.0.3",
"typescript": "7.0.2",
"vite": "8.1.4",
"vitest": "4.1.10"
}
}
@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: "list",
use: { baseURL: "http://127.0.0.1:4173", trace: "on-first-retry" },
webServer: {
command: "npm run build && npx serve dist -l 4173",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit-mobile", use: { ...devices["iPhone 13"] } },
],
});
@@ -0,0 +1,93 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
export { sessionMemory } from "./session";
import { sessionMemory } from "./session";
export type ApiErrorEnvelope = {
error: { code: string; message: string; request_id?: string; details?: Record<string, unknown> };
};
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly requestId?: string,
readonly retryAfter?: number,
) {
super(message);
this.name = "ApiError";
}
}
export type Diagnostic = {
at: number;
method: string;
path: string;
status: number;
requestId: string;
};
const diagnostics: Diagnostic[] = [];
export const getDiagnostics = () => [...diagnostics];
function traceparent() {
const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16);
return `00-${traceId.slice(0, 32)}-${spanId}-01`;
}
function safePath(path: string) {
return path.split("?")[0] ?? path;
}
async function parseError(response: Response, requestId: string) {
let envelope: ApiErrorEnvelope | undefined;
try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ }
const code = envelope?.error?.code ?? `http_${response.status}`;
const retry = Number(response.headers.get("Retry-After"));
return new ApiError(
response.status,
code,
envelope?.error?.message ?? "Запрос не выполнен",
envelope?.error?.request_id ?? requestId,
Number.isFinite(retry) ? retry : undefined,
);
}
export async function apiRequest<T>(
path: string,
init: RequestInit & { protected?: boolean } = {},
replayed = false,
): Promise<T> {
const requestId = crypto.randomUUID();
const isProtected = init.protected ?? false;
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
headers.set("X-Request-ID", requestId);
headers.set("traceparent", traceparent());
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (isProtected) {
const token = getAccessToken();
if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId);
headers.set("Authorization", `Bearer ${token}`);
if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id);
}
const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers });
diagnostics.unshift({
at: Date.now(), method: init.method ?? "GET", path: safePath(path),
status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId,
});
diagnostics.splice(20);
if (response.status === 401 && isProtected && !replayed) {
await refreshTokens();
return apiRequest<T>(path, init, true);
}
if (!response.ok) throw await parseError(response, requestId);
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const json = (value: unknown) => JSON.stringify(value);
export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });
@@ -0,0 +1,121 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
import { sessionMemory } from "./api";
import { authApi, publicApi } from "./services";
import type { Consents } from "./types";
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
type AppContextValue = {
authStatus: AuthStatus;
realtimeState: string;
setRealtimeState: (value: string) => void;
authorize: (consents: Consents) => Promise<boolean>;
finishCallback: (code: string, state: string, consents: Consents) => Promise<void>;
signOut: () => Promise<void>;
ensureSession: () => Promise<void>;
};
const Context = createContext<AppContextValue | null>(null);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
});
export function AppProvider({ children }: { children: React.ReactNode }) {
const [authStatus, setAuthStatus] = useState<AuthStatus>("guest");
const [realtimeState, setRealtimeState] = useState("idle");
const [idleTimeoutMs, setIdleTimeoutMs] = useState<number | null>(null);
const router = useRouter();
const toGuest = useCallback(() => {
sessionMemory.clear();
setRealtimeState("idle");
setAuthStatus("guest");
queryClient.clear();
}, []);
useEffect(() => {
configureAuthFailure(toGuest);
void publicApi.config().then((config) => {
const minutes = config.ux.idle_timeout_minutes;
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
}).catch(() => undefined);
void refreshTokens()
.then(() => {
setAuthStatus("authenticated");
void authApi.startSession("cold_start").catch(() => undefined);
})
.catch(() => setAuthStatus("guest"));
}, [toGuest]);
const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => {
setAuthStatus("bootstrapping");
if (!getAccessToken()) await completeAuthorization(code, state);
await authApi.bootstrap(consents);
await authApi.startSession("first_launch");
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
setAuthStatus("authenticated");
router.replace("/");
}, [router]);
const authorize = useCallback(async (consents: Consents) => {
setAuthStatus("authorizing");
if (typeof window !== "undefined") window.sessionStorage.setItem("han.pending-consents", JSON.stringify(consents));
const result = await beginAuthorization();
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
setAuthStatus("guest");
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
return false;
}
await finishCallback(result.params.code, result.params.state, consents);
return true;
}, [finishCallback]);
const ensureSession = useCallback(async () => {
if (authStatus !== "authenticated") return;
if (!sessionMemory.id) await authApi.startSession("cold_start");
else if (idleTimeoutMs !== null && Date.now() - sessionMemory.lastActivityAt > idleTimeoutMs) {
await authApi.startSession("idle_timeout");
}
sessionMemory.touch();
}, [authStatus, idleTimeoutMs]);
useEffect(() => {
if (Platform.OS === "web") {
const activity = () => sessionMemory.touch();
const resume = () => { if (!document.hidden) void ensureSession(); };
window.addEventListener("pointerdown", activity);
window.addEventListener("keydown", activity);
document.addEventListener("visibilitychange", resume);
return () => {
window.removeEventListener("pointerdown", activity);
window.removeEventListener("keydown", activity);
document.removeEventListener("visibilitychange", resume);
};
}
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") void ensureSession();
});
return () => subscription.remove();
}, [ensureSession]);
const value = useMemo<AppContextValue>(() => ({
authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession,
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
return <QueryClientProvider client={queryClient}><Context.Provider value={value}>{children}</Context.Provider></QueryClientProvider>;
}
export function useApp() {
const value = useContext(Context);
if (!value) throw new Error("AppProvider is missing");
return value;
}
export async function resetAuthForTests() {
await clearTokens();
queryClient.clear();
}
@@ -0,0 +1,194 @@
import * as AuthSession from "expo-auth-session";
import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
import { env, oidcIssuer } from "./config";
import { SingleFlight } from "./single-flight";
import type { TokenSet } from "./types";
WebBrowser.maybeCompleteAuthSession();
const REFRESH_KEY = "han.refresh-token";
const PKCE_KEY = "han.pkce";
const PKCE_TTL_MS = 10 * 60_000;
let tokens: TokenSet | null = null;
const refreshFlight = new SingleFlight<TokenSet>();
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let authFailure: (() => void) | undefined;
const browserStore = {
async get(key: string) {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(key);
},
async set(key: string, value: string) {
if (typeof window !== "undefined") window.localStorage.setItem(key, value);
},
async del(key: string) {
if (typeof window !== "undefined") window.localStorage.removeItem(key);
},
};
const secureStore = {
get: (key: string) =>
Platform.OS === "web" ? browserStore.get(key) : SecureStore.getItemAsync(key),
set: (key: string, value: string) =>
Platform.OS === "web" ? browserStore.set(key, value) : SecureStore.setItemAsync(key, value),
del: (key: string) =>
Platform.OS === "web" ? browserStore.del(key) : SecureStore.deleteItemAsync(key),
};
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
export function configureAuthFailure(callback: () => void) {
authFailure = callback;
}
export function getAccessToken() {
return tokens?.accessToken ?? null;
}
export function getTokenInfo() {
return tokens ? { expiresAt: tokens.expiresAt } : null;
}
async function persist(next: TokenSet) {
tokens = next;
await secureStore.set(REFRESH_KEY, next.refreshToken);
if (refreshTimer) clearTimeout(refreshTimer);
const delay = Math.max(1_000, next.expiresAt - Date.now() - 60_000);
refreshTimer = setTimeout(() => void refreshTokens().catch(() => undefined), delay);
}
async function parseTokenResponse(response: Response): Promise<TokenSet> {
const body = (await response.json()) as Record<string, unknown>;
if (!response.ok || typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
throw new Error(typeof body.error === "string" ? body.error : "token_exchange_failed");
}
return {
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + Number(body.expires_in ?? 300) * 1000,
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
};
}
export async function beginAuthorization() {
const verifier = random();
const state = random();
const nonce = random();
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
encoding: Crypto.CryptoEncoding.BASE64,
});
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
client_id: env.clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid profile offline_access",
code_challenge: challenge,
code_challenge_method: "S256",
state,
nonce,
})}`;
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
const callback = new URL(result.url);
return {
type: "success" as const,
params: {
code: callback.searchParams.get("code"),
state: callback.searchParams.get("state"),
error: callback.searchParams.get("error"),
},
};
}
export async function completeAuthorization(code: string, state: string) {
const raw = await secureStore.get(PKCE_KEY);
await secureStore.del(PKCE_KEY);
if (!raw) throw new Error("pkce_state_missing");
const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number };
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
throw new Error("pkce_state_invalid");
}
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: env.clientId,
redirect_uri: redirectUri,
code,
code_verifier: saved.verifier,
}).toString(),
});
const next = await parseTokenResponse(response);
if (!next.idToken || readJwtClaim(next.idToken, "nonce") !== saved.nonce) {
await clearTokens();
throw new Error("oidc_nonce_invalid");
}
await persist(next);
return next;
}
function readJwtClaim(token: string, claim: string) {
const payload = token.split(".")[1];
if (!payload) return undefined;
try {
const normalized = payload.replaceAll("-", "+").replaceAll("_", "/");
const decoded = decodeURIComponent(
Array.from(atob(normalized), (character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""),
);
return (JSON.parse(decoded) as Record<string, unknown>)[claim];
} catch {
return undefined;
}
}
export async function refreshTokens(): Promise<TokenSet> {
return refreshFlight.run(async () => {
const refreshToken = tokens?.refreshToken ?? (await secureStore.get(REFRESH_KEY));
if (!refreshToken) throw new Error("refresh_token_missing");
try {
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: env.clientId,
refresh_token: refreshToken,
}).toString(),
});
const next = await parseTokenResponse(response);
await persist(next);
return next;
} catch (error) {
await clearTokens();
authFailure?.();
throw error;
}
});
}
export async function clearTokens() {
tokens = null;
if (refreshTimer) clearTimeout(refreshTimer);
await secureStore.del(REFRESH_KEY);
}
export async function logout() {
const idToken = tokens?.idToken;
await clearTokens();
if (idToken) {
void fetch(`${oidcIssuer}/protocol/openid-connect/logout`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: env.clientId, id_token_hint: idToken }).toString(),
}).catch(() => undefined);
}
}
@@ -0,0 +1,13 @@
const required = (name: string, fallback: string) =>
(process.env[name] ?? fallback).replace(/\/$/, "");
export const env = Object.freeze({
apiBaseUrl: required("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000"),
authBaseUrl: required("EXPO_PUBLIC_AUTH_BASE_URL", "http://localhost:8080/auth"),
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
});
export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
export const isProduction = env.appEnv === "production";
@@ -0,0 +1,145 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
import { dialogApi } from "./services";
import type { Message } from "./types";
export { reconcileMessages } from "./reconcile";
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
export type RealtimeEvent =
| { type: "message.new"; dialog_id: string; message: Message; cursor?: string }
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
const safeCursors = new Map<string, string>();
export const getRealtimeDiagnostics = () =>
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
dialog: `${dialogId.slice(0, 8)}`,
cursor,
}));
export function websocketJwtProtocol(token: string) {
const bytes = new TextEncoder().encode(token);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return `han.jwt.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
}
export class RealtimeClient {
private socket?: WebSocket;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private pollingTimer?: ReturnType<typeof setTimeout>;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
private cursors = new Map<string, string>();
constructor(
private dialogIds: string[],
private readonly onEvent: (event: RealtimeEvent) => void,
private readonly onMessages: (dialogId: string, messages: Message[]) => void,
private readonly onState: (state: RealtimeState) => void,
) {}
updateDialogs(ids: string[]) {
this.dialogIds = [...new Set(ids)];
if (this.socket?.readyState === WebSocket.OPEN) this.subscribe();
}
start() {
if (!this.stopped) return;
this.stopped = false;
this.connect();
}
stop() {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.socket?.close();
this.onState("idle");
}
private connect() {
if (this.stopped) return;
const token = getAccessToken();
if (!token) return;
this.onState("connecting");
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
this.socket.onopen = () => {
this.attempt = 0;
// Сначала подписываемся, затем читаем REST-gap: события в этом окне
// уже попадут в merge, а дубли устраняются по message_id.
this.subscribe();
void this.reconcileAll().then(() => {
this.stopPolling();
this.onState("websocket");
});
};
this.socket.onmessage = ({ data }) => {
try {
const event = JSON.parse(String(data)) as RealtimeEvent | { type: string };
if (event.type === "ping") return this.socket?.send(JSON.stringify({ type: "pong" }));
if (event.type === "message.new" || event.type === "message.status" || event.type === "dialog.status") {
const known = event as RealtimeEvent;
if (known.cursor) {
this.cursors.set(known.dialog_id, known.cursor);
safeCursors.set(known.dialog_id, known.cursor);
}
this.onEvent(known);
}
} catch { /* unknown and malformed events are safely ignored */ }
};
this.socket.onclose = (event) => {
if (this.stopped) return;
if (!this.disconnectedAt) this.disconnectedAt = Date.now();
if (event.code === 4401 || event.code === 1008) {
void refreshTokens().finally(() => this.scheduleReconnect());
} else {
this.scheduleReconnect();
}
};
this.socket.onerror = () => this.socket?.close();
}
private subscribe() {
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: this.dialogIds }));
}
private scheduleReconnect() {
if (this.stopped) return;
if (Date.now() - this.disconnectedAt >= 30_000) this.startPolling();
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
private async reconcileAll() {
await Promise.all(this.dialogIds.map(async (id) => {
const page = await dialogApi.messages(id, this.cursors.get(id));
if (page.items.length) this.onMessages(id, page.items);
if (page.next_cursor) {
this.cursors.set(id, page.next_cursor);
safeCursors.set(id, page.next_cursor);
}
}));
this.disconnectedAt = 0;
}
private startPolling() {
if (this.pollingTimer) return;
this.onState("polling");
const poll = async () => {
if (this.stopped) return;
await this.reconcileAll().catch(() => undefined);
const delay = typeof document !== "undefined" && document.hidden ? 15_000 : 5_000;
this.pollingTimer = setTimeout(poll, delay);
};
void poll();
}
private stopPolling() {
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.pollingTimer = undefined;
}
}
@@ -0,0 +1,7 @@
import type { Message } from "./types";
export function reconcileMessages(current: Message[], incoming: Message[]) {
const byId = new Map(current.map((message) => [message.message_id, message]));
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
return [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at));
}
@@ -0,0 +1,108 @@
import { apiRequest, idempotencyHeaders, json, sessionMemory } from "./api";
import { Platform } from "react-native";
import type {
Consents, Dialog, DocumentItem, Message, Page, Profile, PublicConfig, PublicContent,
} from "./types";
export const publicApi = {
config: () => apiRequest<PublicConfig>("/api/v1/public/app-config"),
content: () => apiRequest<PublicContent>("/api/v1/public/content"),
};
export const authApi = {
bootstrap: (consents: Consents) =>
apiRequest<{ user_id: string; profile_ready: boolean }>("/api/v1/auth/bootstrap", {
method: "POST", protected: true,
body: json({ consents, device: deviceMetadata() }),
}),
startSession: async (reason: "first_launch" | "cold_start" | "idle_timeout") => {
const result = await apiRequest<{ ux_session_id: string; started_at: string }>(
"/api/v1/analytics/session-start",
{ method: "POST", protected: true, body: json({ start_reason: reason, device: deviceMetadata() }) },
);
sessionMemory.set(result.ux_session_id);
return result;
},
saveConsents: (consents: Consents) =>
apiRequest<void>("/api/v1/consents", {
method: "POST", protected: true, body: json({ consents }),
}),
};
function deviceMetadata() {
const platform = Platform.OS;
if (platform !== "ios" && platform !== "android" && platform !== "web") {
throw new Error(`Unsupported platform: ${platform}`);
}
return {
platform,
app_version: "1.0.0",
device_id: "frontend-test-site",
};
}
export const dialogApi = {
list: (cursor?: string) =>
apiRequest<Page<Dialog>>(`/api/v1/dialogs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { protected: true }),
get: (id: string) => apiRequest<Dialog>(`/api/v1/dialogs/${encodeURIComponent(id)}`, { protected: true }),
create: (key: string) =>
apiRequest<Dialog>("/api/v1/dialogs", {
method: "POST", protected: true, headers: idempotencyHeaders(key), body: "{}",
}),
messages: (id: string, after?: string) =>
apiRequest<Page<Message>>(
`/api/v1/dialogs/${encodeURIComponent(id)}/messages?limit=50${after ? `&after=${encodeURIComponent(after)}` : ""}`,
{ protected: true },
),
sendText: (id: string, text: string, key: string) =>
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ content_kind: "text", text }),
}),
sendFile: (id: string, attachmentId: string, checksum: string, key: string) =>
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ content_kind: "file", attachment_id: attachmentId, checksum }),
}),
};
export async function uploadAttachment(dialogId: string, file: File, key = crypto.randomUUID()) {
const checksum = await sha256(file);
const init = await apiRequest<{
attachment_id: string;
upload_url: string;
upload_headers?: Record<string, string>;
expires_at: string;
}>(`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/init`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ file_name: file.name, mime_type: file.type, size_bytes: file.size }),
});
const upload = await fetch(init.upload_url, {
method: "PUT",
headers: init.upload_headers ?? { "Content-Type": file.type },
body: file,
});
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
await apiRequest<void>(
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(init.attachment_id)}/complete`,
{ method: "POST", protected: true, headers: idempotencyHeaders(key), body: json({ checksum }) },
);
return { attachmentId: init.attachment_id, checksum };
}
async function sha256(file: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
}
export const profileApi = {
me: () => apiRequest<Profile>("/api/v1/me", { protected: true }),
documents: () => apiRequest<Page<DocumentItem>>("/api/v1/me/documents", { protected: true }),
documentUrl: (id: string) =>
apiRequest<{ download_url: string }>(`/api/v1/documents/${encodeURIComponent(id)}/download-url`, { protected: true }),
attachmentUrl: (dialogId: string, id: string) =>
apiRequest<{ download_url: string }>(
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(id)}/download-url`,
{ protected: true },
),
};
@@ -0,0 +1,10 @@
let uxSessionId: string | null = null;
let lastActivityAt = Date.now();
export const sessionMemory = {
get id() { return uxSessionId; },
get lastActivityAt() { return lastActivityAt; },
touch() { lastActivityAt = Date.now(); },
set(id: string) { uxSessionId = id; lastActivityAt = Date.now(); },
clear() { uxSessionId = null; lastActivityAt = Date.now(); },
};
@@ -0,0 +1,11 @@
export class SingleFlight<T> {
private running: Promise<T> | null = null;
run(operation: () => Promise<T>): Promise<T> {
if (this.running) return this.running;
this.running = operation().finally(() => {
this.running = null;
});
return this.running;
}
}
@@ -0,0 +1,76 @@
export type Consent = { accepted: boolean; version: string };
export type Consents = {
personal_data: Consent;
user_agreement: Consent;
marketing: Consent;
};
export type TokenSet = {
accessToken: string;
refreshToken: string;
expiresAt: number;
idToken?: string;
};
export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed";
export type Dialog = { dialog_id: string; status: DialogStatus; updated_at?: string };
export type Attachment = {
attachment_id: string;
file_name: string;
mime_type: string;
size_bytes: number;
scan_status: "pending" | "clean" | "infected" | "failed";
};
export type Message = {
message_id: string;
dialog_id: string;
sender_type: "client" | "company";
content_kind: "text" | "file";
text: string;
attachments: Attachment[];
safety_status: "pending" | "allowed" | "blocked";
delivery_status: "accepted" | "delivered" | "failed" | "rejected";
created_at: string;
};
export type Page<T> = { items: T[]; next_cursor: string | null };
export type Profile = {
user_id: string;
profile: {
personal_data: {
full_name: string | null;
citizenship: string | null;
russian_phone: string | null;
foreign_phone: string | null;
email: string | null;
};
documents: { count: number };
};
};
export type DocumentItem = {
document_id: string;
name: string;
sent_at: string;
};
export type PublicConfig = {
auth: { phone_enabled: boolean; password_enabled: boolean };
operator: { call_phone: string };
consents: Record<string, {
required: boolean;
document_url: string | null;
version: string;
}>;
attachments: {
max_size_mb: number;
allowed_extensions: string[];
allowed_mime_types: string[];
};
ux: { idle_timeout_minutes: number };
};
export type PublicContent = {
locale: string;
texts: Record<string, string>;
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
version: string;
};
@@ -0,0 +1,81 @@
import { Link } from "expo-router";
import React from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { ApiError } from "./api";
export const styles = StyleSheet.create({
page: { flex: 1, width: "100%", maxWidth: 920, alignSelf: "center", padding: 20, gap: 16 },
header: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12, paddingBottom: 12, borderBottomWidth: 1, borderColor: "#d7dee8" },
title: { fontSize: 28, lineHeight: 34, fontWeight: "700", color: "#12233f" },
heading: { fontSize: 20, lineHeight: 26, fontWeight: "700", color: "#12233f" },
text: { fontSize: 16, lineHeight: 23, color: "#233653" },
muted: { fontSize: 14, lineHeight: 20, color: "#5f6f85" },
card: { padding: 16, gap: 10, borderWidth: 1, borderColor: "#d7dee8", borderRadius: 12, backgroundColor: "#fff" },
input: { minHeight: 48, borderWidth: 1, borderColor: "#8493a8", borderRadius: 8, padding: 12, fontSize: 16, backgroundColor: "#fff" },
textarea: { minHeight: 104, textAlignVertical: "top" },
row: { flexDirection: "row", flexWrap: "wrap", gap: 10, alignItems: "center" },
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: 8, backgroundColor: "#185abd" },
buttonSecondary: { backgroundColor: "#e8eef8" },
buttonDanger: { backgroundColor: "#b42318" },
buttonDisabled: { opacity: 0.5 },
buttonText: { color: "#fff", fontWeight: "700", fontSize: 15 },
buttonTextSecondary: { color: "#173b70" },
link: { color: "#075db7", fontSize: 16, textDecorationLine: "underline", paddingVertical: 10 },
badge: { borderRadius: 20, backgroundColor: "#edf2f8", color: "#263b58", paddingHorizontal: 10, paddingVertical: 5, fontSize: 13 },
error: { borderLeftWidth: 4, borderColor: "#b42318", backgroundColor: "#fff1f0", padding: 12, color: "#7a271a" },
success: { borderLeftWidth: 4, borderColor: "#16803c", backgroundColor: "#edfdf2", padding: 12, color: "#14532d" },
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(10,25,45,.45)", alignItems: "center", justifyContent: "center", padding: 20 },
modal: { width: "100%", maxWidth: 560, borderRadius: 14, backgroundColor: "#fff", padding: 20, gap: 14 },
messageClient: { alignSelf: "flex-end", maxWidth: "82%", backgroundColor: "#e4efff", padding: 12, borderRadius: 12 },
messageCompany: { alignSelf: "flex-start", maxWidth: "82%", backgroundColor: "#f0f2f5", padding: 12, borderRadius: 12 },
});
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
return <View style={styles.header}>
<Link href="/" style={styles.link}>HAN Chat</Link>
<Link href="/dialogs" style={styles.link}>Диалоги</Link>
<Link href="/profile" style={styles.link}>Профиль</Link>
<Link href="/diagnostics" style={styles.link}>Диагностика</Link>
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
{onLogout && <Button title="Выйти" secondary onPress={onLogout} />}
</View>;
}
export function Button({ title, onPress, disabled, secondary, danger }: {
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
}) {
return <Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ focused }) => [
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
disabled && styles.buttonDisabled, focused && { borderWidth: 3, borderColor: "#ffbf47" },
]}
>
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
</Pressable>;
}
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
return <View style={{ gap: 6 }}>
<Text style={styles.text}>{props.label}</Text>
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} />
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
</View>;
}
export function Loading() {
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator /><Text style={styles.muted}>Загрузка</Text></View>;
}
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
const requestId = error instanceof ApiError ? error.requestId : undefined;
return <View style={{ gap: 8 }}>
<Text accessibilityRole="alert" style={styles.error}>
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
{requestId ? `\nКод обращения: ${requestId}` : ""}
</Text>
{retry && <Button title="Повторить" secondary onPress={retry} />}
</View>;
}
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/public/app-config", (route) => route.fulfill({
json: {
auth: { phone_enabled: true, password_enabled: false },
operator: { call_phone: "+74950000000" },
ux: { idle_timeout_minutes: 15 },
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
consents: {
personal_data: { required: true, version: "2026-07-01", document_url: "https://example.test/personal" },
user_agreement: { required: true, version: "2026-07-01", document_url: "https://example.test/agreement" },
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
},
},
}));
await page.route("**/api/v1/public/content", (route) => route.fulfill({
json: {
locale: "ru",
texts: { welcome: "Добро пожаловать в HAN Chat" },
popular_questions: [{ id: "visa", mnemonic: "visa", text: "Как оформить визу?" }],
version: "1",
},
}));
});
test("гостевой экран загружает публичный контент", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Помощь мигрантам" })).toBeVisible();
await expect(page.getByText("Добро пожаловать в HAN Chat")).toBeVisible();
await expect(page.getByRole("button", { name: "Как оформить визу?" })).toBeVisible();
await expect(page.getByText(/Гость/)).toBeVisible();
});
test("первое сообщение требует обязательные согласия", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Сообщение").fill("Здравствуйте");
await page.getByRole("button", { name: "Отправить" }).click();
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
});
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
let protectedCalls = 0;
await page.route("**/api/v1/me", (route) => { protectedCalls++; return route.abort(); });
await page.goto("/profile");
await expect(page.getByText("Профиль доступен после авторизации.")).toBeVisible();
expect(protectedCalls).toBe(0);
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { reconcileMessages } from "../../src/reconcile";
import { sessionMemory } from "../../src/session";
import { SingleFlight } from "../../src/single-flight";
import { websocketJwtProtocol } from "../../src/realtime";
import type { Message } from "../../src/types";
const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({
message_id: id,
dialog_id: "dialog",
sender_type: "client",
content_kind: "text",
text: "Тест",
attachments: [],
safety_status: "allowed",
delivery_status: status,
created_at: createdAt,
});
describe("reconcileMessages", () => {
it("устраняет дубли, обновляет статус и сортирует сообщения", () => {
const result = reconcileMessages(
[message("2", "2026-01-02T00:00:00Z"), message("1", "2026-01-01T00:00:00Z")],
[message("2", "2026-01-02T00:00:00Z", "delivered"), message("3", "2026-01-03T00:00:00Z")],
);
expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]);
expect(result[1]?.delivery_status).toBe("delivered");
});
});
describe("UX session memory", () => {
it("хранит идентификатор только в памяти и очищает его", () => {
sessionMemory.set("ux-test");
expect(sessionMemory.id).toBe("ux-test");
sessionMemory.clear();
expect(sessionMemory.id).toBeNull();
expect(localStorage.getItem("ux_session_id")).toBeNull();
});
});
describe("SingleFlight", () => {
it("объединяет параллельные refresh операции", async () => {
const flight = new SingleFlight<number>();
let calls = 0;
const operation = async () => {
calls++;
await Promise.resolve();
return 42;
};
const [first, second, third] = await Promise.all([
flight.run(operation), flight.run(operation), flight.run(operation),
]);
expect([first, second, third]).toEqual([42, 42, 42]);
expect(calls).toBe(1);
});
});
describe("WebSocket authentication protocol", () => {
it("кодирует JWT как canonical han.jwt.<base64url(jwt)>", () => {
const protocol = websocketJwtProtocol("header.payload.signature");
expect(protocol).toBe("han.jwt.aGVhZGVyLnBheWxvYWQuc2lnbmF0dXJl");
expect(protocol).not.toContain("=");
});
});
@@ -0,0 +1,14 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"types": ["vitest/globals"]
},
"include": ["app", "src", "tests", "app.config.ts", "expo-env.d.ts"]
}
@@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
include: ["tests/unit/**/*.test.{ts,tsx}"],
clearMocks: true,
},
});
@@ -0,0 +1,229 @@
x-api-runtime: &api-runtime
build:
context: ../../api-backend
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
env_file:
- path: ../../.env
required: false
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
networks: [backend, observability]
security_opt: ["no-new-privileges:true"]
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
services:
frontend-static:
build:
context: ../../frontend-test-site
target: static
args:
EXPO_PUBLIC_API_BASE_URL: ${PUBLIC_WEB_URL}
EXPO_PUBLIC_AUTH_BASE_URL: ${PUBLIC_AUTH_URL}
EXPO_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID: han-chat-frontend
EXPO_PUBLIC_APP_ENV: ${APP_ENV:-production-like}
image: han-chat-frontend-static:${RELEASE_VERSION:-local}
volumes:
- frontend-static:/output
restart: "no"
read_only: true
tmpfs:
- /tmp:size=8m,mode=1777
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
keycloak:
build:
context: ../../keycloak
image: ${KEYCLOAK_IMAGE:-han-chat-keycloak:local}
env_file:
- path: ../../.env
required: false
environment:
KC_DB: postgres
KC_DB_URL: ${KEYCLOAK_DB_URL}
KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME}
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
KC_PROXY_HEADERS: xforwarded
KC_HTTP_ENABLED: "true"
KC_HTTP_RELATIVE_PATH: /auth
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL}
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?KEYCLOAK_OTP_MOCK_CODE is required}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required}
command: ["start", "--optimized", "--import-realm"]
expose: ["8080", "9000"]
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
networks: [public, backend, observability]
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && grep -q '200 OK' <&3"]
interval: 15s
timeout: 5s
retries: 12
start_period: 60s
restart: unless-stopped
security_opt: ["no-new-privileges:true"]
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
message-safety:
build:
context: ../../message-safety
image: ${MESSAGE_SAFETY_IMAGE:-han-chat-message-safety:local}
env_file:
- path: ../../.env
required: false
expose: ["8080"]
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
networks: [backend, observability]
depends_on:
redis: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
security_opt: ["no-new-privileges:true"]
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
api-backend:
<<: *api-runtime
expose: ["8000"]
depends_on:
redis: {condition: service_healthy}
keycloak: {condition: service_healthy}
message-safety: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
delivery-worker:
<<: *api-runtime
entrypoint: []
command: ["han-delivery-worker"]
depends_on:
api-backend: {condition: service_healthy}
bitrix-local-app: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-delivery-worker' in Path('/proc/1/cmdline').read_bytes()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
safety-recovery-worker:
<<: *api-runtime
entrypoint: []
command: ["han-safety-worker"]
depends_on:
api-backend: {condition: service_healthy}
message-safety: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-safety-worker' in Path('/proc/1/cmdline').read_bytes()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
cleanup-worker:
<<: *api-runtime
entrypoint: []
command: ["han-cleanup-worker"]
depends_on:
api-backend: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-cleanup-worker' in Path('/proc/1/cmdline').read_bytes()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
bitrix-local-app:
build:
context: ../../bitrix-local-app
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
env_file:
- path: ../../.env
required: false
expose: ["8080"]
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
networks: [backend, observability]
depends_on:
api-backend: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
security_opt: ["no-new-privileges:true"]
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
bitrix-sync:
build:
context: ../../bitrix-sync
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
env_file:
- path: ../../.env
required: false
expose: ["8080"]
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
networks: [backend, observability]
depends_on:
redis: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"]
interval: 15s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
security_opt: ["no-new-privileges:true"]
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
+7
View File
@@ -0,0 +1,7 @@
target
.git
.idea
.vscode
*.iml
*.log
.env
+17
View File
@@ -0,0 +1,17 @@
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.internal:6432/han_chat?sslmode=verify-full&currentSchema=keycloak&ApplicationName=keycloak
KC_DB_URL_PROPERTIES=currentSchema=keycloak
KC_BOOTSTRAP_ADMIN_USERNAME=bootstrap-admin
KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
KEYCLOAK_LOG_LEVEL=INFO
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
+25
View File
@@ -0,0 +1,25 @@
ARG KEYCLOAK_VERSION=26.1.4
FROM maven:3.9.9-eclipse-temurin-21 AS provider-build
WORKDIR /build
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
COPY --from=provider-build /build/target/han-phone-otp-provider.jar /opt/keycloak/providers/
COPY themes/han-phone /opt/keycloak/themes/han-phone
ENV KC_HEALTH_ENABLED=true \
KC_METRICS_ENABLED=true \
KC_DB=postgres \
KC_HTTP_RELATIVE_PATH=/auth
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.1.4
COPY --from=keycloak-build --chown=keycloak:keycloak /opt/keycloak/ /opt/keycloak/
COPY --chown=keycloak:keycloak realm/han-chat-realm.json /opt/keycloak/data/import/han-chat-realm.json
USER 1000
EXPOSE 8080 9000
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized", "--import-realm"]
+88
View File
@@ -0,0 +1,88 @@
# HAN Chat Keycloak
Production-like Keycloak 26.1.4 image and realm for OTP-only phone authentication. The module is self-contained and does not publish host ports; root nginx must proxy `/auth/*` to `keycloak:8080`.
## Security contract
- Realm `han-chat`; public client `han-chat-frontend`.
- Authorization Code flow only, mandatory PKCE S256; implicit, password/direct, device and service-account grants are disabled.
- Access tokens contain audience `han-chat-api`, canonical E.164 `phone_number` and boolean `phone_number_verified`.
- Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse `0`; SSO idle/max are 30/90 days.
- Realm brute-force protection uses temporary bounded lockouts.
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migration `han-otp-1.0.0` is applied by Keycloak's JPA entity provider.
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
- Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
- Mock mode is explicit. Startup rejects missing values, code `1234`, codes shorter than six characters, and HMAC keys shorter than 32 bytes. Disabling mock mode without a real delivery provider fails startup.
## Build and test
Requires Java 21 and Maven 3.9:
```bash
mvn -B -ntp clean verify
docker build -t han-chat/keycloak:26.1.4-otp-1.0.0 .
```
The Maven build shades only libphonenumber into the provider JAR; Keycloak SPI dependencies remain provided by the pinned server image.
## Configuration
Copy values from `.env.example` into the root backend `.env`; never commit `.env`. Generate independent random values for admin password, mock code, OTP HMAC key and settings bridge token.
Before production deployment replace the explicit placeholder entries in `realm/han-chat-realm.json`:
- `https://APP_LINK_HOST.example/auth/callback`
- `https://APP_LINK_HOST.example/auth/logout`
- `https://APP_WEB_ORIGIN.example`
Use exact Expo universal/app links and web origins. Do not replace them with wildcards. `https://tohin.ru/auth/callback` and `han-chat://auth/callback` are already allow-listed.
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
## Runtime
For standalone validation:
```bash
docker compose --env-file .env up --build
```
The service exposes only Docker-network ports:
- application HTTP: `8080`, relative path `/auth`;
- management health and metrics: `9000`;
- readiness: `GET http://keycloak:9000/auth/health/ready`;
- liveness: `GET http://keycloak:9000/auth/health/live`;
- Prometheus metrics: `GET http://keycloak:9000/auth/metrics`.
Only nginx may publish external ports. Preserve `Host`, `X-Forwarded-Proto=https`, `X-Forwarded-Host`, `X-Forwarded-Port=443` and the trusted client IP chain.
## Realm lifecycle
`--import-realm` is suitable for a clean environment. It does not safely reconcile an existing production realm. For changes to a live realm:
1. take a managed PostgreSQL backup/PITR checkpoint and export the current realm without users/secrets;
2. compare the desired safe subset (clients, scopes, flows, token policy);
3. apply through a controlled admin job or Admin API procedure;
4. verify discovery issuer, JWKS, PKCE login, refresh rotation and logout;
5. retain old passive signing keys until all tokens signed by them expire.
Private signing keys are generated and stored by Keycloak and are absent from the realm JSON.
## OTP data and operations
Provider tables:
- `han_otp_challenge`: expiring, one-time challenges with optimistic version and pessimistic verification lock;
- `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC;
- `han_otp_security_event`: append-only minimal outcomes without raw phone or OTP.
Resend marks an earlier active challenge as superseded. Verification locks a challenge row, increments attempts, and atomically consumes a valid challenge, preventing replay and parallel double use.
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
## Release and recovery
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
Backups must include the full Keycloak schema (realm signing keys and provider tables). After restore verify issuer `https://tohin.ru/auth/realms/han-chat`, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic.
@@ -0,0 +1,61 @@
services:
keycloak:
build:
context: .
dockerfile: Dockerfile
image: han-chat/keycloak:26.1.4-otp-1.0.0
command: ["start", "--optimized", "--import-realm"]
environment:
KC_DB: postgres
KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required}
KC_DB_URL_PROPERTIES: ${KC_DB_URL_PROPERTIES:-currentSchema=keycloak}
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-https://tohin.ru/auth}
KC_HOSTNAME_STRICT: "true"
KC_HTTP_ENABLED: "true"
KC_HTTP_PORT: "8080"
KC_HTTP_RELATIVE_PATH: /auth
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HTTP_MANAGEMENT_PORT: "9000"
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
KC_LOG_CONSOLE_OUTPUT: json
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
expose:
- "8080"
- "9000"
networks:
- public
- backend
- observability
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"]
interval: 15s
timeout: 5s
retries: 12
start_period: 60s
read_only: true
tmpfs:
- /tmp:size=64m,mode=1770
- /opt/keycloak/data/tmp:size=64m,uid=1000,gid=0,mode=0770
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 30s
restart: unless-stopped
networks:
public:
backend:
observability:
+102
View File
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.han.chat</groupId>
<artifactId>han-phone-otp-provider</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<keycloak.version>26.1.4</keycloak.version>
<libphonenumber.version>8.13.55</libphonenumber.version>
<junit.version>5.11.4</junit.version>
<mockito.version>5.15.2</mockito.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId>
<version>${libphonenumber.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>han-phone-otp-provider</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<artifactSet>
<includes>
<include>com.googlecode.libphonenumber:libphonenumber</include>
</includes>
</artifactSet>
<createDependencyReducedPom>false</createDependencyReducedPom>
<shadedArtifactAttached>false</shadedArtifactAttached>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,205 @@
{
"realm": "han-chat",
"enabled": true,
"displayName": "HAN Chat",
"registrationAllowed": false,
"registrationEmailAsUsername": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"loginWithEmailAllowed": false,
"duplicateEmailsAllowed": false,
"verifyEmail": false,
"rememberMe": true,
"sslRequired": "external",
"defaultSignatureAlgorithm": "RS256",
"accessTokenLifespan": 300,
"accessCodeLifespan": 60,
"accessCodeLifespanLogin": 300,
"ssoSessionIdleTimeout": 2592000,
"ssoSessionMaxLifespan": 7776000,
"clientSessionIdleTimeout": 2592000,
"clientSessionMaxLifespan": 7776000,
"revokeRefreshToken": true,
"refreshTokenMaxReuse": 0,
"offlineSessionMaxLifespanEnabled": true,
"offlineSessionMaxLifespan": 0,
"bruteForceProtected": true,
"permanentLockout": false,
"maxTemporaryLockouts": 0,
"failureFactor": 5,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"minimumQuickLoginWaitSeconds": 60,
"maxFailureWaitSeconds": 900,
"maxDeltaTimeSeconds": 43200,
"eventsEnabled": true,
"eventsExpiration": 7776000,
"enabledEventTypes": [
"LOGIN", "LOGIN_ERROR", "LOGOUT", "LOGOUT_ERROR",
"REFRESH_TOKEN", "REFRESH_TOKEN_ERROR", "REVOKE_GRANT", "REVOKE_GRANT_ERROR"
],
"adminEventsEnabled": true,
"adminEventsDetailsEnabled": false,
"internationalizationEnabled": true,
"supportedLocales": ["ru"],
"defaultLocale": "ru",
"loginTheme": "han-phone",
"browserFlow": "han-phone-otp-browser",
"clients": [
{
"clientId": "han-chat-frontend",
"name": "HAN Chat Frontend",
"enabled": true,
"publicClient": true,
"clientAuthenticatorType": "client-secret",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"authorizationServicesEnabled": false,
"frontchannelLogout": true,
"fullScopeAllowed": false,
"redirectUris": [
"https://tohin.ru/auth/callback",
"han-chat://auth/callback",
"https://APP_LINK_HOST.example/auth/callback"
],
"webOrigins": [
"https://tohin.ru",
"https://APP_WEB_ORIGIN.example"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://tohin.ru/##han-chat://auth/logout##https://APP_LINK_HOST.example/auth/logout",
"oauth2.device.authorization.grant.enabled": "false",
"oidc.ciba.grant.enabled": "false",
"use.refresh.tokens": "true",
"client.use.lightweight.access.token.enabled": "false"
},
"defaultClientScopes": ["openid", "profile", "phone", "han-chat-api"],
"optionalClientScopes": ["offline_access"]
}
],
"clientScopes": [
{
"name": "phone",
"description": "Verified E.164 phone claims",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "phone number",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "phone_number",
"claim.name": "phone_number",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "phone verified",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "phone_number_verified",
"claim.name": "phone_number_verified",
"jsonType.label": "boolean",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
},
{
"name": "han-chat-api",
"description": "HAN Chat API audience",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "han-chat-api audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "han-chat-api",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"authenticationFlows": [
{
"alias": "han-phone-otp-browser",
"description": "Cookie SSO or phone OTP only",
"providerId": "basic-flow",
"topLevel": true,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "auth-cookie",
"requirement": "ALTERNATIVE",
"priority": 10,
"authenticatorFlow": false
},
{
"flowAlias": "han-phone-otp-forms",
"requirement": "ALTERNATIVE",
"priority": 20,
"authenticatorFlow": true
}
]
},
{
"alias": "han-phone-otp-forms",
"description": "Normalize phone, reserve challenge and verify OTP",
"providerId": "basic-flow",
"topLevel": false,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "han-phone-identity",
"requirement": "REQUIRED",
"priority": 10,
"authenticatorFlow": false
},
{
"authenticator": "han-phone-otp",
"requirement": "REQUIRED",
"priority": 20,
"authenticatorFlow": false
}
]
}
],
"requiredActions": [],
"components": {
"org.keycloak.keys.KeyProvider": [
{
"name": "rsa-generated",
"providerId": "rsa-generated",
"subType": "rsa-generated",
"config": {
"priority": ["100"],
"algorithm": ["RS256"],
"keySize": ["2048"]
}
}
]
}
}
@@ -0,0 +1,54 @@
package ru.han.chat.keycloak;
import java.net.URI;
import java.time.Duration;
final class Config {
static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true);
static final String MOCK_CODE = required("KEYCLOAK_OTP_MOCK_CODE");
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
static final Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
static final int MAX_VERIFY_ATTEMPTS = integer("KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS", 5, 1, 10);
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
"http://api-backend:8000/internal/settings/v1/otp"));
static final String SETTINGS_TOKEN = required("KEYCLOAK_SETTINGS_BRIDGE_TOKEN");
static {
if (!MOCK_ENABLED) {
throw new IllegalStateException("No real OTP delivery provider configured; refusing to start");
}
if (MOCK_CODE.isBlank() || "1234".equals(MOCK_CODE) || MOCK_CODE.length() < 6) {
throw new IllegalStateException("KEYCLOAK_OTP_MOCK_CODE must be a non-default secret of at least 6 characters");
}
if (HMAC_KEY.length < 32) {
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
}
}
private Config() {}
private static String required(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " is required");
}
return value;
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
private static boolean bool(String name, boolean fallback) {
return Boolean.parseBoolean(env(name, Boolean.toString(fallback)));
}
private static int integer(String name, int fallback, int min, int max) {
int value = Integer.parseInt(env(name, Integer.toString(fallback)));
if (value < min || value > max) throw new IllegalStateException(name + " is outside allowed range");
return value;
}
}
@@ -0,0 +1,35 @@
package ru.han.chat.keycloak;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
final class Crypto {
private static final SecureRandom RANDOM = new SecureRandom();
private Crypto() {}
static String randomId() {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String hmac(String purpose, String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(Config.HMAC_KEY, "HmacSHA256"));
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal((purpose + "\0" + value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("HMAC unavailable", exception);
}
}
static boolean constantTimeEquals(String left, String right) {
return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,110 @@
package ru.han.chat.keycloak;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import java.time.Duration;
import java.time.Instant;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
import ru.han.chat.keycloak.entity.OtpSecurityEventEntity;
import ru.han.chat.keycloak.entity.OtpSendCounterEntity;
final class OtpStore {
private final EntityManager entityManager;
OtpStore(KeycloakSession session) {
this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
OtpChallengeEntity reserve(String phone, SettingsBridge.Limits limits) {
Instant now = Instant.now();
String phoneHmac = Crypto.hmac("phone", phone);
OtpSendCounterEntity counter = entityManager.find(
OtpSendCounterEntity.class, phoneHmac, LockModeType.PESSIMISTIC_WRITE);
if (counter == null) {
counter = new OtpSendCounterEntity();
counter.id = phoneHmac;
counter.phoneHmac = phoneHmac;
counter.windowStart = now;
counter.lastSentAt = Instant.EPOCH;
counter.sendCount = 0;
entityManager.persist(counter);
} else if (counter.windowStart.plus(Duration.ofHours(24)).isBefore(now)) {
counter.windowStart = now;
counter.sendCount = 0;
}
if (counter.sendCount >= limits.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, "limited", "daily_limit");
throw new OtpLimitException("otp_send_limited");
}
if (counter.lastSentAt.plusSeconds(limits.minSecondsBetween()).isAfter(now)) {
event("otp_send", phoneHmac, null, "limited", "cooldown");
throw new OtpLimitException("otp_send_limited");
}
counter.sendCount++;
counter.lastSentAt = now;
entityManager.createQuery("""
update OtpChallengeEntity c set c.consumedAt = :now, c.providerStatus = 'superseded'
where c.phoneHmac = :phone and c.consumedAt is null and c.expiresAt > :now
""").setParameter("now", now).setParameter("phone", phoneHmac).executeUpdate();
OtpChallengeEntity challenge = new OtpChallengeEntity();
challenge.id = Crypto.randomId();
challenge.phoneHmac = phoneHmac;
challenge.destinationMasked = PhoneNormalizer.mask(phone);
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, Config.MOCK_CODE);
challenge.createdAt = now;
challenge.expiresAt = now.plus(Config.OTP_TTL);
challenge.verifyAttempts = 0;
challenge.maxVerifyAttempts = Config.MAX_VERIFY_ATTEMPTS;
challenge.settingsVersion = limits.version();
challenge.providerId = "mock-" + Crypto.randomId();
challenge.providerStatus = "accepted";
entityManager.persist(challenge);
event("otp_send", phoneHmac, challenge.id, "success", "mock");
return challenge;
}
boolean consume(String challengeId, String suppliedCode) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
Instant now = Instant.now();
if (challenge == null || challenge.consumedAt != null || !challenge.expiresAt.isAfter(now)) {
if (challenge != null) event("otp_verify", challenge.phoneHmac, challengeId, "failure", "expired_or_used");
return false;
}
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
event("otp_verify", challenge.phoneHmac, challengeId, "limited", "attempt_limit");
return false;
}
challenge.verifyAttempts++;
boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode));
if (!valid) {
event("otp_verify", challenge.phoneHmac, challengeId, "failure", "invalid");
return false;
}
challenge.consumedAt = now;
challenge.providerStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, "success", "verified");
return true;
}
private void event(String type, String phoneHmac, String challengeId, String outcome, String details) {
OtpSecurityEventEntity event = new OtpSecurityEventEntity();
event.id = Crypto.randomId();
event.occurredAt = Instant.now();
event.eventType = type;
event.phoneHmac = phoneHmac;
event.challengeId = challengeId;
event.outcome = outcome;
event.details = details;
entityManager.persist(event);
}
static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); }
}
}

Some files were not shown because too many files have changed in this diff Show More