Действие — это открытый эндпоинт

мутации≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Форма с действием выглядит как форма: поле, кнопка и функция рядом с разметкой. На сервере это обычный HTTP-эндпоинт с публичным адресом — и позвать его можно без вашей формы, без вашей кнопки и вообще без браузера. Значит, всё, что приезжает в действие, — те самые «данные извне» из части III, а права проверяются внутри действия, а не в разметке вокруг него.

Разделы темы:·

В первой статье главы мы разобрали, что уходит по сети: сборка оставляет от действия строку-идентификатор, React кладёт её в форму, а отправка превращается в POST на адрес текущей страницы с этим идентификатором в заголовке next-action. Прочитайте это утверждение задом наперёд — и получится вся сегодняшняя статья. Если запрос с таким идентификатором вызывает функцию, то любой запрос с таким идентификатором вызывает функцию.

Что нужно, чтобы позвать действиеОткуда это взять
адрес страницы, где действие объявленоиз адресной строки
идентификатор действияиз разметки той же страницы
поля запросанаписать любые
ваша форма, ваша кнопка, ваш JavaScriptне нужны вовсе

Идентификатор непредсказуем и пересчитывается между сборками — но на этом его защитные свойства кончаются. Он лежит в разметке страницы у каждого, кому эту страницу показали; достать его оттуда и повторить запрос — упражнение на десять минут. Модель ниже огрубляет всё, кроме сути: вызов приходит по идентификатору, а разметка в этом не участвует. Прогноз перед запуском: сработает ли удаление, если кнопку никто не рисовал?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Условие {isAdmin && <DeleteButton />} из статьи про условия в разметке решает задачу интерфейса: не показывать человеку то, чем он не может воспользоваться. Задачу доступа оно не решает никак — оно вообще выполняется в другом месте и в другое время, чем действие.

ПриёмОт чего защищает
не отрисовать кнопкуот случайного нажатия — и только
required, maxLength, type="email" в разметкеот опечатки; отключается в инспекторе за секунду
проверка в клиентском компоненте перед вызовомот опечатки; клиентский код целиком в руках посетителя
проверка доступа в раскладке layout.tsxни от чего: действие выполнится раньше, чем что-либо начнёт отрисовываться
непредсказуемый идентификатор действияот угадывания вслепую; из своей же страницы он читается
проверка сессии и входных данных внутри действияот всего перечисленного

Правило одной строкой: действие — не продолжение формы, а самостоятельный вход в приложение. Всё, что вы бы проверили в маршруте, написанном руками, проверяется и здесь.

Одну атаку Next закрывает сам. Чужой сайт может нарисовать у себя форму, ведущую на ваш адрес, и отправить её от имени вашего посетителя — вместе с его куками. Это подделка межсайтового запроса, CSRF. Поэтому Next сверяет у запроса к действию заголовок Origin с Host и отклоняет запрос, если они разошлись.

Отсюда единственная настройка, которая может понадобиться: если приложение стоит за обратным прокси и Host честно отличается от внешнего адреса, список допустимых источников задаётся в next.config — experimental.serverActions.allowedOrigins. И заметьте, чего эта проверка не делает: она отвечает на вопрос «откуда пришёл запрос», а не «кто его отправил» и «можно ли ему это».

У любого действия два вопроса: кто пришёл и что он просит сделать. Ошибка почти всегда одна и та же — оба ответа берут из одного места, из присланных данных. Идентификатор задачи в скрытом поле, идентификатор пользователя рядом с ним, и функция сравнивает одно с другим.

Прогноз перед запуском: обе функции получают одни и те же поля от одного и того же посетителя, и в полях указана чужая задача с её настоящим владельцем. Что ответит каждая?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая функция не сломана — она честно сравнила два числа. Беда в том, что оба числа прислал тот, у кого мы спрашиваем разрешения. Сравнение task.ownerId === fields.userId проверяет не права, а внутреннюю согласованность запроса: подставьте под чужую задачу её настоящего владельца — и проверка пройдёт.

