Fix ship physics init order and Supabase RLS user sync
All checks were successful
Build / build (push) Successful in 1m47s

- 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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-01 07:00:24 -06:00
parent d3d6175360
commit c87b85de40
6 changed files with 596 additions and 27 deletions

View File

@ -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));

View File

@ -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<void> {
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

View File

@ -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<boolean> {
private async ensureUserProfile(auth0Id: string): Promise<string | null> {
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,

View File

@ -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();

310
supabase/policies.sql Normal file
View File

@ -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;

228
supabase/schema.sql Normal file
View File

@ -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;