From 1b2127a02f2ea42c2e1a2417ca7bf29f4a60259d Mon Sep 17 00:00:00 2001
From: Amayyas
Date: Wed, 22 Jul 2026 22:29:48 +0200
Subject: [PATCH 1/7] feat(backend): Supabase schema with row level security
(M10)
The data model of section 2.6 - profiles, scores, puzzle_progress and
achievements - as a migration, with RLS enabled on every table and no
permissive default. Section 06 rates a misconfigured RLS as a high impact
risk, so each table states exactly who may read and who may write.
- Profiles and scores are world-readable: the leaderboard has to show who
holds a score, and guests are allowed to browse it.
- Writes are always pinned to auth.uid(), so a score can only ever be
filed under its own author.
- Scores get no update or delete policy: a submitted score is final.
- Puzzle progress is private to its owner.
- A trigger creates the profile on sign-up, so an account can never exist
without one, and it falls back to a suffixed username rather than
failing the signup on a collision.
- scores joins the realtime publication for the live leaderboard.
Verified against a real local Postgres: signing up creates the profile,
and every attempt to write in someone else's name is refused (403), as is
deleting a submitted score, while a guest can still read the board.
The grants matter as much as the policies: RLS filters rows, a GRANT
opens the table. Without them every request failed with 42501 however
correct the policies were - which is exactly what the first run against a
real database showed.
---
.env.example | 5 +
.gitignore | 4 +
.prettierignore | 4 +
supabase/.gitignore | 8 +
supabase/config.toml | 414 ++++++++++++++++++++
supabase/migrations/20260711000000_init.sql | 185 +++++++++
6 files changed, 620 insertions(+)
create mode 100644 .env.example
create mode 100644 supabase/.gitignore
create mode 100644 supabase/config.toml
create mode 100644 supabase/migrations/20260711000000_init.sql
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..b91fb14
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# Supabase (optional). Without these the app runs entirely as a guest:
+# every mode stays playable, only the worldwide leaderboard needs an account.
+# Create a project on https://supabase.com, then copy Settings > API.
+VITE_SUPABASE_URL=https://xxxxxxxxxxxxxxxx.supabase.co
+VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
diff --git a/.gitignore b/.gitignore
index 330445b..e90ad7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,7 @@ coverage
# Private specification — kept locally, never committed
cahier-des-charges.html
+
+# Supabase CLI local artefacts
+supabase/.temp
+supabase/.branches
diff --git a/.prettierignore b/.prettierignore
index f63a484..aa673f2 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -6,3 +6,7 @@ cahier-des-charges.html
# Vendored Stockfish build — never reformat
public/stockfish
+
+# Supabase CLI local artefacts
+supabase/.temp
+supabase/.branches
diff --git a/supabase/.gitignore b/supabase/.gitignore
new file mode 100644
index 0000000..ad9264f
--- /dev/null
+++ b/supabase/.gitignore
@@ -0,0 +1,8 @@
+# Supabase
+.branches
+.temp
+
+# dotenvx
+.env.keys
+.env.local
+.env.*.local
diff --git a/supabase/config.toml b/supabase/config.toml
new file mode 100644
index 0000000..60e3436
--- /dev/null
+++ b/supabase/config.toml
@@ -0,0 +1,414 @@
+# For detailed configuration reference documentation, visit:
+# https://supabase.com/docs/guides/local-development/cli/config
+# A string used to distinguish different Supabase projects on the same host. Defaults to the
+# working directory name when running `supabase init`.
+project_id = "ChessTrainer"
+
+[api]
+enabled = true
+# Port to use for the API URL.
+port = 54321
+# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
+# endpoints. `public` and `graphql_public` schemas are included by default.
+schemas = ["public", "graphql_public"]
+# Extra schemas to add to the search_path of every request.
+extra_search_path = ["public", "extensions"]
+# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
+# for accidental or malicious requests.
+max_rows = 1000
+# Controls whether new tables, views, sequences and functions created in the `public` schema by
+# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
+# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud
+# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is
+# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent.
+# auto_expose_new_tables = true
+
+[api.tls]
+# Enable HTTPS endpoints locally using a self-signed certificate.
+enabled = false
+# Paths to self-signed certificate pair.
+# cert_path = "../certs/my-cert.pem"
+# key_path = "../certs/my-key.pem"
+
+[db]
+# Port to use for the local database URL.
+port = 54322
+# Port used by db diff command to initialize the shadow database.
+shadow_port = 54320
+# Maximum amount of time to wait for health check when starting the local database.
+health_timeout = "2m"
+# The database major version to use. This has to be the same as your remote database's. Run `SHOW
+# server_version;` on the remote database to check.
+major_version = 17
+
+[db.pooler]
+enabled = false
+# Port to use for the local connection pooler.
+port = 54329
+# Specifies when a server connection can be reused by other clients.
+# Configure one of the supported pooler modes: `transaction`, `session`.
+pool_mode = "transaction"
+# How many server connections to allow per user/database pair.
+default_pool_size = 20
+# Maximum number of client connections allowed.
+max_client_conn = 100
+
+# [db.vault]
+# secret_key = "env(SECRET_VALUE)"
+
+[db.migrations]
+# If disabled, migrations will be skipped during a db push or reset.
+enabled = true
+# Specifies an ordered list of schema files, directories, or glob patterns that describe your database.
+# Supports paths relative to supabase directory: "./schemas/*.sql", "./database".
+schema_paths = []
+
+[db.seed]
+# If enabled, seeds the database after migrations during a db reset.
+enabled = true
+# Specifies an ordered list of seed files to load during db reset.
+# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
+sql_paths = ["./seed.sql"]
+
+[db.network_restrictions]
+# Enable management of network restrictions.
+enabled = false
+# List of IPv4 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
+allowed_cidrs = ["0.0.0.0/0"]
+# List of IPv6 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
+allowed_cidrs_v6 = ["::/0"]
+
+# Uncomment to reject non-secure connections to the database.
+# [db.ssl_enforcement]
+# enabled = true
+
+[realtime]
+enabled = true
+# Bind realtime via either IPv4 or IPv6. (default: IPv4)
+# ip_version = "IPv6"
+# The maximum length in bytes of HTTP request headers. (default: 4096)
+# max_header_length = 4096
+
+[studio]
+enabled = true
+# Port to use for Supabase Studio.
+port = 54323
+# External URL of the API server that frontend connects to.
+api_url = "http://127.0.0.1"
+# OpenAI API Key to use for Supabase AI in the Supabase Studio.
+openai_api_key = "env(OPENAI_API_KEY)"
+
+# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
+# are monitored, and you can view the emails that would have been sent from the web interface.
+[local_smtp]
+enabled = true
+# Port to use for the email testing server web interface.
+port = 54324
+# Uncomment to expose additional ports for testing user applications that send emails.
+# smtp_port = 54325
+# pop3_port = 54326
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+[storage]
+enabled = true
+# The maximum file size allowed (e.g. "5MB", "500KB").
+file_size_limit = "50MiB"
+
+# Uncomment to configure local storage buckets
+# [storage.buckets.images]
+# public = false
+# file_size_limit = "50MiB"
+# allowed_mime_types = ["image/png", "image/jpeg"]
+# objects_path = "./images"
+
+# Allow connections via S3 compatible clients
+[storage.s3_protocol]
+enabled = true
+
+# Image transformation API is available to Supabase Pro plan.
+# [storage.image_transformation]
+# enabled = true
+
+# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
+# This feature is only available on the hosted platform.
+[storage.analytics]
+enabled = false
+max_namespaces = 5
+max_tables = 10
+max_catalogs = 2
+
+# Analytics Buckets is available to Supabase Pro plan.
+# [storage.analytics.buckets.my-warehouse]
+
+# Store vector embeddings in S3 for large and durable datasets
+[storage.vector]
+enabled = true
+max_buckets = 10
+max_indexes = 5
+
+# Vector Buckets is available to Supabase Pro plan.
+# [storage.vector.buckets.documents-openai]
+
+[auth]
+enabled = true
+# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
+# in emails.
+site_url = "http://127.0.0.1:3000"
+# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
+# external_url = ""
+# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
+additional_redirect_urls = ["https://127.0.0.1:3000"]
+# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
+jwt_expiry = 3600
+# JWT issuer URL. If not set, defaults to auth.external_url.
+# jwt_issuer = ""
+# Path to JWT signing key. DO NOT commit your signing keys file to git.
+# signing_keys_path = "./signing_keys.json"
+# If disabled, the refresh token will never expire.
+enable_refresh_token_rotation = true
+# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
+# Requires enable_refresh_token_rotation = true.
+refresh_token_reuse_interval = 10
+# Allow/disallow new user signups to your project.
+enable_signup = true
+# Allow/disallow anonymous sign-ins to your project.
+enable_anonymous_sign_ins = false
+# Allow/disallow testing manual linking of accounts
+enable_manual_linking = false
+# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
+minimum_password_length = 6
+# Passwords that do not meet the following requirements will be rejected as weak. Supported values
+# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
+password_requirements = ""
+
+# Configure passkey sign-ins.
+# [auth.passkey]
+# enabled = false
+
+# Configure WebAuthn relying party settings (required when passkey is enabled).
+# [auth.webauthn]
+# rp_display_name = "Supabase"
+# rp_id = "localhost"
+# rp_origins = ["http://127.0.0.1:3000"]
+
+[auth.rate_limit]
+# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
+email_sent = 2
+# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
+sms_sent = 30
+# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
+anonymous_users = 30
+# Number of sessions that can be refreshed in a 5 minute interval per IP address.
+token_refresh = 150
+# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
+sign_in_sign_ups = 30
+# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
+token_verifications = 30
+# Number of Web3 logins that can be made in a 5 minute interval per IP address.
+web3 = 30
+
+# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
+# [auth.captcha]
+# enabled = true
+# provider = "hcaptcha"
+# secret = ""
+
+[auth.email]
+# Allow/disallow new user signups via email to your project.
+enable_signup = true
+# If enabled, a user will be required to confirm any email change on both the old, and new email
+# addresses. If disabled, only the new email is required to confirm.
+double_confirm_changes = true
+# If enabled, users need to confirm their email address before signing in.
+enable_confirmations = false
+# If enabled, users will need to reauthenticate or have logged in recently to change their password.
+secure_password_change = false
+# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
+max_frequency = "1s"
+# Number of characters used in the email OTP.
+otp_length = 6
+# Number of seconds before the email OTP expires (defaults to 1 hour).
+otp_expiry = 3600
+
+# Use a production-ready SMTP server
+# [auth.email.smtp]
+# enabled = true
+# host = "smtp.sendgrid.net"
+# port = 587
+# user = "apikey"
+# pass = "env(SENDGRID_API_KEY)"
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+# Uncomment to customize email template
+# [auth.email.template.invite]
+# subject = "You have been invited"
+# content_path = "./supabase/templates/invite.html"
+
+# Uncomment to customize notification email template
+# [auth.email.notification.password_changed]
+# enabled = true
+# subject = "Your password has been changed"
+# content_path = "./templates/password_changed_notification.html"
+
+[auth.sms]
+# Allow/disallow new user signups via SMS to your project.
+enable_signup = false
+# If enabled, users need to confirm their phone number before signing in.
+enable_confirmations = false
+# Template for sending OTP to users
+template = "Your code is {{ `{{ .Code }}` }}"
+# Controls the minimum amount of time that must pass before sending another sms otp.
+max_frequency = "5s"
+
+# Use pre-defined map of phone number to OTP for testing.
+# [auth.sms.test_otp]
+# 4152127777 = "123456"
+
+# Configure logged in session timeouts.
+# [auth.sessions]
+# Force log out after the specified duration.
+# timebox = "24h"
+# Force log out if the user has been inactive longer than the specified duration.
+# inactivity_timeout = "8h"
+
+# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
+# [auth.hook.before_user_created]
+# enabled = true
+# uri = "pg-functions://postgres/auth/before-user-created-hook"
+
+# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
+# [auth.hook.custom_access_token]
+# enabled = true
+# uri = "pg-functions:////"
+
+# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
+[auth.sms.twilio]
+enabled = false
+account_sid = ""
+message_service_sid = ""
+# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
+auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
+
+# Multi-factor-authentication is available to Supabase Pro plan.
+[auth.mfa]
+# Control how many MFA factors can be enrolled at once per user.
+max_enrolled_factors = 10
+
+# Control MFA via App Authenticator (TOTP)
+[auth.mfa.totp]
+enroll_enabled = false
+verify_enabled = false
+
+# Configure MFA via Phone Messaging
+[auth.mfa.phone]
+enroll_enabled = false
+verify_enabled = false
+otp_length = 6
+template = "Your code is {{ `{{ .Code }}` }}"
+max_frequency = "5s"
+
+# Configure MFA via WebAuthn
+# [auth.mfa.web_authn]
+# enroll_enabled = true
+# verify_enabled = true
+
+# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
+# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
+# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
+[auth.external.apple]
+enabled = false
+client_id = ""
+# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
+secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
+# Overrides the default auth callback URL derived from auth.external_url.
+redirect_uri = ""
+# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
+# or any other third-party OIDC providers.
+url = ""
+# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
+skip_nonce_check = false
+# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
+email_optional = false
+
+# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
+# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
+[auth.web3.solana]
+enabled = false
+
+# Use Firebase Auth as a third-party provider alongside Supabase Auth.
+[auth.third_party.firebase]
+enabled = false
+# project_id = "my-firebase-project"
+
+# Use Auth0 as a third-party provider alongside Supabase Auth.
+[auth.third_party.auth0]
+enabled = false
+# tenant = "my-auth0-tenant"
+# tenant_region = "us"
+
+# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
+[auth.third_party.aws_cognito]
+enabled = false
+# user_pool_id = "my-user-pool-id"
+# user_pool_region = "us-east-1"
+
+# Use Clerk as a third-party provider alongside Supabase Auth.
+[auth.third_party.clerk]
+enabled = false
+# Obtain from https://clerk.com/setup/supabase
+# domain = "example.clerk.accounts.dev"
+
+# OAuth server configuration
+[auth.oauth_server]
+# Enable OAuth server functionality
+enabled = false
+# Path for OAuth consent flow UI
+authorization_url_path = "/oauth/consent"
+# Allow dynamic client registration
+allow_dynamic_registration = false
+
+[edge_runtime]
+enabled = true
+# Supported request policies: `oneshot`, `per_worker`.
+# `per_worker` (default) — enables hot reload during local development.
+# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
+policy = "per_worker"
+# Port to attach the Chrome inspector for debugging edge functions.
+inspector_port = 8083
+# The Deno major version to use.
+deno_version = 2
+
+# [edge_runtime.secrets]
+# secret_key = "env(SECRET_VALUE)"
+
+[analytics]
+enabled = true
+port = 54327
+# Configure one of the supported backends: `postgres`, `bigquery`.
+backend = "postgres"
+
+# Experimental features may be deprecated any time
+[experimental]
+# Configures Postgres storage engine to use OrioleDB (S3)
+orioledb_version = ""
+# Configures S3 bucket URL, eg. .s3-.amazonaws.com
+s3_host = "env(S3_HOST)"
+# Configures S3 bucket region, eg. us-east-1
+s3_region = "env(S3_REGION)"
+# Configures AWS_ACCESS_KEY_ID for S3 bucket
+s3_access_key = "env(S3_ACCESS_KEY)"
+# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
+s3_secret_key = "env(S3_SECRET_KEY)"
+
+# pg-delta is the schema diff engine for db diff / db pull / db remote commit.
+# Set enabled = false to fall back to the legacy migra engine.
+[experimental.pgdelta]
+enabled = true
+# Directory under `supabase/` where declarative files are written.
+# declarative_schema_path = "./database"
+# JSON string passed through to pg-delta SQL formatting.
+# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
diff --git a/supabase/migrations/20260711000000_init.sql b/supabase/migrations/20260711000000_init.sql
new file mode 100644
index 0000000..0777fe0
--- /dev/null
+++ b/supabase/migrations/20260711000000_init.sql
@@ -0,0 +1,185 @@
+-- ChessTrainer — data model of specification section 2.6.
+--
+-- Row Level Security is enabled on every table and no policy is permissive by
+-- default: section 06 of the specification rates a misconfigured RLS as a high
+-- impact risk, so each table states exactly who may read and who may write.
+
+-- ---------------------------------------------------------------- profiles --
+-- One row per account, created automatically on sign-up by the trigger below.
+create table if not exists public.profiles (
+ id uuid primary key references auth.users on delete cascade,
+ username text not null check (char_length(trim(username)) between 3 and 24),
+ avatar_piece text not null default 'n' check (avatar_piece in ('k','q','r','b','n','p')),
+ xp integer not null default 0 check (xp >= 0),
+ level integer not null default 1 check (level between 1 and 30),
+ created_at timestamptz not null default now()
+);
+
+create unique index if not exists profiles_username_key on public.profiles (lower(username));
+
+alter table public.profiles enable row level security;
+
+-- Profiles are public: the leaderboard has to show who holds a score.
+drop policy if exists "profiles are readable by everyone" on public.profiles;
+create policy "profiles are readable by everyone"
+ on public.profiles for select
+ using (true);
+
+drop policy if exists "a user creates only their own profile" on public.profiles;
+create policy "a user creates only their own profile"
+ on public.profiles for insert
+ to authenticated
+ with check (auth.uid() = id);
+
+drop policy if exists "a user edits only their own profile" on public.profiles;
+create policy "a user edits only their own profile"
+ on public.profiles for update
+ to authenticated
+ using (auth.uid() = id)
+ with check (auth.uid() = id);
+
+-- ------------------------------------------------------------------ scores --
+-- One row per finished Piece Hunt round.
+create table if not exists public.scores (
+ id bigint generated always as identity primary key,
+ user_id uuid not null references public.profiles (id) on delete cascade,
+ piece text not null check (piece in ('q','r','b','n','k')),
+ score integer not null check (score >= 0),
+ captures integer not null default 0 check (captures >= 0),
+ played_at timestamptz not null default now()
+);
+
+-- The leaderboard reads "best scores for a piece", and filters by period.
+create index if not exists scores_piece_score_idx on public.scores (piece, score desc);
+create index if not exists scores_played_at_idx on public.scores (played_at desc);
+
+alter table public.scores enable row level security;
+
+-- The leaderboard is worldwide, so reading is open even to guests.
+drop policy if exists "scores are readable by everyone" on public.scores;
+create policy "scores are readable by everyone"
+ on public.scores for select
+ using (true);
+
+-- A score can only ever be filed under its own author.
+drop policy if exists "a user submits only their own scores" on public.scores;
+create policy "a user submits only their own scores"
+ on public.scores for insert
+ to authenticated
+ with check (auth.uid() = user_id);
+
+-- Deliberately no update or delete policy: a submitted score is final.
+
+-- ---------------------------------------------------------- puzzle_progress --
+create table if not exists public.puzzle_progress (
+ user_id uuid not null references public.profiles (id) on delete cascade,
+ puzzle_id text not null,
+ solved boolean not null default false,
+ attempts integer not null default 0 check (attempts >= 0),
+ solved_at timestamptz,
+ primary key (user_id, puzzle_id)
+);
+
+alter table public.puzzle_progress enable row level security;
+
+-- Private to its owner: nobody needs to see someone else's puzzle history.
+drop policy if exists "puzzle progress is private" on public.puzzle_progress;
+create policy "puzzle progress is private"
+ on public.puzzle_progress for select
+ to authenticated
+ using (auth.uid() = user_id);
+
+drop policy if exists "a user writes only their own puzzle progress" on public.puzzle_progress;
+create policy "a user writes only their own puzzle progress"
+ on public.puzzle_progress for insert
+ to authenticated
+ with check (auth.uid() = user_id);
+
+drop policy if exists "a user updates only their own puzzle progress" on public.puzzle_progress;
+create policy "a user updates only their own puzzle progress"
+ on public.puzzle_progress for update
+ to authenticated
+ using (auth.uid() = user_id)
+ with check (auth.uid() = user_id);
+
+-- ------------------------------------------------------------ achievements --
+create table if not exists public.achievements (
+ user_id uuid not null references public.profiles (id) on delete cascade,
+ badge_id text not null,
+ unlocked_at timestamptz not null default now(),
+ primary key (user_id, badge_id)
+);
+
+alter table public.achievements enable row level security;
+
+-- Badges are public so a profile can be shown off.
+drop policy if exists "achievements are readable by everyone" on public.achievements;
+create policy "achievements are readable by everyone"
+ on public.achievements for select
+ using (true);
+
+drop policy if exists "a user unlocks only their own badges" on public.achievements;
+create policy "a user unlocks only their own badges"
+ on public.achievements for insert
+ to authenticated
+ with check (auth.uid() = user_id);
+
+-- ----------------------------------------------------------- privileges ----
+-- RLS filters *rows*; a GRANT opens the *table*. Both are needed, and relying
+-- on Supabase's default privileges is not portable — without these grants every
+-- request fails with "permission denied" (42501) however correct the policies.
+-- Each role gets only what its policies allow, and nothing more.
+grant usage on schema public to anon, authenticated;
+
+-- Readable by everyone, including guests browsing the leaderboard.
+grant select on public.profiles, public.scores, public.achievements to anon, authenticated;
+
+grant insert, update on public.profiles to authenticated;
+-- Insert only: a submitted score is final, so no update or delete is granted.
+grant insert on public.scores to authenticated;
+grant insert on public.achievements to authenticated;
+-- Private to its owner, so guests get nothing at all.
+grant select, insert, update on public.puzzle_progress to authenticated;
+
+grant usage, select on all sequences in schema public to authenticated;
+
+-- ------------------------------------------------------- profile on signup --
+-- Creating the profile from the client would need a second round trip that can
+-- fail after the account exists, leaving an account with no profile.
+create or replace function public.handle_new_user()
+returns trigger
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ requested text := coalesce(new.raw_user_meta_data ->> 'username', split_part(new.email, '@', 1));
+ candidate text := left(regexp_replace(requested, '[^A-Za-z0-9_-]', '', 'g'), 24);
+begin
+ if char_length(candidate) < 3 then
+ candidate := 'joueur' || left(replace(new.id::text, '-', ''), 6);
+ end if;
+
+ -- Usernames are unique; fall back to a suffixed one rather than fail signup.
+ if exists (select 1 from public.profiles where lower(username) = lower(candidate)) then
+ candidate := left(candidate, 17) || left(replace(new.id::text, '-', ''), 6);
+ end if;
+
+ insert into public.profiles (id, username, avatar_piece)
+ values (
+ new.id,
+ candidate,
+ coalesce(new.raw_user_meta_data ->> 'avatar_piece', 'n')
+ );
+ return new;
+end;
+$$;
+
+drop trigger if exists on_auth_user_created on auth.users;
+create trigger on_auth_user_created
+ after insert on auth.users
+ for each row execute function public.handle_new_user();
+
+-- --------------------------------------------------------------- realtime --
+-- The leaderboard subscribes to score inserts (specification section 2.6).
+alter publication supabase_realtime add table public.scores;
From 56c6b5da2ef52e58a94e38c3df7179c21aa373ba Mon Sep 17 00:00:00 2001
From: Amayyas
Date: Wed, 22 Jul 2026 22:29:48 +0200
Subject: [PATCH 2/7] feat(auth): accounts, route guard and live worldwide
leaderboard (M10)
Section 2.6, end to end.
- Supabase client that degrades to nothing when unconfigured, so the app
stays fully playable as a guest and the build needs no secrets.
- useAuthStore: email sign-up and sign-in, Google OAuth, sign-out, the
session restored on load with its JWT refreshing itself, and Supabase's
technical errors translated for the player.
- Sign-in and sign-up screens, and a RequireAuth guard on the leaderboard
only - every game mode stays open to guests. The guard waits for the
stored session before deciding, or a signed-in player would be bounced
to the login screen on every refresh.
- Leaderboard: top ten per piece or overall, filtered by day, week or all
time, refreshed live through Supabase Realtime, with the signed-in
player highlighted. Only each player's best round is listed, so one
person cannot fill the table.
- Hunt submits a score automatically when signed in, and invites an
anonymous player who posts one to create an account.
- Profile gains the account: pseudonym, avatar among the six piece
symbols, sign-up date and sign-out.
Verified in a real browser against a local Supabase: a guest visiting the
leaderboard lands on the login screen, signing up creates the account and
profile and shows its date, and a score inserted from outside the browser
appears at the top of the board without a reload.
Tests run with the backend deliberately unconfigured, which is what CI
sees and keeps them off the network.
The initial JavaScript budget moves from 200 to 230 kB: the Supabase
client is a hard requirement of this module. M9 should bring it back down
with the route-level lazy loading the specification assigns to it.
---
package-lock.json | 111 +++++++++++++
package.json | 1 +
scripts/check-bundle-size.mjs | 6 +-
src/App.test.tsx | 4 +-
src/App.tsx | 21 ++-
src/components/Layout/navigation.test.ts | 10 +-
src/features/auth/AuthLayout.tsx | 68 ++++++++
src/features/auth/LoginPage.tsx | 69 ++++++++
src/features/auth/RegisterPage.tsx | 81 +++++++++
src/features/auth/RequireAuth.tsx | 44 +++++
src/features/hunt/HuntPage.tsx | 36 +++-
src/features/leaderboard/LeaderboardPage.tsx | 154 +++++++++++++++++-
.../leaderboard/useLeaderboard.test.ts | 55 +++++++
src/features/leaderboard/useLeaderboard.ts | 126 ++++++++++++++
src/features/profile/ProfilePage.tsx | 89 +++++++++-
src/lib/supabase.ts | 54 ++++++
src/routes.ts | 7 +-
src/store/useAuthStore.ts | 142 ++++++++++++++++
vite.config.ts | 3 +
19 files changed, 1061 insertions(+), 20 deletions(-)
create mode 100644 src/features/auth/AuthLayout.tsx
create mode 100644 src/features/auth/LoginPage.tsx
create mode 100644 src/features/auth/RegisterPage.tsx
create mode 100644 src/features/auth/RequireAuth.tsx
create mode 100644 src/features/leaderboard/useLeaderboard.test.ts
create mode 100644 src/features/leaderboard/useLeaderboard.ts
create mode 100644 src/lib/supabase.ts
create mode 100644 src/store/useAuthStore.ts
diff --git a/package-lock.json b/package-lock.json
index e53b401..8195b17 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/playfair-display": "^5.2.8",
+ "@supabase/supabase-js": "^2.110.8",
"chess.js": "^1.4.0",
"framer-motion": "^11.18.2",
"react": "^18.3.1",
@@ -1945,6 +1946,108 @@
"node": ">=6"
}
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.110.8",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz",
+ "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/auth-js/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.110.8",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz",
+ "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/@supabase/phoenix": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
+ "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw=="
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.110.8",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz",
+ "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.110.8",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz",
+ "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==",
+ "dependencies": {
+ "@supabase/phoenix": "0.4.5",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.110.8",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz",
+ "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.110.8",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz",
+ "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==",
+ "dependencies": {
+ "@supabase/auth-js": "2.110.8",
+ "@supabase/functions-js": "2.110.8",
+ "@supabase/postgrest-js": "2.110.8",
+ "@supabase/realtime-js": "2.110.8",
+ "@supabase/storage-js": "2.110.8"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4995,6 +5098,14 @@
"node": ">= 14"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
diff --git a/package.json b/package.json
index c3281d5..1c0e231 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/playfair-display": "^5.2.8",
+ "@supabase/supabase-js": "^2.110.8",
"chess.js": "^1.4.0",
"framer-motion": "^11.18.2",
"react": "^18.3.1",
diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs
index f4cd47c..c0138aa 100644
--- a/scripts/check-bundle-size.mjs
+++ b/scripts/check-bundle-size.mjs
@@ -27,7 +27,11 @@ const BUDGETS = [
{
label: 'Initial JavaScript',
match: (path) => path.endsWith('.js'),
- maxGzipKb: 200,
+ // Raised from 200 kB for M10: the Supabase client is about 65 kB gzipped
+ // and is a hard requirement of the accounts and leaderboard of section 2.6.
+ // M9 should bring this back down with route-level lazy loading, which the
+ // specification assigns to it; until then the guard still catches growth.
+ maxGzipKb: 230,
},
{
label: 'CSS',
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 705ed27..48cb88d 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -22,7 +22,9 @@ describe('routing', () => {
[ROUTES.battle, /Affrontement/],
[ROUTES.puzzle, 'Puzzles'],
[ROUTES.hunt, /Chasse aux Pièces/],
- [ROUTES.leaderboard, /Classement mondial/],
+ // Tests run with no backend configured, so the guard explains the
+ // leaderboard is unavailable rather than rendering it.
+ [ROUTES.leaderboard, /Classement indisponible/],
[ROUTES.profile, 'Profil'],
])('renders the expected page at %s', (path, heading) => {
renderAt(path)
diff --git a/src/App.tsx b/src/App.tsx
index 2fb4262..0d8ef14 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,9 @@
+import { useEffect } from 'react'
import { Route, Routes } from 'react-router-dom'
import AppLayout from '@/components/Layout/AppLayout'
+import LoginPage from '@/features/auth/LoginPage'
+import RegisterPage from '@/features/auth/RegisterPage'
+import RequireAuth from '@/features/auth/RequireAuth'
import BattlePage from '@/features/battle/BattlePage'
import CoachPage from '@/features/coach/CoachPage'
import HomePage from '@/features/home/HomePage'
@@ -9,8 +13,14 @@ import NotFoundPage from '@/features/NotFoundPage'
import ProfilePage from '@/features/profile/ProfilePage'
import PuzzlePage from '@/features/puzzle/PuzzlePage'
import { ROUTES } from '@/routes'
+import { useAuthStore } from '@/store/useAuthStore'
export default function App() {
+ const initialise = useAuthStore((state) => state.initialise)
+
+ // Restores the stored session and follows sign-in/sign-out for the whole app.
+ useEffect(() => initialise(), [initialise])
+
return (
}>
@@ -19,7 +29,16 @@ export default function App() {
} />
} />
} />
- } />
+
+
+
+ }
+ />
+ } />
+ } />
} />
} />
diff --git a/src/components/Layout/navigation.test.ts b/src/components/Layout/navigation.test.ts
index 402ebe4..296adf4 100644
--- a/src/components/Layout/navigation.test.ts
+++ b/src/components/Layout/navigation.test.ts
@@ -3,10 +3,12 @@ import { BOTTOM_BAR_ITEMS, NAV_ITEMS } from '@/components/Layout/navigation'
import { ROUTES } from '@/routes'
describe('navigation', () => {
- it('exposes one entry per SPA route', () => {
- const navPaths = NAV_ITEMS.map((item) => item.path).sort()
- const routePaths = Object.values(ROUTES).sort()
- expect(navPaths).toEqual(routePaths)
+ it('exposes one entry per navigable route', () => {
+ // The auth screens are reached from a guard or a link, never from the menu.
+ const navigable = Object.values(ROUTES).filter(
+ (route) => route !== ROUTES.login && route !== ROUTES.register,
+ )
+ expect(NAV_ITEMS.map((item) => item.path).sort()).toEqual(navigable.sort())
})
it('contains no duplicate route', () => {
diff --git a/src/features/auth/AuthLayout.tsx b/src/features/auth/AuthLayout.tsx
new file mode 100644
index 0000000..6ec078d
--- /dev/null
+++ b/src/features/auth/AuthLayout.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+import { Button, Card } from '@/components/UI'
+import { isSupabaseConfigured } from '@/lib/supabase'
+import { useAuthStore } from '@/store/useAuthStore'
+
+interface AuthLayoutProps {
+ title: string
+ subtitle: string
+ children: ReactNode
+ footer: ReactNode
+}
+
+/** Shell shared by the sign-in and sign-up screens (spec section 2.6). */
+export default function AuthLayout({ title, subtitle, children, footer }: AuthLayoutProps) {
+ const error = useAuthStore((state) => state.error)
+ const signInWithGoogle = useAuthStore((state) => state.signInWithGoogle)
+
+ if (!isSupabaseConfigured) {
+ return (
+
+
+ ♜
+
+
Comptes indisponibles
+
+ Aucun serveur n'est configuré sur cette installation. Tous les modes restent jouables en
+ invité ; seul le classement mondial demande un compte.
+
+
+ )
+}
diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx
new file mode 100644
index 0000000..c3ae14d
--- /dev/null
+++ b/src/features/auth/LoginPage.tsx
@@ -0,0 +1,69 @@
+import { useState, type FormEvent } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { Button } from '@/components/UI'
+import AuthLayout from '@/features/auth/AuthLayout'
+import { ROUTES } from '@/routes'
+import { useAuthStore } from '@/store/useAuthStore'
+
+export default function LoginPage() {
+ const signIn = useAuthStore((state) => state.signIn)
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // Send the player back where the guard intercepted them.
+ const redirectTo = (location.state as { from?: string } | null)?.from ?? ROUTES.leaderboard
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault()
+ setIsSubmitting(true)
+ const ok = await signIn({ email, password })
+ setIsSubmitting(false)
+ if (ok) navigate(redirectTo, { replace: true })
+ }
+
+ return (
+
+ Pas encore de compte ?{' '}
+
+ Créer un compte
+
+ >
+ }
+ >
+
+
+ )
+}
diff --git a/src/features/auth/RegisterPage.tsx b/src/features/auth/RegisterPage.tsx
new file mode 100644
index 0000000..01a258d
--- /dev/null
+++ b/src/features/auth/RegisterPage.tsx
@@ -0,0 +1,81 @@
+import { useState, type FormEvent } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { Button } from '@/components/UI'
+import AuthLayout from '@/features/auth/AuthLayout'
+import { ROUTES } from '@/routes'
+import { useAuthStore } from '@/store/useAuthStore'
+
+export default function RegisterPage() {
+ const signUp = useAuthStore((state) => state.signUp)
+ const navigate = useNavigate()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [username, setUsername] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault()
+ setIsSubmitting(true)
+ const ok = await signUp({ email, password, username })
+ setIsSubmitting(false)
+ if (ok) navigate(ROUTES.profile, { replace: true })
+ }
+
+ return (
+
+ Déjà inscrit ?{' '}
+
+ Se connecter
+
+ >
+ }
+ >
+
+
+ )
+}
diff --git a/src/features/auth/RequireAuth.tsx b/src/features/auth/RequireAuth.tsx
new file mode 100644
index 0000000..ff0f802
--- /dev/null
+++ b/src/features/auth/RequireAuth.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from 'react'
+import { Navigate, useLocation } from 'react-router-dom'
+import { Card, Spinner } from '@/components/UI'
+import { isSupabaseConfigured } from '@/lib/supabase'
+import { ROUTES } from '@/routes'
+import { useAuthStore } from '@/store/useAuthStore'
+
+/**
+ * Route guard for the pages that need an account (spec section 2.6). Only the
+ * leaderboard is behind it: every game mode stays open to guests.
+ */
+export default function RequireAuth({ children }: { children: ReactNode }) {
+ const isReady = useAuthStore((state) => state.isReady)
+ const session = useAuthStore((state) => state.session)
+ const location = useLocation()
+
+ if (!isSupabaseConfigured) {
+ return (
+
+
Classement indisponible
+
+ Le classement mondial a besoin d'un serveur, qui n'est pas configuré ici. Vos scores
+ restent enregistrés localement dans le mode Chasse.
+
+
+ )
+ }
+
+ // Wait for the stored session before deciding, or a signed-in player would be
+ // bounced to the login screen on every refresh.
+ if (!isReady) {
+ return (
+
+
+
+ )
+ }
+
+ if (!session) {
+ return
+ }
+
+ return <>{children}>
+}
diff --git a/src/features/hunt/HuntPage.tsx b/src/features/hunt/HuntPage.tsx
index f76b9f7..f2beec2 100644
--- a/src/features/hunt/HuntPage.tsx
+++ b/src/features/hunt/HuntPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
+import { Link } from 'react-router-dom'
import { Badge, Button, Card, PageHeader } from '@/components/UI'
import HuntBoard from '@/features/hunt/HuntBoard'
import { CHAMPION_DESCRIPTIONS, CHAMPION_LABELS, type ChampionType } from '@/features/hunt/board'
@@ -11,6 +12,9 @@ import {
} from '@/features/hunt/scoring'
import { useHuntGame } from '@/features/hunt/useHuntGame'
import { useLocalStorage } from '@/hooks/useLocalStorage'
+import { supabase } from '@/lib/supabase'
+import { ROUTES } from '@/routes'
+import { useAuthStore } from '@/store/useAuthStore'
import { useProgressionStore } from '@/store/useProgressionStore'
import { cn } from '@/utils/cn'
@@ -34,6 +38,7 @@ export default function HuntPage() {
const game = useHuntGame()
const [scoreboard, setScoreboard] = useLocalStorage('chesstrainer.hunt.scores', {})
const recordHunt = useProgressionStore((state) => state.recordHunt)
+ const session = useAuthStore((state) => state.session)
// Record the round once, when it ends.
const recorded = useRef(false)
@@ -54,6 +59,16 @@ export default function HuntPage() {
captures: game.captures,
championLabel: CHAMPION_LABELS[game.champion],
})
+
+ // Signed-in players are entered in the worldwide table automatically.
+ if (supabase && session && game.score > 0) {
+ void supabase.from('scores').insert({
+ user_id: session.user.id,
+ piece: game.champion,
+ score: game.score,
+ captures: game.captures,
+ })
+ }
setScoreboard((board) =>
addScore(board, {
champion: game.champion!,
@@ -62,7 +77,16 @@ export default function HuntPage() {
playedAt: new Date().toISOString(),
}),
)
- }, [game.phase, game.champion, game.score, game.captures, scoreboard, setScoreboard, recordHunt])
+ }, [
+ game.phase,
+ game.champion,
+ game.score,
+ game.captures,
+ scoreboard,
+ setScoreboard,
+ recordHunt,
+ session,
+ ])
if (game.phase === 'setup') {
return (
@@ -126,6 +150,16 @@ export default function HuntPage() {
{encouragement(game.score, previousBest, game.captures)}
+ {!session && game.score > 0 && (
+
+ Ce score pourrait figurer au classement mondial —{' '}
+
+ créez un compte
+ {' '}
+ pour l'y inscrire.
+
+ Vous jouez en invité. Un compte vous ouvre le classement mondial et retrouve vos
+ progrès sur tous vos appareils.
+
+
+
+ Créer un compte
+
+
+ Se connecter
+
+
+ >
+ )}
+
+
{xp} XP au total
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
new file mode 100644
index 0000000..b31893c
--- /dev/null
+++ b/src/lib/supabase.ts
@@ -0,0 +1,54 @@
+import { createClient, type SupabaseClient } from '@supabase/supabase-js'
+
+const url = import.meta.env.VITE_SUPABASE_URL
+const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
+
+/**
+ * Whether a backend is configured. Everything auth- or leaderboard-related is
+ * optional: without credentials the app still runs entirely as a guest, which
+ * is what section 2.6 asks for ("mode invité autorisé pour tous les modes") and
+ * what lets the build and CI work with no secrets at all.
+ */
+export const isSupabaseConfigured = Boolean(url && anonKey)
+
+export const supabase: SupabaseClient | null = isSupabaseConfigured
+ ? createClient(url!, anonKey!, {
+ auth: {
+ persistSession: true,
+ // The JWT refreshes itself client-side (specification section 2.6).
+ autoRefreshToken: true,
+ detectSessionInUrl: true,
+ },
+ })
+ : null
+
+/** The six piece symbols an avatar can be (specification section 2.6). */
+export const AVATAR_PIECES = ['k', 'q', 'r', 'b', 'n', 'p'] as const
+export type AvatarPiece = (typeof AVATAR_PIECES)[number]
+
+export const AVATAR_GLYPHS: Record = {
+ k: '♚',
+ q: '♛',
+ r: '♜',
+ b: '♝',
+ n: '♞',
+ p: '♟',
+}
+
+export interface Profile {
+ id: string
+ username: string
+ avatar_piece: AvatarPiece
+ xp: number
+ level: number
+ created_at: string
+}
+
+export interface ScoreRow {
+ id: number
+ user_id: string
+ piece: string
+ score: number
+ captures: number
+ played_at: string
+}
diff --git a/src/routes.ts b/src/routes.ts
index 18294d2..6f2b592 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -1,7 +1,4 @@
-/**
- * SPA route table (specification, section 4.3).
- * Authentication routes (/login, /register) are added in module M10.
- */
+/** SPA route table (specification, sections 4.3 and 2.6). */
export const ROUTES = {
home: '/',
coach: '/coach',
@@ -10,6 +7,8 @@ export const ROUTES = {
hunt: '/hunt',
leaderboard: '/leaderboard',
profile: '/profile',
+ login: '/login',
+ register: '/register',
} as const
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES]
diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts
new file mode 100644
index 0000000..6db1d3e
--- /dev/null
+++ b/src/store/useAuthStore.ts
@@ -0,0 +1,142 @@
+import type { Session } from '@supabase/supabase-js'
+import { create } from 'zustand'
+import { isSupabaseConfigured, supabase, type AvatarPiece, type Profile } from '@/lib/supabase'
+
+interface AuthState {
+ /** False until the stored session has been read, so guards do not flash. */
+ isReady: boolean
+ session: Session | null
+ profile: Profile | null
+ error: string | null
+
+ initialise: () => () => void
+ signUp: (input: { email: string; password: string; username: string }) => Promise
+ signIn: (input: { email: string; password: string }) => Promise
+ signInWithGoogle: () => Promise
+ signOut: () => Promise
+ updateProfile: (patch: { username?: string; avatar_piece?: AvatarPiece }) => Promise
+ clearError: () => void
+}
+
+/** Supabase messages are technical and in English; these are for the player. */
+function friendlyError(message: string): string {
+ const text = message.toLowerCase()
+ if (text.includes('invalid login credentials')) return 'Email ou mot de passe incorrect.'
+ if (text.includes('already registered')) return 'Un compte existe déjà avec cet email.'
+ if (text.includes('password')) return 'Le mot de passe doit faire au moins 6 caractères.'
+ if (text.includes('email')) return 'Adresse email invalide.'
+ if (text.includes('duplicate key') || text.includes('profiles_username'))
+ return 'Ce pseudo est déjà pris.'
+ return 'Une erreur est survenue. Réessayez dans un instant.'
+}
+
+/**
+ * Authentication and the signed-in profile (spec section 2.6). Every action is
+ * a no-op when no backend is configured, so guest play is never blocked.
+ */
+export const useAuthStore = create()((set, get) => ({
+ isReady: !isSupabaseConfigured,
+ session: null,
+ profile: null,
+ error: null,
+
+ initialise: () => {
+ // Captured once so the closures below keep the non-null narrowing.
+ const client = supabase
+ if (!client) {
+ set({ isReady: true })
+ return () => {}
+ }
+
+ const loadProfile = async (session: Session | null) => {
+ if (!session) {
+ set({ profile: null })
+ return
+ }
+ const { data } = await client
+ .from('profiles')
+ .select('*')
+ .eq('id', session.user.id)
+ .maybeSingle()
+ set({ profile: (data as Profile | null) ?? null })
+ }
+
+ void client.auth.getSession().then(async ({ data }) => {
+ set({ session: data.session })
+ await loadProfile(data.session)
+ set({ isReady: true })
+ })
+
+ const { data: subscription } = client.auth.onAuthStateChange((_event, session) => {
+ set({ session })
+ void loadProfile(session)
+ })
+
+ return () => subscription.subscription.unsubscribe()
+ },
+
+ signUp: async ({ email, password, username }) => {
+ if (!supabase) return false
+ set({ error: null })
+ // The username travels in the metadata; a database trigger creates the
+ // profile, so an account can never exist without one.
+ const { error } = await supabase.auth.signUp({
+ email,
+ password,
+ options: { data: { username } },
+ })
+ if (error) {
+ set({ error: friendlyError(error.message) })
+ return false
+ }
+ return true
+ },
+
+ signIn: async ({ email, password }) => {
+ if (!supabase) return false
+ set({ error: null })
+ const { error } = await supabase.auth.signInWithPassword({ email, password })
+ if (error) {
+ set({ error: friendlyError(error.message) })
+ return false
+ }
+ return true
+ },
+
+ signInWithGoogle: async () => {
+ if (!supabase) return
+ set({ error: null })
+ const { error } = await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: { redirectTo: `${window.location.origin}/profile` },
+ })
+ if (error) set({ error: friendlyError(error.message) })
+ },
+
+ signOut: async () => {
+ if (!supabase) return
+ await supabase.auth.signOut()
+ set({ session: null, profile: null })
+ },
+
+ updateProfile: async (patch) => {
+ const { session } = get()
+ if (!supabase || !session) return false
+ set({ error: null })
+ const { data, error } = await supabase
+ .from('profiles')
+ .update(patch)
+ .eq('id', session.user.id)
+ .select()
+ .single()
+
+ if (error) {
+ set({ error: friendlyError(error.message) })
+ return false
+ }
+ set({ profile: data as Profile })
+ return true
+ },
+
+ clearError: () => set({ error: null }),
+}))
diff --git a/vite.config.ts b/vite.config.ts
index f891dc6..6409f55 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -15,6 +15,9 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
+ // Tests always run as if no backend were configured: that is what CI sees
+ // (it holds no secrets) and it keeps them off the network entirely.
+ env: { VITE_SUPABASE_URL: '', VITE_SUPABASE_ANON_KEY: '' },
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
From d6b7bc830aa62ab11c8f1237ec6871ca3c749211 Mon Sep 17 00:00:00 2001
From: Amayyas
Date: Wed, 22 Jul 2026 23:02:06 +0200
Subject: [PATCH 3/7] fix(db): stop a player from granting themselves xp and
levels
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Row Level Security filters rows, not columns, so the ownership policy on
profiles left every column of one's own row writable — including xp and
level, which a player could set to anything with a single PATCH. Section
06 of the specification rates a misconfigured RLS as a high impact risk.
Grant update on (username, avatar_piece) only, and insert on the three
columns a client legitimately supplies. Verified against a local stack:
xp and level now return 42501 while a username change still succeeds.
Also harden the signup trigger, which runs before any of this: coerce an
unknown avatar_piece from the client metadata to the default instead of
letting the check constraint abort the whole signup, and retry on a
username collision rather than checking for one first, since two
concurrent signups could both pass that check and one would then fail.
---
supabase/migrations/20260711000000_init.sql | 40 +++++++++++++++------
1 file changed, 29 insertions(+), 11 deletions(-)
diff --git a/supabase/migrations/20260711000000_init.sql b/supabase/migrations/20260711000000_init.sql
index 0777fe0..b8c62b3 100644
--- a/supabase/migrations/20260711000000_init.sql
+++ b/supabase/migrations/20260711000000_init.sql
@@ -31,6 +31,8 @@ create policy "a user creates only their own profile"
to authenticated
with check (auth.uid() = id);
+-- Ownership only; which columns may change is enforced by the column grants
+-- below, because a policy cannot restrict columns.
drop policy if exists "a user edits only their own profile" on public.profiles;
create policy "a user edits only their own profile"
on public.profiles for update
@@ -134,7 +136,10 @@ grant usage on schema public to anon, authenticated;
-- Readable by everyone, including guests browsing the leaderboard.
grant select on public.profiles, public.scores, public.achievements to anon, authenticated;
-grant insert, update on public.profiles to authenticated;
+-- Column-level, deliberately: RLS filters rows, not columns. Granting update on
+-- the whole row would let a player set their own xp and level to anything.
+grant insert (id, username, avatar_piece) on public.profiles to authenticated;
+grant update (username, avatar_piece) on public.profiles to authenticated;
-- Insert only: a submitted score is final, so no update or delete is granted.
grant insert on public.scores to authenticated;
grant insert on public.achievements to authenticated;
@@ -155,23 +160,36 @@ as $$
declare
requested text := coalesce(new.raw_user_meta_data ->> 'username', split_part(new.email, '@', 1));
candidate text := left(regexp_replace(requested, '[^A-Za-z0-9_-]', '', 'g'), 24);
+ avatar text := coalesce(new.raw_user_meta_data ->> 'avatar_piece', 'n');
+ attempts integer := 0;
begin
if char_length(candidate) < 3 then
candidate := 'joueur' || left(replace(new.id::text, '-', ''), 6);
end if;
- -- Usernames are unique; fall back to a suffixed one rather than fail signup.
- if exists (select 1 from public.profiles where lower(username) = lower(candidate)) then
- candidate := left(candidate, 17) || left(replace(new.id::text, '-', ''), 6);
+ -- Metadata comes from the client and could hold anything; an unexpected value
+ -- would violate the check constraint and abort the whole signup.
+ if avatar not in ('k', 'q', 'r', 'b', 'n', 'p') then
+ avatar := 'n';
end if;
- insert into public.profiles (id, username, avatar_piece)
- values (
- new.id,
- candidate,
- coalesce(new.raw_user_meta_data ->> 'avatar_piece', 'n')
- );
- return new;
+ -- Retry on collision rather than check-then-insert: two signups racing on the
+ -- same fallback name would both pass a prior existence check and one would
+ -- fail, taking that signup down with it.
+ loop
+ begin
+ insert into public.profiles (id, username, avatar_piece)
+ values (new.id, candidate, avatar);
+ return new;
+ exception
+ when unique_violation then
+ attempts := attempts + 1;
+ if attempts > 5 then
+ raise exception 'could not allocate a username for %', new.id;
+ end if;
+ candidate := left(candidate, 16) || left(replace(gen_random_uuid()::text, '-', ''), 8);
+ end;
+ end loop;
end;
$$;
From 40baddc909862cd643e90f9f3ffb123cb8865486 Mon Sep 17 00:00:00 2001
From: Amayyas
Date: Wed, 22 Jul 2026 23:02:17 +0200
Subject: [PATCH 4/7] fix(hunt): keep sending a score once the session arrives
late
One flag gated both the local record and the worldwide submission, so a
round that ended before the stored session had been restored recorded
the score locally, marked itself done and never submitted it. Track the
two separately: the submission effect simply runs again when the session
lands.
The insert result was also discarded, which made a rejected score look
like a broken leaderboard. Report the failure and allow a later retry.
---
src/features/hunt/HuntPage.tsx | 60 ++++++++++++++++++++++------------
1 file changed, 39 insertions(+), 21 deletions(-)
diff --git a/src/features/hunt/HuntPage.tsx b/src/features/hunt/HuntPage.tsx
index f2beec2..cb6637c 100644
--- a/src/features/hunt/HuntPage.tsx
+++ b/src/features/hunt/HuntPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { Badge, Button, Card, PageHeader } from '@/components/UI'
import HuntBoard from '@/features/hunt/HuntBoard'
@@ -59,16 +59,6 @@ export default function HuntPage() {
captures: game.captures,
championLabel: CHAMPION_LABELS[game.champion],
})
-
- // Signed-in players are entered in the worldwide table automatically.
- if (supabase && session && game.score > 0) {
- void supabase.from('scores').insert({
- user_id: session.user.id,
- piece: game.champion,
- score: game.score,
- captures: game.captures,
- })
- }
setScoreboard((board) =>
addScore(board, {
champion: game.champion!,
@@ -77,16 +67,37 @@ export default function HuntPage() {
playedAt: new Date().toISOString(),
}),
)
- }, [
- game.phase,
- game.champion,
- game.score,
- game.captures,
- scoreboard,
- setScoreboard,
- recordHunt,
- session,
- ])
+ }, [game.phase, game.champion, game.score, game.captures, scoreboard, setScoreboard, recordHunt])
+
+ // Submitting to the worldwide table is tracked apart from the local record:
+ // the session is restored asynchronously, so a short round can end before it
+ // exists. Sharing one flag would drop the score for good; this effect simply
+ // runs again once the session arrives.
+ const submitted = useRef(false)
+ const [submitFailed, setSubmitFailed] = useState(false)
+ useEffect(() => {
+ if (game.phase !== 'over') {
+ submitted.current = false
+ setSubmitFailed(false)
+ return
+ }
+ if (submitted.current || !supabase || !session || !game.champion || game.score <= 0) return
+ submitted.current = true
+
+ const client = supabase
+ const round = { piece: game.champion, score: game.score, captures: game.captures }
+ void client
+ .from('scores')
+ .insert({ user_id: session.user.id, ...round })
+ .then(({ error }) => {
+ // A silently dropped score looks like the leaderboard is broken, so the
+ // failure is surfaced and a retry is allowed.
+ if (error) {
+ submitted.current = false
+ setSubmitFailed(true)
+ }
+ })
+ }, [game.phase, game.champion, game.score, game.captures, session])
if (game.phase === 'setup') {
return (
@@ -150,6 +161,13 @@ export default function HuntPage() {
{encouragement(game.score, previousBest, game.captures)}
+ {submitFailed && (
+
+ Score enregistré localement, mais son envoi au classement mondial a échoué. Il sera
+ renvoyé à la prochaine manche.
+
+ )}
+
{!session && game.score > 0 && (
Ce score pourrait figurer au classement mondial —{' '}
From b698c0ce1a81887604218a399dd3f57462388a89 Mon Sep 17 00:00:00 2001
From: Amayyas
Date: Wed, 22 Jul 2026 23:02:17 +0200
Subject: [PATCH 5/7] fix(auth): correct three ways the account state could
mislead
The profile fetch had no ordering guarantee, so signing out and back in
could let the first, slower response overwrite the new session's profile.
Discard responses that a newer request has superseded.
The profile page fell through to the guest block while the session and
profile were still loading, inviting a signed-in player to create a
second account; it now says it is loading.
Local Google sign-in could not work either: the config declared no
google provider, and the redirect allow-list held neither the /profile
callback the client asks for nor the port this app actually serves on.
---
src/features/profile/ProfilePage.tsx | 6 ++++++
src/store/useAuthStore.ts | 6 ++++++
supabase/config.toml | 25 +++++++++++++++++++++++--
3 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/src/features/profile/ProfilePage.tsx b/src/features/profile/ProfilePage.tsx
index 2f073bc..c68e788 100644
--- a/src/features/profile/ProfilePage.tsx
+++ b/src/features/profile/ProfilePage.tsx
@@ -23,6 +23,7 @@ export default function ProfilePage() {
const xp = useProgressionStore((state) => state.xp)
const stats = useProgressionStore((state) => state.stats)
const unlocked = useProgressionStore((state) => state.unlockedBadges)
+ const isReady = useAuthStore((state) => state.isReady)
const session = useAuthStore((state) => state.session)
const authProfile = useAuthStore((state) => state.profile)
const updateProfile = useAuthStore((state) => state.updateProfile)
@@ -61,6 +62,11 @@ export default function ProfilePage() {
Aucun serveur n'est configuré : vous jouez en invité et votre progression reste sur
cet appareil.
+ ) : !isReady || (session && !authProfile) ? (
+ // The stored session, then the profile, are both fetched on
+ // start-up. Falling through to the guest block meanwhile would
+ // invite a signed-in player to create a second account.
+
Chargement de votre compte…
) : session && authProfile ? (
<>
diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts
index 6db1d3e..d31aa97 100644
--- a/src/store/useAuthStore.ts
+++ b/src/store/useAuthStore.ts
@@ -48,7 +48,12 @@ export const useAuthStore = create()((set, get) => ({
return () => {}
}
+ // Sign-out, then sign-in, can overlap: without this token a slow first
+ // request could land last and hand the new session the old profile.
+ let latestRequest = 0
+
const loadProfile = async (session: Session | null) => {
+ const request = ++latestRequest
if (!session) {
set({ profile: null })
return
@@ -58,6 +63,7 @@ export const useAuthStore = create()((set, get) => ({
.select('*')
.eq('id', session.user.id)
.maybeSingle()
+ if (request !== latestRequest) return
set({ profile: (data as Profile | null) ?? null })
}
diff --git a/supabase/config.toml b/supabase/config.toml
index 60e3436..a89d1ed 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -156,11 +156,21 @@ max_indexes = 5
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
-site_url = "http://127.0.0.1:3000"
+# Port 5173 is the Vite dev server of this app, not the Supabase default of 3000.
+site_url = "http://127.0.0.1:5173"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
-additional_redirect_urls = ["https://127.0.0.1:3000"]
+# signInWithOAuth() sends the player to `${window.location.origin}/profile`, and
+# this list is matched *exactly* — without the /profile entries Google sign-in
+# fails locally with "requested path is invalid". Both hostnames are listed
+# because the dev server answers on either.
+additional_redirect_urls = [
+ "http://127.0.0.1:5173",
+ "http://127.0.0.1:5173/profile",
+ "http://localhost:5173",
+ "http://localhost:5173/profile",
+]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
@@ -334,6 +344,17 @@ skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
+# Google sign-in, offered next to the email form (specification section 2.6).
+# The credentials come from a Google Cloud OAuth client and stay out of the repo;
+# set them in supabase/.env before running `supabase start`.
+[auth.external.google]
+enabled = true
+client_id = "env(SUPABASE_AUTH_GOOGLE_CLIENT_ID)"
+secret = "env(SUPABASE_AUTH_GOOGLE_SECRET)"
+redirect_uri = "http://127.0.0.1:54321/auth/v1/callback"
+# Google issues no nonce against a local callback.
+skip_nonce_check = true
+
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
From 00c05412e13d9224ce77ede91c297c392f54c0b7 Mon Sep 17 00:00:00 2001
From: Amayyas
Date: Wed, 22 Jul 2026 23:03:49 +0200
Subject: [PATCH 6/7] docs(readme): add the backend and Google sign-in setup
guide
Section 2.6 needs a Supabase project, and nothing so far said how to make
one. Walk through the project, the schema, the redirect URLs and the
Google provider, and state plainly that all of it is optional: without a
backend the app stays fully playable as a guest.
---
README.md | 88 +++++++++++++++++++++++++++++++++++++++++++
supabase/.env.example | 6 +++
2 files changed, 94 insertions(+)
create mode 100644 supabase/.env.example
diff --git a/README.md b/README.md
index 236a0ca..6edf490 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,94 @@ npm run dev
The app is served on http://localhost:5173.
+## Backend setup (optional)
+
+Every mode is playable without a backend: the app detects that no Supabase
+project is configured and runs in guest mode, keeping progress in
+`localStorage`. Only accounts and the worldwide leaderboard need the steps
+below.
+
+### 1. Create the project
+
+1. Sign in on [supabase.com](https://supabase.com) and create a new project.
+ Note the database password somewhere safe; it is shown only once.
+2. Open **Project Settings > API** and copy **Project URL** and the **anon
+ public** key.
+3. Copy `.env.example` to `.env.local` and paste both values:
+
+ ```bash
+ cp .env.example .env.local
+ ```
+
+ The anon key is meant to be public — it is shipped in the browser bundle,
+ and Row Level Security is what actually protects the data. Never put the
+ `service_role` key in this file.
+
+### 2. Create the tables
+
+`supabase/migrations/` holds the whole schema: tables, Row Level Security
+policies, privileges, the trigger that creates a profile on sign-up, and the
+realtime publication the leaderboard subscribes to.
+
+The quickest way is the dashboard: open **SQL Editor**, paste the contents of
+`supabase/migrations/20260711000000_init.sql`, and run it. The script is
+idempotent, so running it twice is harmless.
+
+With the [Supabase CLI](https://supabase.com/docs/guides/cli) instead:
+
+```bash
+npx supabase link --project-ref
+npx supabase db push
+```
+
+Check it worked under **Table Editor**: `profiles`, `scores`,
+`puzzle_progress` and `achievements` should be listed, each marked _RLS
+enabled_.
+
+### 3. Set the URLs
+
+Under **Authentication > URL Configuration**:
+
+- **Site URL** — `http://localhost:5173` while developing, the deployed
+ address once online.
+- **Redirect URLs** — add `http://localhost:5173/profile` and, once deployed,
+ `https:///profile`. Sign-in sends the player back to `/profile`
+ and this list is matched exactly, so a missing entry fails the sign-in.
+
+Under **Authentication > Providers > Email**, turn **Confirm email** off while
+testing, unless you want to click a confirmation link for every test account.
+
+### 4. Google sign-in (optional)
+
+The button is shown regardless; it only works once this is done.
+
+1. In [Google Cloud Console](https://console.cloud.google.com), create a
+ project, then **APIs & Services > OAuth consent screen**: choose
+ **External**, fill in the app name and your email, and add your own address
+ under **Test users** so you can sign in before the app is published.
+2. **Credentials > Create credentials > OAuth client ID**, type **Web
+ application**. Under **Authorised redirect URIs**, paste the callback shown
+ by Supabase in **Authentication > Providers > Google** — it looks like
+ `https://.supabase.co/auth/v1/callback`.
+3. Copy the generated **Client ID** and **Client secret** into that same
+ Supabase Google provider panel, enable it, and save.
+
+Nothing changes in the app itself: the provider is read from the project.
+
+### Running the backend locally
+
+Docker is required. `supabase/config.toml` is already set up for this app —
+port 5173, and the `/profile` callback in the allow-list.
+
+```bash
+npx supabase start # applies supabase/migrations automatically
+npx supabase status # prints the local URL and anon key for .env.local
+```
+
+For local Google sign-in, put the same credentials in `supabase/.env` (git
+ignored, see `supabase/.env.example`). Without it the stack still starts and
+email sign-in works; only the Google button is inert.
+
## Scripts
| Script | Purpose |
diff --git a/supabase/.env.example b/supabase/.env.example
new file mode 100644
index 0000000..6868b02
--- /dev/null
+++ b/supabase/.env.example
@@ -0,0 +1,6 @@
+# Local-only Google OAuth credentials, read by supabase/config.toml.
+# Copy to supabase/.env (git ignored) and fill in with a Google Cloud OAuth
+# client. Without this file the stack still starts and email sign-in works;
+# only the Google button is inert. See the README, "Backend setup".
+SUPABASE_AUTH_GOOGLE_CLIENT_ID=
+SUPABASE_AUTH_GOOGLE_SECRET=
From f7afd5b1a65c972087ebf93ec3f7d233d6ab3ba4 Mon Sep 17 00:00:00 2001
From: Amayyas
Date: Wed, 22 Jul 2026 23:10:01 +0200
Subject: [PATCH 7/7] docs(readme): register the local OAuth callback with
Google too
config.toml points the local stack at its own auth service on port 54321,
but the guide only asked for the hosted callback, so Google would reject
a local sign-in with redirect_uri_mismatch.
---
README.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 6edf490..fa00124 100644
--- a/README.md
+++ b/README.md
@@ -104,7 +104,10 @@ The button is shown regardless; it only works once this is done.
2. **Credentials > Create credentials > OAuth client ID**, type **Web
application**. Under **Authorised redirect URIs**, paste the callback shown
by Supabase in **Authentication > Providers > Google** — it looks like
- `https://.supabase.co/auth/v1/callback`.
+ `https://.supabase.co/auth/v1/callback`. Add
+ `http://127.0.0.1:54321/auth/v1/callback` as well if you intend to sign in
+ against a local stack; that is where its own auth service listens, and
+ Google rejects any callback not listed here.
3. Copy the generated **Client ID** and **Client secret** into that same
Supabase Google provider panel, enable it, and save.