וואלהפריצה לבית קפה בתל אביב - במהלך יום הכיפוריםThe Jerusalem PostWhat will Israel bring to its most important alliance? - opinionPunchTrump TV goes live as major US networks halt coverageInquirer EntertainmentTaylor Swift will become the most awarded artist in MTV VMAs historyInquirerWoman dies after refusing to leave burning house in QuezonCNN TürkİSTANBULKART 1 TL ÖĞRENCİ ABONMAN BAŞVURUSU 2026| İBB aylık 1 TL öğrenci abonmanı başvurusu nasıl yapılır, kimler başvurabilir?Bollywood HungamaEXCLUSIVE: Ajay Devgn-Rohit Jugraj’s horror thriller titled Surya PrakashSözcüHedef Yatırım'dan yeni açıklamaUOLMercado de carros clássicos muda com novos interesses de colecionadores mais jovensObservador DesportoFragatas. Nuno Melo ouvido no parlamento sobre fragatasהידעןמפת עולם חדשה לחלבונים: בינה מלאכותית מחברת בין רצף, מבנה ומיליארדי שנות אבולוציהn-tvBasketball droht Spaltung: Gierig und planlos: NBA versetzt Europa in Aufruhr
The Daily Newsstand · Free, Always
Tuesday, September 22, 2026

Одна карточка на всех без сервера: детерминированная генерация, RLS и флаг retired в шахматном бинго

Translate

Разбираю несколько инженерных решений из небольшого браузерного проекта: бинго на шахматных задачах 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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.