From c87b85de405fbfae0de912ce6a0ce9e5920b19a2 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Mon, 1 Dec 2025 07:00:24 -0600 Subject: [PATCH] Fix ship physics init order and Supabase RLS user sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass initial position to ship.initialize() to set position BEFORE creating physics body, preventing collision race condition on reload - Use get_or_create_user_id RPC (security definer) to bypass RLS for user profile sync in both authService and cloudLeaderboardService - Sync user to Supabase on Auth0 login to ensure profile exists - Add Supabase schema.sql and policies.sql for documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/levels/level1.ts | 8 +- src/services/authService.ts | 30 +++ src/services/cloudLeaderboardService.ts | 37 +-- src/ship/ship.ts | 10 +- supabase/policies.sql | 310 ++++++++++++++++++++++++ supabase/schema.sql | 228 +++++++++++++++++ 6 files changed, 596 insertions(+), 27 deletions(-) create mode 100644 supabase/policies.sql create mode 100644 supabase/schema.sql diff --git a/src/levels/level1.ts b/src/levels/level1.ts index c7be420..36744b6 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -353,12 +353,10 @@ export class Level1 implements Level { log.error('Initialize called twice'); return; } - await this._ship.initialize(); - setLoadingMessage("Loading level from configuration..."); - - // Apply ship configuration from level config + // Get ship config BEFORE initialize to pass position (avoids physics race condition) const shipConfig = this._deserializer.getShipConfig(); - this._ship.position = new Vector3(...shipConfig.position); + await this._ship.initialize(new Vector3(...shipConfig.position)); + setLoadingMessage("Loading level from configuration..."); if (shipConfig.linearVelocity) { this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity)); diff --git a/src/services/authService.ts b/src/services/authService.ts index a6d8f85..e1cd6a9 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,5 +1,6 @@ import { createAuth0Client, Auth0Client, User } from '@auth0/auth0-spa-js'; import log from '../core/logger'; +import { SupabaseService } from './supabaseService'; /** * Singleton service for managing Auth0 authentication @@ -89,6 +90,9 @@ export class AuthService { email: this._user?.email, sub: this._user?.sub }); + + // Sync user to Supabase (fire and forget - don't block init) + this.syncUserToSupabase(); } else { log.info('[AuthService] User not authenticated'); } @@ -156,6 +160,32 @@ export class AuthService { } } + /** + * Sync user to Supabase users table + * Called after successful authentication + * Uses RPC to bypass RLS via security definer function + */ + private async syncUserToSupabase(): Promise { + if (!this._user?.sub) return; + + const supabase = SupabaseService.getInstance(); + if (!supabase.isConfigured()) return; + + const client = await supabase.getAuthenticatedClient(); + if (!client) return; + + // Use security definer function to create/get user (bypasses RLS) + const { data, error } = await client.rpc('get_or_create_user_id', { + p_auth0_id: this._user.sub + }); + + if (error) { + log.warn('[AuthService] Failed to sync user to Supabase:', error); + } else { + log.info('[AuthService] User synced to Supabase, UUID:', data); + } + } + /** * Check if user logged in via Facebook * Auth0 stores the identity provider in the user's sub claim diff --git a/src/services/cloudLeaderboardService.ts b/src/services/cloudLeaderboardService.ts index 0188b1e..907ad47 100644 --- a/src/services/cloudLeaderboardService.ts +++ b/src/services/cloudLeaderboardService.ts @@ -62,35 +62,32 @@ export class CloudLeaderboardService { } /** - * Ensure user exists in the users table with current display name + * Ensure user exists in the users table * Called before submitting scores + * Uses RPC to bypass RLS via security definer function + * @returns The internal UUID of the user, or null on failure */ - private async ensureUserProfile(userId: string, displayName: string): Promise { + private async ensureUserProfile(auth0Id: string): Promise { const supabase = SupabaseService.getInstance(); const client = await supabase.getAuthenticatedClient(); if (!client) { log.warn('[CloudLeaderboardService] Not authenticated - cannot sync user'); - return false; + return null; } - // Upsert the user (insert or update if exists) - const { error } = await client - .from('users') - .upsert({ - user_id: userId, - display_name: displayName - }, { - onConflict: 'user_id' - }); + // Use security definer function to create/get user (bypasses RLS) + const { data, error } = await client.rpc('get_or_create_user_id', { + p_auth0_id: auth0Id + }); if (error) { log.error('[CloudLeaderboardService] Failed to sync user:', error); - return false; + return null; } - log.info('[CloudLeaderboardService] User synced:', userId); - return true; + log.info('[CloudLeaderboardService] User synced:', auth0Id, '-> UUID:', data); + return data; } /** @@ -123,11 +120,15 @@ export class CloudLeaderboardService { return false; } - // Ensure user profile exists with current display name - await this.ensureUserProfile(user.sub, result.playerName); + // Ensure user profile exists and get the internal UUID + const internalUserId = await this.ensureUserProfile(user.sub); + if (!internalUserId) { + log.warn('[CloudLeaderboardService] Failed to get/create user profile'); + return false; + } const entry = { - user_id: user.sub, + user_id: internalUserId, level_id: result.levelId, level_name: result.levelName, completed: result.completed, diff --git a/src/ship/ship.ts b/src/ship/ship.ts index c62ba7e..95976a0 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -148,16 +148,18 @@ export class Ship { } } - public async initialize() { + public async initialize(initialPosition?: Vector3) { this._scoreboard = new Scoreboard(); this._scoreboard.setShip(this); // Pass ship reference for velocity reading this._gameStats = new GameStats(); this._ship = new TransformNode("shipBase", DefaultScene.MainScene); const data = await loadAsset("ship.glb"); this._ship = data.container.transformNodes[0]; - //this._ship.rotation = new Vector3(0, Math.PI, 0); - // this._ship.id = "Ship"; // Set ID so mission brief can find it - // Position is now set from level config in Level1.initialize() + + // Set position BEFORE creating physics body to avoid collision race condition + if (initialPosition) { + this._ship.position.copyFrom(initialPosition); + } // Create physics if enabled const config = GameConfig.getInstance(); diff --git a/supabase/policies.sql b/supabase/policies.sql new file mode 100644 index 0000000..c91a7c5 --- /dev/null +++ b/supabase/policies.sql @@ -0,0 +1,310 @@ +create policy "Anyone can read leaderboard" on public.leaderboard + as permissive + for select + using true; + +create policy "Allow all inserts" on public.leaderboard + as permissive + for insert + with check true; + +create function public.is_admin() returns boolean + security definer + language plpgsql +as +$$ +BEGIN +RETURN EXISTS ( + SELECT 1 FROM admins + WHERE user_id = auth.uid()::text + AND is_active = true + AND (expires_at IS NULL OR expires_at > NOW()) +); +END; +$$; + +alter function public.is_admin() owner to postgres; + +grant execute on function public.is_admin() to anon; + +grant execute on function public.is_admin() to authenticated; + +grant execute on function public.is_admin() to service_role; + +create function public.has_admin_permission(permission text) returns boolean + security definer + language plpgsql +as +$$ +DECLARE +admin_record admins%ROWTYPE; +BEGIN +SELECT * INTO admin_record FROM admins +WHERE user_id = auth.uid()::text + AND is_active = true + AND (expires_at IS NULL OR expires_at > NOW()); + +IF admin_record IS NULL THEN + RETURN false; +END IF; + +CASE permission + WHEN 'review_levels' THEN RETURN admin_record.can_review_levels; +WHEN 'manage_admins' THEN RETURN admin_record.can_manage_admins; +WHEN 'manage_official' THEN RETURN admin_record.can_manage_official; +WHEN 'view_analytics' THEN RETURN admin_record.can_view_analytics; +ELSE RETURN false; +END CASE; +END; +$$; + +alter function public.has_admin_permission(text) owner to postgres; + +grant execute on function public.has_admin_permission(text) to anon; + +grant execute on function public.has_admin_permission(text) to authenticated; + +grant execute on function public.has_admin_permission(text) to service_role; + +create function public.validate_slug(slug text) returns boolean + immutable + language plpgsql +as +$$ +BEGIN + -- Allow NULL slugs (optional) + IF slug IS NULL THEN + RETURN true; +END IF; + + -- Must be 3-50 chars, lowercase alphanumeric with hyphens, no leading/trailing hyphens +RETURN slug ~ '^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$'; +END; +$$; + +alter function public.validate_slug(text) owner to postgres; + +grant execute on function public.validate_slug(text) to anon; + +grant execute on function public.validate_slug(text) to authenticated; + +grant execute on function public.validate_slug(text) to service_role; + +create function public.is_slug_available(check_slug text, exclude_level_id uuid DEFAULT NULL::uuid) returns boolean + security definer + language plpgsql +as +$$ +BEGIN + IF check_slug IS NULL THEN + RETURN true; +END IF; + +RETURN NOT EXISTS ( + SELECT 1 FROM levels + WHERE slug = check_slug + AND (exclude_level_id IS NULL OR id != exclude_level_id) +); +END; +$$; + +alter function public.is_slug_available(text, uuid) owner to postgres; + +grant execute on function public.is_slug_available(text, uuid) to anon; + +grant execute on function public.is_slug_available(text, uuid) to authenticated; + +grant execute on function public.is_slug_available(text, uuid) to service_role; + +create function public.submit_level_for_review(level_id uuid) returns void + security definer + language plpgsql +as +$$ +BEGIN +UPDATE levels +SET level_type = 'pending_review', + submitted_at = NOW() +WHERE id = level_id + AND user_id = auth_user_id() + AND level_type IN ('private', 'rejected'); +END; +$$; + +alter function public.submit_level_for_review(uuid) owner to postgres; + +grant execute on function public.submit_level_for_review(uuid) to anon; + +grant execute on function public.submit_level_for_review(uuid) to authenticated; + +grant execute on function public.submit_level_for_review(uuid) to service_role; + +create function public.approve_level(p_level_id uuid, p_notes text DEFAULT NULL::text) returns void + security definer + language plpgsql +as +$$ +BEGIN + IF NOT has_admin_permission('review_levels') THEN + RAISE EXCEPTION 'Permission denied'; +END IF; + +UPDATE levels +SET + level_type = 'published', + reviewed_at = NOW(), + reviewed_by = auth.uid()::text, + review_notes = p_notes, + updated_at = NOW() +WHERE id = p_level_id + AND level_type = 'pending_review'; +END; +$$; + +alter function public.approve_level(uuid, text) owner to postgres; + +grant execute on function public.approve_level(uuid, text) to anon; + +grant execute on function public.approve_level(uuid, text) to authenticated; + +grant execute on function public.approve_level(uuid, text) to service_role; + +create function public.reject_level(p_level_id uuid, p_notes text) returns void + security definer + language plpgsql +as +$$ +BEGIN + IF NOT has_admin_permission('review_levels') THEN + RAISE EXCEPTION 'Permission denied'; +END IF; + +UPDATE levels +SET + level_type = 'rejected', + reviewed_at = NOW(), + reviewed_by = auth.uid()::text, + review_notes = p_notes, + updated_at = NOW() +WHERE id = p_level_id + AND level_type = 'pending_review'; +END; +$$; + +alter function public.reject_level(uuid, text) owner to postgres; + +grant execute on function public.reject_level(uuid, text) to anon; + +grant execute on function public.reject_level(uuid, text) to authenticated; + +grant execute on function public.reject_level(uuid, text) to service_role; + +create function public.update_updated_at() returns trigger + language plpgsql +as +$$ +BEGIN + NEW.updated_at = NOW(); +RETURN NEW; +END; +$$; + +alter function public.update_updated_at() owner to postgres; + +grant execute on function public.update_updated_at() to anon; + +grant execute on function public.update_updated_at() to authenticated; + +grant execute on function public.update_updated_at() to service_role; + +create function public.increment_play_count(p_level_id uuid) returns void + security definer + language plpgsql +as +$$ +BEGIN +UPDATE levels +SET play_count = play_count + 1 +WHERE id = p_level_id; +END; +$$; + +alter function public.increment_play_count(uuid) owner to postgres; + +grant execute on function public.increment_play_count(uuid) to anon; + +grant execute on function public.increment_play_count(uuid) to authenticated; + +grant execute on function public.increment_play_count(uuid) to service_role; + +create function public.increment_completion_count(p_level_id uuid) returns void + security definer + language plpgsql +as +$$ +BEGIN +UPDATE levels +SET completion_count = completion_count + 1 +WHERE id = p_level_id; +END; +$$; + +alter function public.increment_completion_count(uuid) owner to postgres; + +grant execute on function public.increment_completion_count(uuid) to anon; + +grant execute on function public.increment_completion_count(uuid) to authenticated; + +grant execute on function public.increment_completion_count(uuid) to service_role; + +create function public.get_or_create_user_id(p_auth0_id text) returns uuid + security definer + language plpgsql +as +$$ +DECLARE +v_user_id UUID; +BEGIN + -- Try to find existing user +SELECT id INTO v_user_id FROM users WHERE auth0_id = p_auth0_id; + +-- Create if not found +IF v_user_id IS NULL THEN + INSERT INTO users (auth0_id) VALUES (p_auth0_id) + RETURNING id INTO v_user_id; +END IF; + +RETURN v_user_id; +END; +$$; + +alter function public.get_or_create_user_id(text) owner to postgres; + +grant execute on function public.get_or_create_user_id(text) to anon; + +grant execute on function public.get_or_create_user_id(text) to authenticated; + +grant execute on function public.get_or_create_user_id(text) to service_role; + +create function public.auth_user_id() returns uuid + stable + security definer + language plpgsql +as +$$ +BEGIN +RETURN ( + SELECT id FROM users + WHERE auth0_id = auth.jwt() ->> 'sub' + ); +END; +$$; + +alter function public.auth_user_id() owner to postgres; + +grant execute on function public.auth_user_id() to anon; + +grant execute on function public.auth_user_id() to authenticated; + +grant execute on function public.auth_user_id() to service_role; + diff --git a/supabase/schema.sql b/supabase/schema.sql new file mode 100644 index 0000000..30019cc --- /dev/null +++ b/supabase/schema.sql @@ -0,0 +1,228 @@ +create table public._migrations +( + id serial + primary key, + name text not null + unique, + executed_at timestamp with time zone default now() +); + +alter table public._migrations + owner to postgres; + +grant select, update, usage on sequence public._migrations_id_seq to anon; + +grant select, update, usage on sequence public._migrations_id_seq to authenticated; + +grant select, update, usage on sequence public._migrations_id_seq to service_role; + +grant delete, insert, references, select, trigger, truncate, update on public._migrations to anon; + +grant delete, insert, references, select, trigger, truncate, update on public._migrations to authenticated; + +grant delete, insert, references, select, trigger, truncate, update on public._migrations to service_role; + +create table public.users +( + id uuid default gen_random_uuid() not null + primary key, + auth0_id text not null + unique, + display_name text, + email text, + avatar_url text, + created_at timestamp with time zone default now(), + last_login_at timestamp with time zone default now() +); + +alter table public.users + owner to postgres; + +create table public.leaderboard +( + id uuid default gen_random_uuid() not null + primary key, + level_id text not null, + level_name text not null, + completed boolean not null, + end_reason text not null, + game_time_seconds numeric not null, + asteroids_destroyed integer not null, + total_asteroids integer not null, + accuracy numeric not null, + hull_damage_taken numeric not null, + fuel_consumed numeric not null, + final_score integer not null, + star_rating integer not null, + created_at timestamp with time zone default now(), + is_test_data boolean default false not null, + user_id uuid + constraint leaderboard_internal_user_id_fkey + references public.users +); + +alter table public.leaderboard + owner to postgres; + +create index idx_leaderboard_score + on public.leaderboard (final_score desc); + +create index idx_leaderboard_level + on public.leaderboard (level_id); + +create index idx_leaderboard_test_data + on public.leaderboard (is_test_data) + where (is_test_data = true); + +create index idx_leaderboard_user_id + on public.leaderboard (user_id); + +grant delete, insert, references, select, trigger, truncate, update on public.leaderboard to anon; + +grant delete, insert, references, select, trigger, truncate, update on public.leaderboard to authenticated; + +grant delete, insert, references, select, trigger, truncate, update on public.leaderboard to service_role; + +create table public.admins +( + id uuid default gen_random_uuid() not null + primary key, + user_id text not null + unique, + display_name text, + email text, + can_review_levels boolean default true, + can_manage_admins boolean default false, + can_manage_official boolean default false, + can_view_analytics boolean default false, + is_active boolean default true, + expires_at timestamp with time zone, + created_at timestamp with time zone default now(), + created_by text, + notes text, + internal_user_id uuid + references public.users +); + +alter table public.admins + owner to postgres; + +create index idx_admins_user_id + on public.admins (user_id); + +create index idx_admins_active + on public.admins (is_active) + where (is_active = true); + +grant delete, insert, references, select, trigger, truncate, update on public.admins to anon; + +grant delete, insert, references, select, trigger, truncate, update on public.admins to authenticated; + +grant delete, insert, references, select, trigger, truncate, update on public.admins to service_role; + +create table public.levels +( + id uuid default gen_random_uuid() not null + primary key, + slug text + unique + constraint valid_slug_format + check (validate_slug(slug)), + name text not null, + description text, + difficulty text not null + constraint valid_difficulty + check (difficulty = ANY + (ARRAY ['recruit'::text, 'pilot'::text, 'captain'::text, 'commander'::text, 'test'::text])), + estimated_time text, + tags text[] default '{}'::text[], + config jsonb not null, + mission_brief text[] default '{}'::text[], + level_type text default 'private'::text not null + constraint valid_level_type + check (level_type = ANY + (ARRAY ['official'::text, 'private'::text, 'pending_review'::text, 'published'::text, 'rejected'::text])), + sort_order integer default 0, + unlock_requirements text[] default '{}'::text[], + default_locked boolean default false, + submitted_at timestamp with time zone, + reviewed_at timestamp with time zone, + reviewed_by text, + review_notes text, + play_count integer default 0, + completion_count integer default 0, + avg_rating numeric(3, 2) default 0, + rating_count integer default 0, + created_at timestamp with time zone default now(), + updated_at timestamp with time zone default now(), + user_id uuid + references public.users +); + +alter table public.levels + owner to postgres; + +create index idx_levels_type + on public.levels (level_type); + +create index idx_levels_slug + on public.levels (slug); + +create index idx_levels_official_order + on public.levels (sort_order) + where (level_type = 'official'::text); + +create index idx_levels_published + on public.levels (created_at desc) + where (level_type = 'published'::text); + +create index idx_levels_pending + on public.levels (submitted_at) + where (level_type = 'pending_review'::text); + +create index idx_levels_user_id + on public.levels (user_id); + +grant delete, insert, references, select, trigger, truncate, update on public.levels to anon; + +grant delete, insert, references, select, trigger, truncate, update on public.levels to authenticated; + +grant delete, insert, references, select, trigger, truncate, update on public.levels to service_role; + +create table public.level_ratings +( + id uuid default gen_random_uuid() not null + primary key, + level_id uuid not null + references public.levels + on delete cascade, + rating integer not null + constraint level_ratings_rating_check + check ((rating >= 1) AND (rating <= 5)), + created_at timestamp with time zone default now(), + user_id uuid + references public.users, + unique (level_id, user_id) +); + +alter table public.level_ratings + owner to postgres; + +create index idx_ratings_level + on public.level_ratings (level_id); + +grant delete, insert, references, select, trigger, truncate, update on public.level_ratings to anon; + +grant delete, insert, references, select, trigger, truncate, update on public.level_ratings to authenticated; + +grant delete, insert, references, select, trigger, truncate, update on public.level_ratings to service_role; + +create index idx_users_auth0_id + on public.users (auth0_id); + +grant delete, insert, references, select, trigger, truncate, update on public.users to anon; + +grant delete, insert, references, select, trigger, truncate, update on public.users to authenticated; + +grant delete, insert, references, select, trigger, truncate, update on public.users to service_role; +