Перевірка відповідей
Маршрут може оголосити ResponseSchema — схему ArkType того, що повертає контролер. Крім документації маршрутів (GET /<prefix>/doc/routes), сервер перевіряє за нею кожну відповідь під час роботи, і схема працює як allow-list полів: клієнт отримує лише поля, які в ній описані. Реакцію на порушення задає змінна RESPONSE_VALIDATION_CONTRACT.
Це перевірка під час виконання. Ще до запуску TypeScript звіряє зі схемою тип результату контролера, на всіх маршрутах і для відповідей без тіла, які тут не перевіряються (3xx, 204, 205) — див. Тип відповіді хендлера. TypeScript зайві поля дозволяє; відсікає їх ця перевірка.
Режими
Порушень контракту два: відповідь не відповідає схемі (неправильний тип, бракує поля) або має поля, яких у схемі немає. Невідповідність — завжди 500: вирізати таку відповідь неможливо, а відправити як є означало б віддати клієнту й зайві поля, якщо вони там є.
RESPONSE_VALIDATION_CONTRACT | Невідповідність схемі | Зайві поля | За замовчуванням |
|---|---|---|---|
strict | 500, error у лог | 500, error у лог | поза production |
log | 500, error у лог | вирізаються, клієнт отримує решту, warn у лог | production |
Production — коли APP_IS_PRODUCTION=true (або APP_ENV=production/prod). Невідоме значення зупиняє старт сервера.
- Режиму
offнемає. Схема виконується на кожну відповідь у будь-якому режимі: ArkType вирізає ключі тим самим обходом, що й перевіряє, тож режим «без перевірки» нічого б не заощадив. - Sampling немає. Allow-list, що діє на частині запитів, не захищає.
- У
strictзайве поле дає500, а не тихе вирізання: у розробці помилку transformer'а треба помітити й виправити. Вирізання — страховка для production.
RESPONSE_VALIDATION_CONTRACT=logЯк вводити режими під час розгортання — Розгортання.
Розгортання
Шаблон поки не має production; це порядок для проєкту, який його розгортає. У log невідповідність схемі теж дає 500, а перемикача «вимкнути» немає, тож розбіжності треба знайти до production.
| Етап | Режим | Умова переходу далі |
|---|---|---|
| Розробка, CI, e2e | strict (за замовчуванням) | Тести й e2e зелені |
| Staging | log | Щонайменше 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'). На кожну відповідь:
rejectпроходить, і обхід відповіді нічого не знайшов → відправляється той самий об'єкт, без копії. Це шлях майже всіх відповідей.- Серед помилок є не лише зайві ключі →
500. - У відповіді є значення, яке
JSON.stringifyможе серіалізувати інакше, ніж його бачила схема →500. Дозволені лише простий об'єкт, простий масив іDateбез власногоtoJSON(перелічуваного чи ні). Екземпляр класу, підкласDateчи масиву,Map, власнийtoJSONу будь-якого з них — відхиляються. - Помилки — лише зайві ключі: у
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 залишаються, бо стан сесії на сервері вже змінено.
{ "status": "error", "code": "internal_error", "message": "Internal Server Error" }WebSocket
ws-api-dispatcher.ts перевіряє пряму відповідь обробника так само: у log зайві ключі вирізаються з data, а відхилена відповідь стає:
{
"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):
{
"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) |
|---|---|---|---|---|
| 10 | 0.019 | 0.014 | 0.020 (1.09×) | 0.19 (9.9×) |
| 100 | 0.179 | 0.143 | 0.197 (1.10×) | 1.80 (10.0×) |
| 1000 | 1.86 | 1.38 | 1.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.