Skip to content

Перевірка відповідей ​

Маршрут може оголосити ResponseSchema — схему ArkType того, що повертає контролер. Крім документації маршрутів (GET /<prefix>/doc/routes), сервер перевіряє за нею кожну відповідь під час роботи, і схема працює як allow-list полів: клієнт отримує лише поля, які в ній описані. Реакцію на порушення задає змінна RESPONSE_VALIDATION_CONTRACT.

Це перевірка під час виконання. Ще до запуску TypeScript звіряє зі схемою тип результату контролера, на всіх маршрутах і для відповідей без тіла, які тут не перевіряються (3xx, 204, 205) — див. Тип відповіді хендлера. TypeScript зайві поля дозволяє; відсікає їх ця перевірка.

Режими ​

Порушень контракту два: відповідь не відповідає схемі (неправильний тип, бракує поля) або має поля, яких у схемі немає. Невідповідність — завжди 500: вирізати таку відповідь неможливо, а відправити як є означало б віддати клієнту й зайві поля, якщо вони там є.

RESPONSE_VALIDATION_CONTRACTНевідповідність схеміЗайві поляЗа замовчуванням
strict500, error у лог500, error у логпоза production
log500, error у логвирізаються, клієнт отримує решту, warn у логproduction

Production — коли APP_IS_PRODUCTION=true (або APP_ENV=production/prod). Невідоме значення зупиняє старт сервера.

  • Режиму off немає. Схема виконується на кожну відповідь у будь-якому режимі: ArkType вирізає ключі тим самим обходом, що й перевіряє, тож режим «без перевірки» нічого б не заощадив.
  • Sampling немає. Allow-list, що діє на частині запитів, не захищає.
  • У strict зайве поле дає 500, а не тихе вирізання: у розробці помилку transformer'а треба помітити й виправити. Вирізання — страховка для production.
dotenv
RESPONSE_VALIDATION_CONTRACT=log

Як вводити режими під час розгортання — Розгортання.

Розгортання ​

Шаблон поки не має production; це порядок для проєкту, який його розгортає. У log невідповідність схемі теж дає 500, а перемикача «вимкнути» немає, тож розбіжності треба знайти до production.

ЕтапРежимУмова переходу далі
Розробка, CI, e2estrict (за замовчуванням)Тести й e2e зелені
StaginglogЩонайменше 7 днів без записів Response does not match ResponseSchema; кожен undeclared_key розібрано
Production, перші релізиlog (за замовчуванням)—
Production, за бажаннямstrictЩонайменше 14 днів без undeclared_key у логах і Sentry; рішення команди
  • Що розбирати. Записи з тегом Sentry response_validation: kind: mismatch — завжди 500, виправити схему або контролер; kind: undeclared — поле, якого немає в схемі. Або це витік (прибрати в transformer), або клієнту воно потрібне (додати в схему).
  • Що змінює strict у production. Лише одне: зайві поля дають 500 замість вирізання. Невідповідність схемі і так 500 в обох режимах. strict має сенс, коли команда хоче, щоб помилка transformer'а не лишалась непоміченою навіть у production.
  • Відкат. strict → log змінною RESPONSE_VALIDATION_CONTRACT. Невідповідність схемі змінною не вимикається: відкат — попередня версія бекенда.

Як перевіряється відповідь ​

validate-response.ts при старті будує для маршруту схему onDeepUndeclaredKey('reject') і, у log, onDeepUndeclaredKey('delete'). На кожну відповідь:

  1. reject проходить, і обхід відповіді нічого не знайшов → відправляється той самий об'єкт, без копії. Це шлях майже всіх відповідей.
  2. Серед помилок є не лише зайві ключі → 500.
  3. У відповіді є значення, яке JSON.stringify може серіалізувати інакше, ніж його бачила схема → 500. Дозволені лише простий об'єкт, простий масив і Date без власного toJSON (перелічуваного чи ні). Екземпляр класу, підклас Date чи масиву, Map, власний toJSON у будь-якого з них — відхиляються.
  4. Помилки — лише зайві ключі: у strict → 500; у log → відправляється копія без них. Якщо вирізання повернуло помилку, теж 500: вихідний об'єкт іде клієнту лише після успішного reject.

Ключі з іменами властивостей Object.prototype (constructor, __proto__, hasOwnProperty, toString тощо) ArkType до 2.2.9 читав через прототип і не вважав зайвими. Тому окремий обхід шукає їх серед власних ключів на будь-якій глибині й обробляє як зайві: 500 у strict, вирізання в log. Починаючи з 2.2.9 ArkType і сам повідомляє про такі ключі; у логі кожен шлях все одно з'являється один раз, а вирізання лишається за обходом.

Зайві ключі шукаються на будь-якій глибині: у вкладених об'єктах, в елементах масивів і в кожній гілці union (успіх і помилка).

Перевіряється об'єкт, який повернув контролер, до JSON-серіалізації, і схема описує саме той JSON, який отримує клієнт. Тому в схемах відповідей немає Date і bigint: transformer віддає id рядком (String(id)), а дату ISO-рядком UTC (toISOString()), схема оголошує 'string'. Date чи bigint, що потрапили у відповідь, — невідповідність, 500. Infinity / -Infinity ArkType приймає як number, але JSON записав би null, тож обхід відповіді теж дає 500 (код non_finite_number).

У схемах відповідей немає й морфів (.pipe, .parse): відправляється вихідний об'єкт, а не результат схеми, тож морф змінив би формат лише для відповідей із зайвими ключами. Це постійне правило, не тимчасове. Тести в route-access.spec.ts не дають додати в схему маршруту ні морф, ні Date, ні bigint.