Отсюда расстановка, которая годится для любого действия. Личность достаётся из сессии — из куки, которую расшифровывает сервер. Аргументы отвечают только на вопрос «над чем работаем». Право проверяется сравнением того, что лежит в хранилище, с тем, что сказала сессия, — и ни в одном из трёх шагов присланный «кто» не участвует.

На чужую задачу вторая функция отвечает «отказано», и это уже уступка примеру. В настоящем действии на чужое и на несуществующее лучше отвечать одинаково: разница в ответах — готовый способ перебором выяснить, какие записи у вас есть.

Шифрует. Действие, объявленное внутри серверного компонента, замыкает переменные этого компонента — и раз вызов приходит из браузера, замкнутое приходится возить туда и обратно. Чтобы эти значения не читались прямо в исходнике страницы, Next шифрует их ключом, который создаётся на каждую сборку. Скрытое поле формы, наоборот, лежит открытым текстом: там правится всё.

Только это ответ на другой вопрос. Шифрование говорит «значение не подменили», а не «отправитель имеет право»: идентификатор действия остаётся публичным, тот же запрос можно повторить через час, а сессия за это время успевает истечь. Да и рядом в той же форме лежат обычные поля, которые правятся свободно. Проверку внутри действия шифрование не заменяет — оно лишь избавляет от привычки считать разметку надёжным хранилищем.

Второй вопрос после «кто» — «что прислали». Здесь помощь приходит откуда не ждали: типы FormData написаны честно, и компилятор возражает раньше, чем вы успеете что-то испортить. Тип у get — FormDataEntryValue | null, а сам FormDataEntryValue — это File | string.

То есть в одном значении сразу два отказа: поля может не оказаться вовсе, а может приехать файл. Прогноз: сколько диагностик даст пример и что напечатают три последние строки?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика одна, на первом вызове, — и после неё код всё равно выполняется: тип string в сигнатуре не мешает файлу доехать до тела функции. Два вызова saveTask напечатали одно и то же, и в этом весь урок: as string — обещание компилятору, а не проверка. Обещание оказалось ложным, и title.length вернул undefined. А String() честно превратил файл в строку "[object File]" длиной 13 символов, а отсутствующее поле — в строку "null" длиной 4.

Оба «лекарства» одинаково вредны тем, что после них проверка title.length > 0 проходит. В базу уезжает задача с названием null, а рядом в логах — ни одной ошибки.

Как читают полеЧто пропускает дальше
form.get("x") as stringи null, и File — под видом строки
String(form.get("x"))"null" и "[object File]" — настоящие непустые строки
String(form.get("x") ?? "")"[object File]"; отсутствие поля становится пустой строкой
typeof v === "string"ничего: остаётся ровно строка, и это видит компилятор

Это ровно тот разговор, который был в части III в статье «Данные извне — это unknown». Там источником был JSON.parse, здесь — FormData, но правило одно: тип на границе не утверждают, а проверяют, и проверку пишут так, чтобы её результат видел компилятор. typeof v === "string" сужает тип; as string просто велит компилятору замолчать.

В примерах прошлых статей главы поля читались через String(formData.get("title") ?? "") — для показа механики этого достаточно, для настоящего действия мало. Разбор из следующего раздела и есть недостающая часть.

С числами всё то же самое, только незаметнее: строку в число превращает Number, и он почти никогда не жалуется. Прогноз: три формы — заполненная как надо, лишившаяся поля amount и присланная со злым умыслом. Что окажется в каждом объекте?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пропавшее поле дало не NaN, а честный ноль: get вернул null, а Number(null) — это 0. Проверка Number.isNaN(amount), которую сюда обычно и дописывают, промолчит. Пустая строка и строка из одних пробелов дают тот же ноль, и защиты от отрицательного количества здесь нет никакой. Заодно посмотрите на первую строку вывода: даже честная форма привезла заголовок вместе с пробелами по краям — до базы доедут и они.

