feat: Supabase auth, RLS backend and live leaderboard (M10) - #9
Conversation
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.
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds optional Supabase integration with local configuration, authentication routes and state, protected leaderboard access, persistent authenticated scores, realtime leaderboard updates, and profile account controls. Guest mode remains available without Supabase credentials. ChangesSupabase foundation
Authentication and routing
Score submission and leaderboard
Profile account controls
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant App
participant useAuthStore
participant RequireAuth
participant LoginPage
participant SupabaseAuth
App->>useAuthStore: initialise()
useAuthStore->>SupabaseAuth: restore session
RequireAuth->>useAuthStore: check session
RequireAuth->>LoginPage: redirect when unauthenticated
LoginPage->>SupabaseAuth: submit credentials
SupabaseAuth-->>useAuthStore: return session
LoginPage-->>RequireAuth: navigate to leaderboard
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/features/hunt/HuntPage.tsx`:
- Around line 62-71: Update the round-completion effect in HuntPage so
recorded.current only gates the local recordHunt call, while remote Supabase
submission can retry when session becomes available. Submit the score once for
an authenticated player with a positive score, track remote submission
separately to prevent duplicates, and inspect the insert result so failures are
surfaced through the existing error-handling mechanism instead of being
discarded.
In `@src/features/profile/ProfilePage.tsx`:
- Line 64: Update the ProfilePage render state around the session/authProfile
condition to model profile loading and profile error separately from
authentication state. Ensure restored sessions remain in an authenticated
loading or error state while loadProfile() is pending or fails, and render guest
CTAs only when session === null; preserve the authenticated profile view once
authProfile is available.
In `@src/store/useAuthStore.ts`:
- Around line 51-62: Update loadProfile to clear the existing profile before
fetching, then commit the fetched profile only when the returned session user
still matches the store’s current session user. Prevent results from sign-out or
a different user session from overwriting the active profile.
In `@supabase/config.toml`:
- Around line 159-163: Update the local OAuth redirect configuration around
additional_redirect_urls to allow the exact http://127.0.0.1:3000/profile
callback used by signInWithOAuth(), or change redirectTo to match an existing
allow-listed URL; ensure local Google sign-in no longer uses the mismatched
HTTPS origin.
- Around line 319-335: Add an [auth.external.google] configuration block
alongside the existing OAuth provider settings, enabling Google with
environment-backed client_id and secret values, and configure the local redirect
URI as needed. Set skip_nonce_check only when required by the local client,
while preserving the provider’s default-disabled behavior unless explicitly
enabled.
In `@supabase/migrations/20260711000000_init.sql`:
- Around line 34-39: Restrict the authenticated update permissions for
public.profiles so clients can update only approved cosmetic columns, not
server-controlled xp or level. Update the profile UPDATE grant near the existing
“a user edits only their own profile” policy, while preserving the ownership
checks in that policy and ensuring server-side profile updates retain access to
xp and level.
- Around line 156-176: Update handle_new_user to make username allocation
atomic: replace the standalone profiles existence check with an insert-and-retry
flow that handles unique conflicts and derives another candidate until insertion
succeeds. In the same function, validate raw_user_meta_data.avatar_piece against
the allowed values ('k','q','r','b','n','p') and use 'n' for null or invalid
values before inserting. Preserve the existing username sanitization and
short-name fallback behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9449dfed-3c75-4026-93d7-757f9dafedc5
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (24)
.env.example.gitignore.prettierignorepackage.jsonscripts/check-bundle-size.mjssrc/App.test.tsxsrc/App.tsxsrc/components/Layout/navigation.test.tssrc/features/auth/AuthLayout.tsxsrc/features/auth/LoginPage.tsxsrc/features/auth/RegisterPage.tsxsrc/features/auth/RequireAuth.tsxsrc/features/hunt/HuntPage.tsxsrc/features/leaderboard/LeaderboardPage.tsxsrc/features/leaderboard/useLeaderboard.test.tssrc/features/leaderboard/useLeaderboard.tssrc/features/profile/ProfilePage.tsxsrc/lib/supabase.tssrc/routes.tssrc/store/useAuthStore.tssupabase/.gitignoresupabase/config.tomlsupabase/migrations/20260711000000_init.sqlvite.config.ts
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.
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.
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.
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.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@README.md`:
- Around line 104-107: Add the local Supabase OAuth callback URL
http://127.0.0.1:54321/auth/v1/callback alongside the hosted callback in the
Google OAuth client setup instructions, preserving the existing redirect URI
guidance.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: bce8e2f7-589b-45cd-85fd-182a4828a4d3
📒 Files selected for processing (2)
README.mdsupabase/.env.example
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.
Delivers module M10 — Auth, Backend & Classement: the Supabase data model with row level security, accounts, route guards and the live worldwide leaderboard. Two commits.
Verified against a real database, not just written
I ran a local Supabase stack (Docker) and checked the whole chain against real Postgres, real auth and real Realtime:
And in the browser: a guest visiting
/leaderboardlands on the login screen, signing up through the UI creates the account and shows "Inscrit le 22/07/2026", and a score inserted from outside the browser appeared at the top of the board with no reload — Realtime works.The bug that only a real database could show
The first run failed almost everywhere with
42501 permission denied, despite correct policies. RLS filters rows; a GRANT opens the table — and relying on Supabase's default privileges is not portable. Without explicit grants nothing worked at all. Had I shipped this without running it, the backend would have been dead on arrival.What is in it
profiles,scores,puzzle_progress,achievements, RLS on every table with no permissive default, writes always pinned toauth.uid(), a sign-up trigger so an account can never exist without a profile, andscoresin the realtime publication.RequireAuthon the leaderboard only — every game mode stays open to guests, as section 2.6 requires. It waits for the stored session before deciding, or a signed-in player would be bounced on every refresh.Notes and deviations
.env.exampleto.env.localwith your project URL and anon key, and applysupabase/migrations/.npm run cigreen: format, lint, typecheck, 245 tests, build, bundle budget.Summary by CodeRabbit