Разбираю несколько инженерных решений из небольшого браузерного проекта: бинго на шахматных задачах Lichess. Механика такая: карточка 4×4 с тактическими мотивами (вилка, связка, мат в два), игроку по очереди показывают позиции, он решает задачу и закрывает подходящую клетку. Ограничения, с которыми я сел за проект: своего сервера нет, только Supabase; играть можно без регистрации; у всех игроков в один день одна и та же карточка.
Карточка дня без сервера
Чтобы у всех была одна карточка, не нужен эндпоинт «выдай карточку дня»: достаточно детерминированного генератора. Сид строится из даты и уровня сложности (dailySeed(date, level)), из сида создаётся генератор псевдослучайных чисел (rngFrom(seed)), и generateCard(pool, categories, options, rng) собирает клетки и колоду. Один и тот же сид и один и тот же пул дают одну и ту же карточку на любом устройстве.
Слабое место такой схемы — пул данных. Стоит убрать из него одну запись или пересобрать данные, и тот же сид даст другую карточку: у уже сыгранных партий и у дуэлей, которые друг другу отправили по ссылке, «поплывёт» содержимое. Поэтому в решении три правила.
Первое: сыгранная партия хранит собственный состав. В таблицу games пишутся seed, расставленные клетки (placed, jsonb) и ошибки (misses, jsonb), и при просмотре старой партии карточка восстанавливается из сохранённого состава, а не пересчитывается заново. Второе: записи из пула не удаляются. Неудачный шахматист или партия получают флаг retired: в новые карточки они не попадают, но по выданному id их по‑прежнему находят старые карточки и дуэли. Третье: выданные id при пересборке данных не меняются. Всё это проверяет тест: он собирает 30 карточек с разными сидами и убеждается, что в колоду и в ответы не попал ни один retired‑элемент, а сохранённая карточка остаётся прежней.
Одна партия в день — на уровне базы
Правило «карточку дня и дуэль играют один раз» нельзя оставлять на клиенте: его обойдёт любой, кто откроет консоль. Оно записано в схему частичными уникальными индексами: create unique index games_one_daily on public.games (user_id, level, card_date) where kind = 'daily'; и create unique index games_one_per_duel on public.games (user_id, duel_id) where kind = 'duel'. Ограничение check ((kind = 'daily') = (card_date is not null)) не даёт записать дневную партию без даты или обычную — с датой. Вторая партия за день просто не вставится.
Отдельная тонкость — какая «сегодня» дата. У каждого игрока своя полночь, поэтому серверное время для серий и статистики не подходит: в RPC‑функции статистики локальная дата игрока приходит параметром p_today.
RLS: политики недостаточно
На таблице games включён RLS и есть только две политики: читать свои партии и добавлять свои (user_id = auth.uid()). Политик на update и delete нет намеренно: результат нельзя переписать задним числом. Казалось, этого достаточно, но при перепроверке нашлась дыра, не связанная с политиками. Supabase по умолчанию выдаёт ролям anon и authenticated все права на таблицы схемы public, в том числе TRUNCATE, а TRUNCATE не подчиняется RLS. То есть любой вошедший игрок, пусть и анонимный гость, мог одной командой очистить games, profiles, duels и лиги.
Лечится не политиками, а правами: revoke truncate, references, trigger on all tables in schema public from anon, authenticated; плюс revoke update, delete на games и аналогичные отзывы там, где клиент не должен писать. Заодно нашлось лишнее право EXECUTE на триггерной функции handle_new_user (она создаёт профиль при появлении пользователя). Напрямую её вызвать нельзя, но security definer‑функции клиентам не нужны, и право тоже отозвано. Вывод: для каждой новой таблицы стоит проверять права ролей, а не только наличие политик RLS. Проверки прогоняются скриптом на живом проекте: гость входит, читает только свои партии, запись от чужого имени и вторая карточка за день отклоняются.
Гость без регистрации и защита от ботов
Первая партия идёт от анонимного Supabase‑аккаунта, позже его можно превратить в обычный (email, пароль, ник) без потери истории. Открытый анонимный вход — приглашение для ботов, поэтому в Supabase включена CAPTCHA (Cloudflare Turnstile). Сначала виджет работал в режиме Managed и показывал галочку перед первой партией, потом его перевели в Invisible. У этого режима есть условие от Cloudflare: в политике конфиденциальности должна быть ссылка на Turnstile Privacy Addendum. Побочный эффект, о котором стоит знать заранее: с включённой CAPTCHA гостевой вход не проходит на localhost, пока хост не добавлен в список виджета.
Итоги
Детерминированный сид вместо серверной выдачи, уникальные индексы вместо проверок на клиенте, права ролей вместе с RLS и флаг retired вместо удаления данных — четыре решения, которые позволили обойтись без собственного сервера. Стек: React, Vite, TypeScript, Tailwind, chessground (поэтому код открыт под GPL-3.0), Supabase, Cloudflare Pages. Данные — открытая база задач Lichess (CC0) и Wikidata. Живой пример: chess‑bingo.pages.dev