Number("0x10") — это 16, а Number("1e3") — 1000. Форма прислала строку, а не число, и как именно эта строка станет числом, решаете вы, а не отправитель.

Лечится это не россыпью if по всему действию, а одной функцией разбора: на входе FormData, на выходе — размеченное объединение из части III. Либо готовое значение нужного типа, либо причина отказа; третьего не дано, и компилятор за этим следит.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Заодно решилась задача из второй статьи главы: parsed.ok === false — это уже готовое состояние формы, которое действие возвращает через useActionState, и текст отказа в нём лежит рядом с меткой. Проверка на сервере и сообщение читателю получаются из одного разбора, а не пишутся дважды.

И оговорка про парность проверок. Браузерная проверка не бесполезна — она нужна, но занята другим делом.

Где проверкаЗачем онаЧто будет, если её убрать
в разметке: required, pattern, maxLengthподсказать человеку до отправкилишний круг до сервера и обратно
в клиентском компонентеживая подсветка ошибки по мере вводаформа станет менее удобной
в действии, на серверерешить, менять ли данныев базе окажется что угодно

В настоящем проекте вместо руками написанного parseNewTask обычно берут библиотеку схем — zod, valibot, что-то из этого ряда. Схема описывается один раз, а тип значения выводится из неё, и повторять его отдельным interface не приходится.

Меняется от этого только объём кода. Схема живёт на сервере, вызывается первой строкой действия и возвращает то же самое: разобранное значение или список причин отказа. Понимать, зачем оно там стоит, важнее, чем помнить синтаксис конкретной библиотеки, — поэтому в этой статье разбор написан руками.

К тому же привязка к конкретной библиотеке слабее, чем кажется. У zod и valibot есть общий интерфейс Standard Schema, и тому, кто читает схему через него, всё равно, какой библиотекой она написана. Так одна схема уходит и в форму, и в действие; как это устроено, разобрано на полке React и Next.

Про вход мы договорились. Осталась вторая половина границы: то, что действие возвращает, тоже пересекает сеть и попадает в браузер — а через useActionState ещё и оседает в состоянии формы. Вернуть из действия строку, которую вернула база, — значит отправить её читателю целиком.

Прогноз: тип обещает два поля, а функция отдаёт объект из четырёх. Что скажет компилятор и что окажется в выводе?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор промолчал — и был прав по своим правилам. Типизация в TypeScript структурная: объект с четырьмя полями подходит везде, где ждут объект с двумя, потому что нужные два у него есть. Про лишние поля компилятор ругается только на свежих литералах — это разбиралось в части III. Здесь литерала нет, есть возвращённое значение функции, и оно проходит молча вместе с хешем пароля.

Тип, который «сужает» ответ, ничего не срезает: типы стираются на сборке, а в браузер едет обычный объект. Срезать поля должен код — и вот тут строгость к литералам работает уже на вас.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика на последнем объявлении — та самая проверка лишних свойств: «объектный литерал может использовать только известные свойства». Она и есть причина писать отображение руками: пока ответ собирается из полей по одному, любое лишнее поле в нём — ошибка сборки, а не тихая утечка.

Тот же разговор про тексты ошибок. Во второй статье главы мы договорились ошибку возвращать, а не бросать; здесь добавляется, что возвращать нужно свой текст, а не сообщение исключения: в нём легко приезжают имена таблиц, куски запроса и путь к файлу на сервере.

Всё сказанное складывается в один порядок, и порядок этот не случайный: сначала дешёвые проверки, потом обращения к базе, работа — последней. Сессия не подтвердилась — не надо разбирать поля. Поля не разобрались — не надо ходить в базу.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пример ничего не печатает, и так задумано: сервера у песочницы нет, объявленных функций тоже. Проверяемо здесь другое — что каркас проходит через компилятор со strict: true и что типы в нём сходятся.

