152-ФЗ на статическом сайте: где именно проходит граница
Норма про базы данных, а не про то, откуда отдаётся HTML. Отсюда: пререндер без единого принимающего эндпоинта, приёмник и БД в РФ, порядок операций внутри приёмника и почему провал шага не может отвечать {ok:true}.
О 152-ФЗ применительно к сайтам написано много, и почти всё сводится к одному неверному тезису: «сайт должен хоститься в России». В законе этого нет. Ст. 18 ч. 5 говорит другое: оператор обязан обеспечить запись, систематизацию, накопление, хранение, уточнение и извлечение персональных данных граждан РФ с использованием баз данных, находящихся на территории России. Норма — про базы данных и операции с ними, а не про то, откуда браузеру приходит HTML.
Разница практическая. Она означает, что граница между «можно за границей» и «только в РФ» проходит не по домену и не по проекту, а по одному конкретному запросу — тому, в котором персональные данные впервые появляются. Всё до этой границы — статика, и ей безразлично, какой CDN её раздаёт. Всё после — российский сервер, без исключений.
Оговорка сразу: ниже инженерное чтение нормы, а не юридическая консультация. Архитектура закрывает техническую часть и не закрывает уведомление в Роскомнадзор, договор с хостером и текст политики.
Что делает маркетинговая страница
Ничего, что подпадает под обработку. Она отдаёт байты. Нет формы — нет субъекта, нет операции. Отсюда первое архитектурное следствие, и формулировать его надо аккуратно. Правильная формулировка — не «ноль серверных функций», а ноль эндпоинтов, которые принимают тело запроса. Все страницы сайта пререндерятся на сборке; во фронтенде нет ни одного route handler и ни одного server action. Сам сайт собран как output: "standalone" — Node-процесс за Caddy, который раздаёт готовый HTML, умеет ставить заголовки и редиректы и не умеет принимать данные.
Разница между двумя формулировками не педантизм. «Функций нет» — свойство конфига, и оно ломается в первый же день, когда понадобится отдать что-нибудь динамически. «Принимающий эндпоинт ровно один, и он в РФ» — свойство, которое видно в CSP и в CORS, переживает рефакторинг и проверяется снаружи. Запрет имеет смысл ровно настолько, насколько он проверяем: эндпоинт, который умеет принять данные, однажды их примет, и обычно от того, кто добавит форму через год и не станет читать этот комментарий.
Второе следствие — про proxy. В proxy.ts нет ни редиректа по geo-IP, ни доступа к телу запроса, ни логирования; он делает одну вещь — запоминает язык, выбранный переключателем. Geo-IP убрали не ради экономии на вычислениях: определять язык по адресу означает выводить признак посетителя из его IP для цели, на которую он не соглашался. Язык здесь — явное действие человека, а по умолчанию русский.
Почему форма не может отправлять на свой же домен
Самый естественный код — route handler на том же домене, который принимает JSON и пересылает дальше. Это ровно то, чего делать нельзя. Тело запроса материализуется в процессе на чужой территории; платформа его видит, а её логи и трассировки живут по своим правилам и своей географии. То, что данные «пролетели насквозь», не помогает: запись произошла.
Поэтому форма постит cross-origin напрямую в приёмник в РФ. Граница видна в двух файлах и больше нигде: NEXT_PUBLIC_API_URL в .env.example и connect-src в CSP в next.config.ts. Если однажды появится третий адрес, его заблокирует браузер, а не ревьюер.
Цена решения честная: cross-origin — это CORS с preflight и невозможность поставить httpOnly-cookie на домен сайта. Взамен во фронтенде — где бы он ни стоял — не остаётся ни одной точки, где персональные данные могли бы оказаться. Обмен того стоит: CORS отлаживается один раз, а «где-то в логах платформы лежат заявки» не отлаживается никогда.
Отдельный случай — когда фронтенд и приёмник стоят на одной машине в России, как у этого сайта. Соблазн очевиден: раз обе стороны в РФ, границу можно и не проводить, форма прекрасно постит на свой же домен. Проводить всё равно стоит. Юрисдикция — свойство размещения, а не архитектуры: сайт уезжает на чужой CDN за один вечер, и вместе с ним уезжает route handler, который к тому моменту уже год как принимает персональные данные. Граница, проведённая заранее, стоит одного preflight-запроса. Проведённая задним числом — это разбор логов за год и вопрос, на который нет хорошего ответа.
Что значит «первичная запись в российскую БД»
На практике это про порядок операций внутри приёмника:
1. валидация формы → 400, ничего не сохранено
2. проверка капчи → 403, ничего не сохранено
3. INSERT + COMMIT в РФ → 500, посетитель видит «не отправилось»
4. уведомление оператора → 200 в любом случае, отказ уходит в лог
Существенно, что шаг 4 идёт после шага 3, а не наоборот. Уведомление — письмо, сообщение, что угодно — это передача данных, у неё своё основание и своё место в политике. Уведомив первым, вы создаёте копию за пределами первичной базы, и первичной записи в РФ для неё может не оказаться вовсе. Порядок здесь не оптимизация, а сама формулировка требования: сначала запись в РФ, всё остальное — производное от неё.
Из того же следует, что валидацией дело не ограничивается. Если приёмник умеет принимать поле, которого нет в описании обработки, он собирает данные, которых по документам не собирает. Схема входа должна быть закрытым списком, а не «объектом с известными полями плюс что придёт».
Почему капча может быть не опциональной
Аргумент не про спам. Открытый POST-endpoint копит мусор, и в этом мусоре рано или поздно окажутся настоящие чужие адреса: скраперы собирают их и вставляют куда попало. С точки зрения закона это персональные данные, которые вы храните и обязаны уметь удалить — при том что субъект к вам никогда не обращался и о вас не слышал. Капча в этой логике не защита формы, а минимизация того, что вы вообще принимаете.
Дальше — какая капча. reCAPTCHA отправляет IP посетителя в Google, то есть воспроизводит ровно тот дефект, из-за которого из проекта убрали Google Analytics. Остаётся Yandex SmartCaptcha: клиентский ключ публичный и лежит в .env.example, серверный не покидает VPS. Поведение по умолчанию — fail closed: если ключ не задан, форма отрисуется, а приёмник отправку отклонит. Обратный вариант («ключа нет — пропускаем») выглядит дружелюбнее к разработчику и означает открытый endpoint в проде.
Почему {ok:true} после провала — это ложь
Формы часто отвечают успехом, потому что «посетителю всё равно». Здесь не всё равно: человек нажал кнопку под текстом о согласии на обработку. Если записи не случилось, честный ответ — «не отправилось».
Но провалы бывают двух разных видов, и различать их обязательно. Не прошёл INSERT — данных нет, отвечаем 5xx, форма предлагает повторить. Прошёл INSERT, не ушло уведомление — с точки зрения посетителя всё сделано, запись есть, и просить нажать ещё раз значит создать вторую. Отвечаем 200 и роняем ошибку в лог оператора. Одна ветка кода на оба случая даёт либо потерянную заявку, либо дубль в базе; выбор между этими двумя — не то, что стоит делать случайно.
Удаляемость как критерий архитектуры
Право на удаление проверяет архитектуру лучше любого чеклиста. Заявка в Postgres удаляется одним запросом. Та же заявка, разошедшаяся по трём почтовым ящикам, каналу в мессенджере и чьей-то выгрузке в таблицу, не удаляется никогда — вы физически не знаете, где она. Отсюда правило, которое выглядит аскезой, но является требованием: приёмников должно быть мало, и каждый должен быть перечислен. Каждый новый канал уведомлений — это минус к вашей способности исполнить обязанность, которая у вас уже есть.
Аналитика
Google Analytics в проекте нет и не будет: тег отправляет идентификатор посетителя за пределы РФ. Yandex Metrica допустима, но только после явного согласия — счётчик грузится после клика, а не до него. Баннер, который ставит скрипт и потом спрашивает разрешение, согласия не получает; он его имитирует. В CSP это видно прямо: mc.yandex.ru в script-src присутствует, но сам скрипт появляется в документе только после решения посетителя.
Чего архитектура не решает
Она не заменяет уведомление в Роскомнадзор, не заменяет договор с российским хостером и не пишет политику обработки. Она делает другое: сводит вопрос «где живут персональные данные» к одной строке в .env.example, на которую можно показать пальцем. Это ровно тот случай, когда правильно поставленная граница стоит дешевле, чем последующий разбор, кто и что видел.