Поля без гарантії allow-list ​

Вміст поля, яке схема описує як unknown, не фільтрується на будь-якій глибині. TypeScript-тип цього не змінює: він не перевіряє значення під час виконання. Такі поля дозволені лише як винятки, і той самий тест падає на кожне нове:

ПолеЧому unknown
details канонічної помилкиБудується лише з конструкторів AppError
messageData логів push (GET /push-subscriptions/:id, …/:id/logs)data сповіщення, яке пристрій цього користувача вже отримав

Маршрут, який може повернути помилку, мусить приймати повну канонічну помилку (reason, details, csrfToken): схема з помилкою, описаною вручну, перетворила б відповідь mapControllerError на 500. Це теж перевіряє тест.

HTTP ​

Перевірку виконує checkHandlerResponse у server.ts одразу після контролера і відправляє те, що повернула перевірка.

ВипадокПеревіряється
Контролер виконався й відповідь має тіло — з будь-яким статусом, напр. GET /ready → 503Так
Middleware зупинив запит (401, 403, 429, CSRF), помилка валідації запитуНі
Контролер кинув виняток (відповідь формує обробник помилок)Ні
Відповідь без тіла: 3xx (зокрема OAuth-редиректи, 304), 204, 205, порожній 2xxНі

Правило «є тіло чи ні» — одна функція hasResponseBody() у response-body.ts, яку використовує і sendResponse.

Коли перевірка замінює відповідь на 500, усі заголовки, які виставив контролер (ETag, Content-Disposition тощо), відкидаються: вони описували відхилену відповідь. Cookie залишаються, бо стан сесії на сервері вже змінено.

json
{ "status": "error", "code": "internal_error", "message": "Internal Server Error" }

WebSocket ​

ws-api-dispatcher.ts перевіряє пряму відповідь обробника так само: у log зайві ключі вирізаються з data, а відхилена відповідь стає:

json
{
    "status": "error",
    "data": null,
    "error": { "code": "internal_error", "message": "Internal server error" }
}

Broadcast ​

Push-події (broadcast:*) перевіряє broadcast-service.ts схемою з реєстру BroadcastEventSchemas у тих самих режимах: перевірка одна на подію, до серіалізації; у лозі й Sentry transport: "broadcast", route — назва події. Невалідна подія не надсилається (500 відповісти нікому) — таблиця в WebSocket. Повідомлення, які WS-обробник надсилає сам, поза broadcastService, не перевіряються: усі асинхронні повідомлення (відповіді support-агента) ідуть через нього.

Логи та Sentry ​

Невідповідність пише error Response does not match ResponseSchema, зайві ключі — Response has keys undeclared in ResponseSchema (error у strict, warn у log):

json
{
    "route": "api/auth/login",
    "transport": "http",
    "requestId": "…",
    "mode": "log",
    "kind": "undeclared",
    "rejected": false,
    "issues": [{ "path": "user.avatar", "code": "undeclared_key", "expected": "removed" }],
    "truncated": 0,
    "suppressed": 0
}
  • Лише шлях, код і очікуваний тип — без значень з відповіді, щоб у логи не потрапляли персональні дані. Зайвий ключ має код undeclared_key.
  • Не більше 20 шляхів на запис, решта — лічильником truncated.
  • Один запис і одна подія Sentry на маршрут і вид (kind: mismatch або undeclared) за хвилину: вирізання не ховає 500 того ж маршруту. Пропущені рахуються в suppressed наступного запису.
  • Подія Sentry має теги response_validation (режим), transport і kind; рівень error для 500, warning для вирізання. Сам лог позначено sentrySkip, тож подія не дублюється.

Як це вбудовано ​

routesHandler у router.ts один раз при старті створює для кожного маршруту responseCheck — функцію перевірки або null для маршруту без ResponseSchema. Тест будує перевірку в обох режимах для кожного маршруту й кожної broadcast-події: union з гілками, що перекриваються, ArkType з delete не приймає, і сервер не стартував би.

Вартість ​

Методика. Скрипт bench-response-check.ts (LOG_LEVEL=silent pnpm --filter backend bench:response): схема — union успіху зі списком і канонічної помилки, елемент із 8 полями (зокрема Date і масив), медіана 7 серій після прогріву, Node 24, Apple M1 Pro. Час на виклик у мс. HTTP-стек, БД і мережа не входять у заміри.

ЕлементівJSON.stringifyПеревірка до feat-046Звичайна відповідьІз зайвим ключем у кожному елементі (log)
100.0190.0140.020 (1.09×)0.19 (9.9×)
1000.1790.1430.197 (1.10×)1.80 (10.0×)
10001.861.381.98 (1.07×)18.0 (9.7×)

Broadcast-подія (support_chat_token, admin_user_online_upsert, той самий скрипт, режим log): перевірка 0.5–0.7 мкс на подію при серіалізації 0.55–1.0 мкс; вона одна на подію, тож від кількості адресатів не залежить.

Заміряно createResponseCheck цілком: reject, обхід відповіді, вирізання й запис у лог (LOG_LEVEL=silent).

  • Звичайна відповідь коштує приблизно ще одну серіалізацію і на ~40–50 % дорожча за перевірку до feat-046: до перевірки схеми додано обхід відповіді. Копії відповіді немає.
  • Відповідь із зайвими ключами дорожча приблизно вдесятеро: ArkType збирає помилку на кожен зайвий ключ. Це шлях помилки transformer'а, а не норма; кожна така відповідь видна в логах з undeclared_key.

Автор: Олександр Блажейко · blazheiko@gmail.com