Тема
ТЗ — Входящие вебхуки (cms/webhooks-in)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: notal (платёжные колбэки) Статус: ТЗ к разработке
Назначение и возможности
Приём входящих вебхуков (платёжные колбэки, внешние уведомления): верификация подписи, идемпотентная обработка, быстрый ответ 200 с отложенной обработкой в очереди. Граница ответственности с /cms-v2/security — этот модуль отвечает только за приём и маршрутизацию, не за общую фильтрацию трафика.
- Единая точка приёма
/api/v1/webhooks/{provider}для всех внешних источников - Верификация подписи запроса (HMAC/провайдер-специфичная схема)
- Идемпотентность по
event_id— повторная доставка не обрабатывается дважды - Быстрый ответ 200 сразу после верификации, обработка — в очереди
- Журнал полученных вебхуков (заголовки, тело, статус обработки)
- Реплей вебхука из Filament (повторная постановка в очередь обработки)
- Маршрутизация в обработчик конкретного модуля-получателя по коду провайдера
Зависимости и выключение
requires: ядро
Поведение при выключении: входящие запросы на /api/v1/webhooks/* отклоняются с 503, внешние системы должны повторить доставку позже — данные не теряются на стороне студии, но требуют ретрая со стороны отправителя.
Стоимость внешних API: не применимо — webhooks-in исключительно входящая сторона (принимает запросы, не инициирует их), платных вызовов вовне не совершает и тарифных лимитов провайдера не расходует.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_webhooks_in_log | id, provider, event_id, headers (json), payload (json), status, processed_at | журнал приёма, уникальность по provider+event_id |
Заметки: status — PHP Enum; headers/payload — JSONB с касом 'array'; уникальный составной индекс (provider, event_id) — основа идемпотентности, не только проверка в коде; журнальная таблица — кандидат на BRIN по created_at/processed_at.
ПДн-паспорт (матрица v2.2): cms_webhooks_in_log.headers/payload могут содержать ПДн плательщика/клиента — модуль принимает чужие вебхуки «как есть» (платёжные колбэки, CRM- уведомления) и не знает доменную структуру payload конкретного провайдера, поэтому не может выборочно исключить поля при записи. Срок хранения — webhooks-in.log_retention_days (90 дней), ретеншн-джоба удаляет записи старше порога. Участие в «выгрузить всё по субъекту»/«забыть по запросу» (152-ФЗ) — как и cms/integrations-bus, сам webhooks-in не умеет находить записи по субъекту (не знает, какое поле payload — идентификатор человека у конкретного провайдера); модуль обязан предоставить хук ядра «найти и анонимизировать записи журнала по предикату» (по provider
- произвольному JSON-условию на
payload), который вызывает модуль-получатель, знающий структуру своего провайдера — анонимизация схлопываетheaders/payloadдо{provider, event_id}, статус и факт обработки не теряются.
Входные и выходные данные
Whitelist-принцип: всё, что не перечислено во «Входах», модуль обязан отвергать (§11 стандарта) — неизвестный провайдер, незадекларированное поле фильтра, отсутствующий заголовок подписи.
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Вебхук (внешний провайдер), POST /api/v1/webhooks/{provider} | заголовки (подпись, timestamp), тело event_id + payload (JSON) | EnsureWebhookSignature (HMAC по секрету источника, hash_equals), whitelist полей payload по схеме провайдера; неизвестный {provider} → 404 до чтения тела |
Admin API, GET /api/v1/admin/webhooks-in/log | filter[provider], filter[status], filter[event_id], sort=-created_at, cursor | FormRequest, whitelist фильтров/сортировки (§7 стандарта); неизвестный параметр → 422 |
Admin-действие, POST /log/{id}/replay | id записи журнала | FormRequest: запись существует и принадлежит cms_webhooks_in_log, webhooks-in.replay_enabled = true, право webhooks-in.manage |
Настройки (settings-store), группа webhooks-in | флаги/пороги — см. «Настройки» | схема настройки (тип, дефолт, валидация), запись — только settings.manage |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Модуль-получатель (provides: payment-gateway и др.) | событие WebhookReceived (provider, event_id) | канал 1 EventBus, payload события — не сырое тело запроса |
Очередь webhooks-in | job обработки (provider, event_id, log_id) | сериализованный job, идемпотентный по event_id |
| Admin UI (Filament / API журнала) | список записей журнала, статусы success/rejected/failed | конверт {data, meta}, keyset-пагинация |
cms/health | доля failed-обработок за последний час | агрегат health-чека модуля |
| Внешняя система (ответ на вебхук) | HTTP-статус (2xx/4xx) | пустое тело или минимальный JSON, без деталей причины отказа |
Настройки (группа webhooks-in)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
webhooks-in.enabled | bool | true | нет | Приём входящих вебхуков |
webhooks-in.signature_verification_required | bool | true | нет | Обязательность проверки подписи |
webhooks-in.log_retention_days | int | 90 | нет | Хранение журнала входящих вебхуков |
webhooks-in.replay_enabled | bool | true | нет | Разрешить реплей из админки |
webhooks-in.rate_limit_per_source | int | 120 | нет | Лимит запросов/мин на один provider (429 + Retry-After при превышении) |
webhooks-in.timestamp_tolerance_seconds | int | 300 | нет | Допустимое расхождение timestamp запроса с текущим временем (защита от replay) |
webhooks-in.allowed_sources | array | [] (= все зарегистрированные) | нет | Whitelist кодов провайдеров/IP-подсетей; вне списка — 404 до проверки подписи |
webhooks-in.max_payload_size_kb | int | 256 | нет | Верхний предел тела запроса, больше — отказ до парсинга JSON |
webhooks-in.daily_quota_per_source | int, nullable | null (без квоты) | нет | Суточный лимит запросов на один provider — ловит «тихий» шторм ниже rate_limit_per_source, но аномальный в сумме за день |
webhooks-in.orphan_detection_minutes | int | 60 | нет | Окно, после которого принятый вебхук без обработавшего подписчика WebhookReceived помечается quarantined |
Kill-switch двух уровней. Общий — webhooks-in.enabled: false отдаёт 503 всем источникам (см. «Зависимости и выключение»), рубильник на весь приём. Точечный — webhooks-in.allowed_sources: удаление кода конкретного провайдера из whitelist мгновенно (следующий запрос, без деплоя и рестарта) останавливает приём именно от него (404 до проверки подписи), не затрагивая остальные источники — штатный способ аварийно отключить один скомпрометированный/сбойный provider, не останавливая приём от остальных.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/webhooks/{provider} | подпись провайдера | Приём вебхука |
| GET | /api/v1/admin/webhooks-in/log | admin (webhooks-in.view) | Журнал приёма с фильтрами |
| POST | /api/v1/admin/webhooks-in/log/{id}/replay | admin (webhooks-in.manage) | Реплей обработки вебхука |
Журнал — keyset-пагинация (не OFFSET).
Admin-эндпоинты журнала/реплея (GET /log, POST /log/{id}/replay) отвечают на 401/403/404/422/429 конвертом {"message": …, "code": "...", "errors": {…}} по ревизии ядра 14.07.2026, п.1 — code машиночитаем для клиентов admin API. Сам вебхук-эндпоинт (POST /api/v1/webhooks/{provider}) — исключение по дизайну: см. «Безопасность», код причины отказа существует только во внутреннем журнале (cms_webhooks_in_log.status), в HTTP-ответ внешнему провайдеру не попадает.
Компоненты
Filament: страница журнала входящих вебхуков с кнопкой реплея, фильтром по статусу (включая quarantined, см. «Крайние случаи»). Команды: cms:webhooks-in:replay --json — принимает как одиночный id, так и диапазон --provider=<code> --from=<ts> --to=<ts>, поддерживает --dry-run (отчёт «сколько записей будет переиграно» без постановки в очередь).
Демо-контент: у модуля нет блоков/виджетов — не применимо в терминах schema/demo-props. Для галереи Filament-страницы журнала и playground — сидер из 2–3 фиктивных записей cms_webhooks_in_log (provider = demo, статусы success/rejected/failed), достаточных для демонстрации UI журнала без реального внешнего источника.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
WebhookReceived | вебхук принят и верифицирован | provider, event_id |
WebhookProcessingFailed | обработка в очереди завершилась ошибкой | provider, event_id, error |
FilterBus не используется. Provides: маршрутизация принятого события в обработчик модуля-получателя по коду провайдера — получатели подписываются на WebhookReceived, не регистрируя собственный публичный роут приёма.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро (RequestContext) | сервис-вызов ядра | in | резолвит контекст запроса перед приёмом (при мультисайте — site_id источника) |
Модуль-получатель по коду провайдера (provides: payment-gateway/delivery-provider/crm-connector и т.п.) | событие WebhookReceived (канал 1) | out | получатель сам подписывается и обрабатывает факт; webhooks-in не знает, кто подписан (см. ⚠️ Противоречие в «Крайних случаях») |
cms/audit (suggests) | событие WebhookReceived/WebhookProcessingFailed (канал 1) | out | фиксирует приём/ошибку в журнале аудита, если модуль включён — иначе тихая деградация |
cms/attack-monitor (suggests) | событие WebhookProcessingFailed + счётчик отказов подписи (канал 1) | out | детектирует шторм невалидных подписей как атаку; при отсутствии модуля алерт не формируется |
cms/health | health-чек из манифеста | out | агрегирует долю failed-обработок за час, отставание очереди webhooks-in |
NotificationDispatch (ядро, контракт notification-channel) | сервис-вызов (канал 3) | out (опционально) | уведомление админа при шторме/деградации подписи; при отсутствии канала — лог-fallback |
Фоновая работа
Очередь webhooks-in для отложенной обработки после верификации подписи и быстрого ответа 200; реплей из Filament ставит то же событие повторно в очередь. Внешние вызовы — только из очереди, никогда синхронно в контроллере приёма.
Производительность и кеш
Ожидаемые объёмы: типично единицы–десятки вебхуков в минуту на источник (платёжные колбэки, CRM-уведомления); пиковый шторм — сотни в минуту при массовых ретраях внешней системы после её сбоя или дублирующей рассылке события. Модуль обязан переживать шторм без деградации остального сайта — rate_limit_per_source режет поток на входе, очередь webhooks-in работает буфером.
Горячий путь — приём (POST /api/v1/webhooks/{provider}) — обязан отвечать 2xx с минимальным бюджетом: 1 запрос на резолв провайдера/секрета (кешируемо), 1 insert журнала (уникальный индекс идемпотентности). Никакой бизнес-логики получателя синхронно в контроллере — она уходит в очередь уже после ACK.
Критичные индексы: уникальный (provider, event_id) — источник истины для идемпотентности, не кеш; индекс по provider (выборка журнала по источнику, rate-limit); BRIN по created_at/processed_at (append-only журнал, keyset-пагинация по времени).
Что кешируется: версия/хеш секрета источника (для быстрой сверки подписи без похода в .env/конфиг на каждый запрос) — не сам секрет; кеш инвалидируется командой при ротации секрета, не событием SettingChanged (секреты не живут в settings-store, §6 стандарта). Whitelist провайдеров (webhooks-in.allowed_sources) читается из кеша группы настроек — 0 запросов на горячем пути. Собственных тегов CacheTags модуль не объявляет — журнал приёма не участвует в page-cache.
Реконструкция состояния получателя по журналу. cms_webhooks_in_log хранит полное тело каждого принятого вебхука за весь log_retention_days — это делает журнал источником истины, пригодным не только для повтора одной записи, но и для восстановления состояния модуля-получателя целиком (например платёжный модуль потерял часть записей или переехал на новую БД). cms:webhooks-in:replay --provider=<code> --from=<ts> --to=<ts> переигрывает диапазон в хронологическом порядке приёма (не порядке id, если возможна гонка вставки), с --dry-run (отчёт: сколько записей попадёт в диапазон, без постановки в очередь) — так модуль-получатель пересобирает своё состояние из журнала, не дожидаясь повторной доставки от внешней системы (она может не хранить историю так долго или не поддерживать ручной повтор).
Ранбук: симптом → команда
| Симптом | Что проверить/сделать |
|---|---|
Шторм невалидных подписей от одного provider | cms/attack-monitor + журнал rejected по этому provider; вероятна атака на конкретный источник — точечный kill-switch (убрать код из webhooks-in.allowed_sources, см. «Настройки»/«UX-требования») |
Очередь webhooks-in отстаёт, health красный | Приём уже отвечает 2xx (потери нет), но обработка задерживается — проверить нагрузку/число воркеров очереди webhooks-in, при необходимости масштабировать воркер |
WebhookReceived без подписчиков дольше orphan_detection_minutes | Записи со статусом quarantined в журнале — проверить, что ожидаемый модуль-получатель (payment-gateway/crm-connector/…) включён; после включения — ручной реплей карантинных записей |
Запись status=failed (обработка упала после ретраев) | Журнал с фильтром по статусу failed, разобрать причину по WebhookProcessingFailed.error, ручной реплей — cms:webhooks-in:replay --json |
| Подозрение на утерю/рассинхрон данных у получателя | Массовый реплей диапазона --provider --from --to с --dry-run для оценки объёма, затем без него — реконструкция состояния из журнала (см. выше) |
Безопасность
Единственная публичная поверхность — вебхук-эндпоинт /api/v1/webhooks/{provider}, граница ядра под верификацию подписи (HMAC/провайдер-специфичная схема), обязательна при webhooks-in.signature_verification_required — запрос без валидной подписи отклоняется до постановки в очередь. Ответственность разделена с /cms-v2/security: модуль не занимается общей фильтрацией трафика, только приёмом и маршрутизацией конкретных вебхуков.
Конкретные векторы:
- подделка подписи — секрет источника хранится только в
.env/config/webhooks-in.php(anti-hardcode), никогда в settings-store и БД; - timing-атака на сравнение подписи — сравнение HMAC только через
hash_equals()(constant-time), обычное===/==запрещено; - replay —
timestamp+nonce/event_id, окно расхожденияtimestamp_tolerance_seconds, повторныйevent_idотклоняется идемпотентностью (не как ошибка — см. «Крайние случаи»); - SSRF при верификационном handshake — если провайдер требует callback на URL из payload (domain-verification паттерн некоторых платёжных/CRM-систем), такой URL проверяется по allowlist известных хостов провайдера, а не вызывается вслепую; на старте модуля такого handshake нет — вектор фиксируется на будущее для новых провайдеров;
- утечка секретов в логи/ошибки — журнал (
headers/payload) хранит заголовки запроса, но значение подписи и секрет источника в журнал и в тело ответа не попадают; ответ на невалидную подпись — общий человеческий текст, без деталей проверки (см. «Крайние случаи», п. 1).
Матрица ролей (permissions webhooks-in.view, webhooks-in.manage):
| Роль | .view (журнал) | .manage (реплей, чувствительные тела) |
|---|---|---|
| Посетитель/API-клиент | нет (публичен только сам приём вебхука) | нет |
| Редактор | нет | нет |
| Менеджер | да — список и статусы записей журнала | нет |
| Админ/studio | да | да — реплей одной записи, массовый реплей по диапазону, доступ к телам headers/payload в чувствительном журнале |
Реплей и просмотр сырых тел запроса — повышенная роль (.manage), т.к. журнал может содержать ПДн (см. «Модель данных»); менеджер видит статусы и метаданные, но не обязан видеть тело.
UX-требования
Админ:
- пустое состояние журнала: нет ни одного принятого вебхука или нет зарегистрированных источников → подсказка «источники не настроены» со ссылкой, какой модуль-получатель включить;
- список последних входящих — статус
success/rejected/failedцветом/иконкой, без сырых кодов ответа; - массовые действия: реплей пачки записей (выбор чекбоксами + «Повторить обработку»), с подтверждением количества и провайдера перед постановкой в очередь;
- человеческие ошибки: «Подпись вебхука неверна» вместо
401, «Источник не зарегистрирован» вместо404, «Слишком много запросов от источника» вместо429(аудитория «админ» из §12 стандарта); - подтверждение необратимых операций: массовый реплей и любая операция очистки/сброса журнала требуют явного подтверждения с указанием периода/количества записей;
- точечный kill-switch: удаление кода провайдера из списка
webhooks-in.allowed_sourcesв настройках — штатный способ мгновенно остановить приём от одного проблемного источника, не трогая остальные и не выключая модуль целиком (см. «Настройки»); UI подсказывает это действие при шторме невалидных подписей от конкретногоprovider.
Внешняя система (посетитель API): UI отсутствует, но воспринимаемая скорость — быстрый ACK (2xx до тяжёлой обработки, см. «Крайние случаи», п. 4); ответ на отказ — машиночитаемый код без подробностей причины (см. «Безопасность»).
Крайние случаи и типовые баги
- неверная/отсутствующая подпись → отказ до постановки в очередь (401/403), тело ответа не раскрывает причину («ожидалась подпись X, получена Y» — запрещено), запись в журнал со статусом
rejected; - replay-атака →
timestampвнеtimestamp_tolerance_secondsотклоняется; повторныйevent_idв пределах окна не считается ошибкой источника — отклоняется идемпотентностью (см. ниже), не HMAC-проверкой; - шторм вебхуков от одного источника →
rate_limit_per_sourceвозвращает 429 +Retry-Afterсверх лимита; очередь буферизует принятое; при переполнении очереди приём продолжает быстро отвечать 2xx, а обработка отстаёт — фиксируется health-чеком, не роняет приём; - ответ 2xx отправлен до тяжёлой обработки, обработка потом падает → job уходит в failed после исчерпания ретраев, статус журнала →
failed, событиеWebhookProcessingFailed; внешняя система уже получила 200 и сама не ретраит — единственный путь довести факт до получателя — ручной/автоматический реплей из Filament, иначе событие теряется безвозвратно; - дубль доставки одного
event_id→ уникальный индекс(provider, event_id)не даёт вставить вторую запись, обработчик повторно не запускается, внешней системе всё равно возвращается 200 (иначе она уйдёт в бесконечный ретрай); - гонка параллельной доставки одного
event_id→ constraint violation на втором insert трактуется как штатный дубль (см. выше), не как ошибка сервера (500); - выключение модуля посреди приёма → новые запросы на
/api/v1/webhooks/*получают 503 (см. «Зависимости и выключение»); уже поставленные в очередь job'ы доигрываются воркером — выключение модуля не останавливает очередь автоматически, это отдельный шаг эксплуатации; - отсутствие suggests-модуля-получателя (например ещё не поставлен нужный
payment-gateway-провайдер) → вебхук принимается и логируется штатно (подпись проверяется по источнику, не по наличию подписчика), ноWebhookReceivedуходит без подписчиков — факт нигде не применяется; риск молчаливой потери бизнес-события. Механизм карантина: запись, чейWebhookReceivedне подобран ни одним обработчиком в течениеwebhooks-in.orphan_detection_minutes(дефолт 60 минут), помечается статусомquarantined— отдельным отsuccess/rejected/failed(там либо успех, либо ошибка проверки/обработки; здесь — «принят, но никем не подхвачен»). Виден в Filament отдельным фильтром журнала, алертится черезcms/healthкак потенциальная потеря бизнес-события; после включения нужного модуля-получателя запись из карантина реплеится вручную (карантин не переигрывается автоматически, чтобы не создать шторм при массовом включении модуля); - суточная квота источника превышена (
webhooks-in.daily_quota_per_source) → приём не блокируется, внешняя система всё равно получает 200 (иначе — ретрай-шторм с её стороны), но событие алертится черезcms/healthкак «тихий» шторм нижеrate_limit_per_source, требующий проверки — квота задумана как индикатор аномалии, не как ограничитель доставки; - огромный payload / некорректный JSON → тело больше
max_payload_size_kbотклоняется до парсинга; невалидный JSON → общий 400, в журнал пишутся заголовки и обрезанное тело для диагностики, процесс не падает; - неизвестный источник (код провайдера не зарегистрирован либо не входит в
webhooks-in.allowed_sources) → 404 до проверки подписи, без подробностей в ответе; попадает в общий rate-limit по IP, чтобы не стать способом перебора списка провайдеров; - пустой/не настроенный секрет источника → модуль обязан отказывать все запросы этого провайдера (fail closed), а не пропускать их как «подпись не проверяется» — иначе комбинация
signature_verification_required = falseс незаполненным секретом превращается в дыру; - ⚠️ Противоречие: сквозной пример в обмене данными описывает
cms/webhooks-in → PaymentsService::confirm() [канал 4: requires], то есть прямой вызов публичного сервиса конкретного платёжного модуля по жёсткомуrequires. Текущее ТЗ (разделы «Зависимости», «События и обмен») описывает модуль как generic-роутер:requires: ядробез привязки к конкретным получателям, маршрутизация — событиемWebhookReceived, на которое получатель сам подписывается по коду провайдера. Эти две модели несовместимы: либо webhooks-in жёстко зависит от каждого поддерживаемого провайдера (requiresрастёт с числом провайдеров, что ломает «модуль не трогает чужого» при добавлении нового провайдера без правки webhooks-in), либо сквозной пример в data-exchange.md упрощён и на деле имеет в виду, что получатель события (напримерcms/pay-gateways-ru) сам вызываетPaymentsService::confirm()внутри своего обработчикаWebhookReceived, а не webhooks-in напрямую. Предложение разрешения: считать generic event-based модель (текущее ТЗ) канонической для webhooks-in, а формулировку в data-exchange.md — сокращённой записью, где «webhooks-in → PaymentsService::confirm()» на самом деле означает «обработчикWebhookReceived, зарегистрированный платёжным модулем, вызывает PaymentsService::confirm()»; уточнить текст сквозного примера в data-exchange.md отдельным PR.
Донорский код
| Что взять | Путь |
|---|---|
| Приём платёжного колбэка | notal/src/app/Http/Controllers/WebhookController.php |
| Верификация подписи (middleware) | notal/src/app/Http/Middleware/EnsureWebhookSignature.php |
Легенда доноров тут — код, не данные: EnsureWebhookSignature и обработчик колбэка переносятся как реализация, а не как источник строк для импорта. Легаси-импорт (§16 стандарта) не применим — модуль не хранит доменных данных для миграции: журнал cms_webhooks_in_log начинается с нуля в момент подключения модуля на проекте, старые вебхук-логи donor-сайта (если были) не переносятся — они относятся к обработчику-получателю (например платёжному модулю), не к webhooks-in.
Тесты и приёмка
- [ ] Контрактный тест: запрос без валидной подписи отклоняется до постановки в очередь, ответ не раскрывает причину отказа
- [ ] Сравнение подписи использует
hash_equals()(тест на constant-time поведение, не строковое==) - [ ] Health-чек модуля отражает долю failed-обработок за последний час
- [ ] При выключении модуля входящие запросы получают 503, без падения приложения
- [ ] Повторная доставка с тем же
event_idне обрабатывается дважды (тест идемпотентности), ответ внешней системе всё равно 200 - [ ] Гонка параллельной доставки одного
event_idне приводит к 500 (constraint violation трактуется как дубль) - [ ]
timestampвнеwebhooks-in.timestamp_tolerance_secondsотклоняется (тест на replay) - [ ] Шторм запросов сверх
webhooks-in.rate_limit_per_sourceполучает 429 +Retry-After, очередь не переполняется бесконтрольно - [ ] Неизвестный провайдер / источник вне
webhooks-in.allowed_sources→ 404 до проверки подписи - [ ] Payload больше
webhooks-in.max_payload_size_kbи невалидный JSON отклоняются без падения процесса - [ ] Пустой/не настроенный секрет источника → отказ (fail closed), не пропуск без проверки
- [ ] Права
webhooks-in.view/webhooks-in.manageразграничивают журнал и реплей - [ ] Ответ 200 отдаётся до завершения бизнес-обработки (обработка асинхронна через очередь)
- [ ] Вебхук без подписчика
WebhookReceivedдольшеwebhooks-in.orphan_detection_minutesпомечаетсяquarantined(неsuccess/failed) и алертится черезcms/health - [ ] Массовый реплей диапазона (
provider+ период) воспроизводит записи в хронологическом порядке приёма,--dry-runне ставит задачи в очередь и отдаёт корректный отчёт - [ ] Превышение
webhooks-in.daily_quota_per_sourceалертит черезcms/health, но не блокирует приём — внешняя система по-прежнему получает 200 - [ ] Удаление кода провайдера из
webhooks-in.allowed_sourcesмгновенно отдаёт 404 запросам именно этого провайдера, приём остальных источников не затронут - [ ] Хук ядра «найти и анонимизировать записи журнала по предикату» схлопывает
headers/payloadдо{provider, event_id}, не трогая записи другихprovider/предикатов - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
webhooks_in_test,migrate:freshзапрещён