Разбираю несколько инженерных решений из небольшого браузерного проекта: бинго на шахматных задачах Lichess без собственного сервера. Как получить одну и ту же карточку у всех игроков в один день с помощью детерминированного сида, почему запись из пула данных нельзя удалять (флаг retired), как записать правило «одна партия в день» частичными уникальными индексами и почему политик RLS в Supabase недостаточно: по умолчанию клиентским ролям выданы права, в том числе TRUNCATE, который RLS не проверяет. Читать далее
Время на прочтение4 мин
Охват и читатели5.3K
Кейс
Разбираю несколько инженерных решений из небольшого браузерного проекта: бинго на шахматных задачах 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
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Мы написали автономного агента для VACUUM/ANALYZE и запустили на 800+ тестовых БД: что из этого вышло | 0 | 8.16 | 23-09-2026 |
| 2 | Каким по счёту ты родился | 0 | 7.52 | 18-09-2026 |
| 3 | Как уронить базу данных | 0 | 6.72 | 17-08-2026 |
| 4 | Ежедневный Хабр: 9 интересных публикаций каждый день | 0 | 12.21 | 06-08-2026 |
| 5 | Четверо в одной транзакции: кейс-батлы, PgBouncer под Prisma и реплика, которая показывает пустой инвентарь | 0 | 10.65 | 20-09-2026 |
| 6 | Как я пытался сделать стратегию из котов в стиральных машинах — и почему правила оказалось сложнее написать, чем код | 0 | 7.18 | 11-08-2026 |
| 7 | Собрать прошлое: как архивировать весь трафик сборки SONiC | 0 | 8.94 | 20-07-2026 |
| 8 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 7 | 8 | 27-06-2026 |
| 9 | Как я пишу RTS про Марс на Rust вместе с ИИ-агентами: архитектура, ошибки и 400+ тестов | 0 | 11.26 | 08-08-2026 |
| 10 | Алгоритм был правильным. Ошибка была в контракте графа | -1 | 7.57 | 13-08-2026 |