Двадцать строк обвязки на одно переименование выглядят избыточно ровно до первого раза, когда их не написали. В настоящем проекте первые два шага уезжают в общую функцию вроде requireUser(), а третий — в правило доступа рядом с моделью данных; в самом действии остаётся четыре вызова и работа. Но исчезнуть из действия эти шаги не могут: другого места, где запрос впервые встречается с вашими данными, у него нет.

ШагОткуда берутся данныеЧего здесь не делают
личностьсессия, расшифрованная на серверене читают userId из формы и из привязанных аргументов
разбор входаFormData через проверки типане пишут as string и не полагаются на required в разметке
правозапись, поднятая из хранилищане сравнивают присланное с присланным
работа и обновлениеразобранное значениене возвращают наружу строку из базы целиком

  • Server Action — публичный эндпоинт: вызов приходит по идентификатору действия, и ни форма, ни кнопка, ни ваш JavaScript для этого не нужны.
  • Условная отрисовка, атрибуты required и проверки в клиентском компоненте — это удобство, а не защита: всё это выключается в инспекторе.
  • «Кто пришёл» берётся только из сессии; аргументы и скрытые поля отвечают лишь на вопрос «над чем работаем», и право проверяется на записи из хранилища.
  • formData.get отдаёт FormDataEntryValue | null: as string и String() не проверяют тип, а маскируют его — проверяет typeof v === "string".
  • Number отсутствующего поля, пустой строки и пробелов — это 0, а не NaN: вход разбирают функцией, возвращающей размеченное объединение.
  • Возвращённое значение тоже пересекает границу: строку из базы отдавать наружу нельзя, ответ собирают литералом из перечисленных полей.

1/4Кнопка «Удалить проект» рисуется только администратору: {isAdmin && <DeleteButton />}. Достаточно ли этого, чтобы не проверять права внутри самого действия?

2/4Какой тип у выражения formData.get("title")?

3/4Поле amount убрали из формы в инспекторе и отправили. Что даст Number(formData.get("amount")) в действии?

4/4Действие объявлено как Promise<PublicUser> с полями id и name, а возвращает строку из базы, где есть ещё и passwordHash. Что произойдёт?

Действие, которое верит форме

Действие удаляет комментарий и проверяет права — но берёт всё, включая личность автора, из присланных полей. Перепишите его так, чтобы «кто» приходило из сессии, а из формы — только идентификатор комментария. Функция sessionUser уже есть.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Личность берётся из сессии, а из полей остаётся только «что»: идентификатор комментария. Сравнивается запись, поднятая из хранилища, с тем, что сказала сессия.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание и на второй ответ: чужой комментарий получает тот же текст, что и несуществующий. Разные ответы позволили бы перебором выяснить, какие записи в базе есть, — а поле authorId из формы теперь можно просто удалить, оно ни на что не влияет.

Разбор одного поля

Напишите parseRating(form): оценка приходит в поле rating и должна быть целым числом от 1 до 5. Возвращайте размеченное объединение — { ok: true, value } либо { ok: false, error }. Все четыре подготовленные формы должны получить верный ответ.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сначала проверка типа — она отсекает и отсутствующее поле, и файл, — потом преобразование, потом диапазон. Одна Number.isInteger закрывает сразу NaN, дробное и бесконечность.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверять отдельно на NaN не нужно: Number.isInteger(NaN) — это false. А вот проверить границы обязательно: без них Number.isInteger пропустит и ноль, и минус сотню.

Что вернуть наружу

Действие возвращает профиль, и компилятор не возражает — а в браузер уезжает вся строка из базы. Соберите ответ так, чтобы наружу попали только id и name.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ответ собирается литералом, поле за полем. Это не только режет лишнее, но и включает проверку лишних свойств: приписать сюда email компилятор уже не даст.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Соблазнительный вариант с деструктуризацией — const { passwordHash, ...rest } = user; return rest; — работает ровно один раз: как только в таблице появится новая колонка, она уедет наружу сама. Перечисление того, что отдаём, надёжнее перечисления того, что прячем.