ADR 0037 — CORS, которого не было у engine, и порт как часть контракта
Контекст
Сообщение о «проблеме CORS между web и api» привело к проверке трёх пар взаимодействия системы. Результат: одна сломанная пара, одна пара, которая никогда не была под угрозой, и первопричина, которая была вовсе не в CORS.
Всё ниже измерено, с curl -H "Origin: …" и с headless Chrome, читающим
консоль, — а не выведено из чтения кода.
Что правильно
| пара | механизм | вердикт |
|---|---|---|
| web → api (HTTP) | CORS Nest, WEB_ORIGIN | ok — preflight возвращает allow-origin, allow-headers с четырьмя заголовками, allow-credentials |
| web → engine (WebSocket) | check_origin Phoenix | ok — handshake отвечает 101; WebSocket не проходит через CORS, а check_origin уже читал WEB_ORIGIN начиная с Фазы 4a |
api → engine (/internal/*) | service token (RN-035) | ok, и CORS неприменим — 401 без токена, 400 валидации с токеном, и ИДЕНТИЧНЫЙ ответ с Origin и без |
engine → api (/internal/*) | service token | ok, и CORS неприменим — 403 без токена, 400 с токеном, то же для Origin |
Последние две стоит проговорить полностью, потому что вопрос естественен, а
ответ структурен: CORS — механизм браузера. Вызывающий в этих двух
направлениях — серверный HTTP-клиент (fetch Node в api, Finch в engine),
который не реализует same-origin policy и полностью игнорирует эти заголовки.
Настраивать нечего, а настройка была бы хуже — см. решение 2.
Что сломано: web → engine по HTTP
У endpoint engine не было никакого CORS. GET /health отвечал 200 с
правильным телом и без единого заголовка Access-Control-*, поэтому браузер
отбрасывал ответ:
Access to fetch at 'http://localhost:4000/health' from origin
'http://localhost:5173' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Видимый эффект: StatusPage показывала engine: error при совершенно
здоровом engine. Ни один тест этого не ловил, и не по небрежности — на стороне
сервера ответ был правильным. Не хватало заголовка, а тест контроллера не
утверждает заголовок CORS.
Дефект старше этой сессии, но стал заметнее теперь:
ADR 0036 сделал
/status публичной и связал её с футером экранов auth, так что неверная строка
стала достижимой до входа.
Первопричина исходного сообщения, которая не была CORS
vite.config.ts фиксировал port: 5173 без strictPort. Когда 5173 занят —
забытый pnpm dev в другом терминале, compose dev на том же хосте, — Vite
поднимается на 5174 и предупреждает одной строкой лога загрузки. Api
принимает ровно http://localhost:5173, поэтому приложение открывается
нормально, а всё блокируется:
blocked by CORS policy: … 'http://localhost:3000/health' (origin 5174)
blocked by CORS policy: … 'http://localhost:3000/auth/refresh' (origin 5174)
blocked by CORS policy: … 'http://localhost:4000/health' (origin 5174)
Заблокированный /auth/refresh — то, из-за чего экран выглядит разлогиненным.
А сообщение говорит о CORS, а не о порте — поэтому всё время уходит не туда.
Хуже: естественное «исправление» — ослабить CORS api, что чинит 5174 и ломает
5173.
Решение
1. Собственный plug CORS в engine вместо Corsica
EngineWeb.Plugs.Cors, ~40 строк логики. Corsica — очевидный выбор и решает
гораздо больше, чем нужно: этот plug обслуживает GET/HEAD, без учётных
данных, на трёх фиксированных путях, с двумя заголовками в списке. CLAUDE.md
требует обоснования для новой библиотеки, и «40 строк» — обоснование.
Если однажды engine выставит настоящий API для браузера — POST, cookie,
собственный заголовок, — замена на Corsica начнёт окупаться. Moduledoc это
фиксирует, чтобы следующий человек знал, что альтернатива рассматривалась.
2. Plug стоит в ENDPOINT и фильтрует по пути
Не в pipeline роутера, и причина измерима: pipeline роутера выполняется только
после совпадения маршрута. Маршрута OPTIONS нет, поэтому preflight умирает с
404 до любого plug pipeline — проверено до исправления (OPTIONS /health →
404). Plug CORS, не видящий preflight, — это пол-plug, а недостающая половина
— та, что ломается в день, когда web добавит заголовок.
В endpoint он видит всё, и цена — явно сказать, где он применяется. Это тот же
дизайн, что у EngineWeb.Plugs.AccessLog, который тоже фильтрует по префиксу
пути, потому что не может зависеть от роутера.
Allowlist — /health, /live и /ready. Два намеренных исключения, оба
— из соображений безопасности:
/internal/*— 13 маршрутов, через которые api командует engine. Как установлено в контексте, CORS там ничего не включил бы; что он сделал бы — объявил бы браузеру, что тот — ожидаемый клиент этого канала. Это информация, которую мы не хотим давать, о поверхности, до которой мы не хотим, чтобы кто-то пытался достучаться из браузера./metrics— scrape Prometheus. Внутренней метрике незачем быть читаемой JavaScript какой-либо страницы.
3. Неизвестный origin получает ответ, а не 403
Запрос обслуживается нормально и уходит без заголовка; чтение блокирует
браузер, которому это решение и принадлежит. Ответ 403 превратил бы каждый
законный запрос без Origin — probe kubelet, curl, docker/smoke.sh — в
новый режим отказа, созданный случайно при починке другого.
vary: origin сопровождает allow-origin. Это не украшение: без него прокси,
сохранивший ответ для одного origin, может отдать его другому с неправильным
заголовком внутри.
4. WEB_ORIGIN читается ОДИН раз и питает обоих потребителей
check_origin сокета уже читал переменную; новому plug нужен был тот же
список. Дублированное чтение — то, как появилась дыра: у сокета origin был
настроен две фазы, а у HTTP не было ничего, потому что ничто не обязывало их
идти вместе.
Теперь runtime.exs вычисляет :web_origins один раз, наверху, а
check_origin начинает выводиться из него.
В продакшене нет значения по умолчанию для разработки. Api бросает
исключение при загрузке, когда WEB_ORIGIN отсутствует (cors-origins.ts);
engine оставляет список пустым, что закрывает доступ из браузера, не роняя
процесс. Асимметрия намеренная: CORS — смысл существования того участка api, а
в engine он периферийный — очереди Oban и каналы Phoenix продолжают работать.
Engine, который не поднимается из-за панели статуса, меняет маленькую проблему
на большую.
Пустой список также не становится check_origin: [], который Phoenix
прочитал бы как «ни один origin не подходит» и уронил бы живую панель команды.
В этом случае действует строгое значение Phoenix по умолчанию (true,
сравнение с PHX_HOST).
5. strictPort: true в Vite
Порт — часть контракта CORS, поэтому его нельзя выбирать молча. С strictPort
Vite отказывается подниматься и говорит Port 5173 is already in use — это
правдивая информация, вместо трёх ошибок CORS, указывающих не туда.
Цена реальна и принята: тому, кто хочет два dev-сервера одновременно, теперь
нужен явный --port. Явный выбор — ровно то, что нужно, потому что другой порт
всё равно должен войти в WEB_ORIGIN.
6. maxAge в CORS api и engine
Каждый вызов web к api проходит preflight: api-client шлёт
Authorization и traceparent, которые не являются safelisted. Без кэша
preflight каждый запрос — две поездки. Кэш браузера — по URL+методу, и при
refetchInterval TanStack Query, бьющем в один и тот же URL снова и снова,
именно там он окупается.
10 минут в обоих. Достаточно коротко, чтобы изменение allowedHeaders не
застревало в кэше того, у кого была открыта вкладка.
Последствия
StatusPageначинает говорить правду. Раньше:api: ok,engine: errorпри работающем engine. Теперь: обаokи ноль ошибок CORS в консоли.- Коллизия порта перестаёт маскироваться под проблему CORS. Это обмен молчаливого и обманчивого режима отказа на шумный и правильный.
/internal/*остаётся без CORS, и теперь есть тест, это утверждающий. Граница стала утверждением — включая утверждение, что в списке путей ровно три записи, чтобы перемещение границы появлялось в diff.- На одну зависимость меньше, чем у очевидного решения, и moduledoc, объясняющий, когда пересмотреть решение.
- Engine получил свой первый тест HTTP-заголовка. 15 случаев покрывают то, что тест контроллера структурно не покрывает: ответ был правильным, а заголовка не хватало.
Что остаётся хвостом
exposedHeadersне настроен ни в одном из двух. Сегодня web не читает ни одного заголовка ответа —trace_idуApiErrorберётся из trace, который сгенерировал сам клиент, а не сервер. Настраивать сейчас значило бы включить неиспользуемую возможность.- У engine нет CORS для
POST, намеренно: ничто в браузере не делает к немуPOST. Когда начнёт, список методов и список заголовков должны вырасти вместе — тест preflight — место, где это будет замечено. EngineWeb.RouteSurfaceTestкрасный вorigin/devещё до этого изменения (ActionClauseErrorвSessionCommandController.create/2, из-за пустого тела в тесте, утверждающем, что внутренние маршруты принимают валидный токен). Проверено, что он падает так же без каких-либо изменений этой поставки, и ни один коммит отсюда не трогаетapps/engine, кроме описанного выше. Зафиксировано, не исправлено: это другой вопрос.