diff --git a/__tests__/unit/config/public-directory.test.ts b/__tests__/unit/config/public-directory.test.ts index 91afc069d..a7d0a1ef5 100644 --- a/__tests__/unit/config/public-directory.test.ts +++ b/__tests__/unit/config/public-directory.test.ts @@ -3,6 +3,7 @@ import { isFixtureDisplayName, isFixtureProfile, isFixtureGroupTitle, + isEmailDerivedHandle, } from '@/config/public-directory'; describe('public-directory', () => { @@ -48,4 +49,36 @@ describe('public-directory', () => { expect(isFixtureProfile({ username: 'adelina1996gry', name: 'E2E Reset User' })).toBe(true); expect(isFixtureProfile({ username: 'adelina1996gry', name: 'Adelina' })).toBe(false); }); + + // Reported from the New Message picker's default suggestion list: an + // account not yet through scripts/rename-email-derived-usernames.sql + // still shows its owner's email local part as a public, crawlable handle. + it('flags a handle that still republishes its owner email', () => { + expect( + isEmailDerivedHandle({ + username: 'georgy.butaev+ocauth1', + email: 'georgy.butaev+ocauth1@gmail.com', + }) + ).toBe(true); + expect(isEmailDerivedHandle({ username: 'mao', email: 'georgy.butaev@orangecat.ch' })).toBe( + false + ); + }); + + it('is case-insensitive, since email and username casing can drift independently', () => { + expect(isEmailDerivedHandle({ username: 'Mao', email: 'MAO@orangecat.ch' })).toBe(true); + }); + + // .invalid (RFC 2606) has no mailbox, so no owner and no personal + // information in its local part โ the exception that stopped a rename + // script from retiring the Cat's own handle. See + // 20260828070000_system_accounts_keep_their_handles.sql. + it('never flags a system account on a .invalid address', () => { + expect(isEmailDerivedHandle({ username: 'cat', email: 'cat@orangecat.invalid' })).toBe(false); + }); + + it('is false with nothing to compare', () => { + expect(isEmailDerivedHandle({ username: 'mao', email: null })).toBe(false); + expect(isEmailDerivedHandle({ username: null, email: 'mao@orangecat.ch' })).toBe(false); + }); }); diff --git a/scripts/db/reset-user-password.js b/scripts/db/reset-user-password.js index a44c4a31a..363f1d61b 100644 --- a/scripts/db/reset-user-password.js +++ b/scripts/db/reset-user-password.js @@ -13,99 +13,99 @@ if (!supabaseUrl || !supabaseServiceKey) { const supabase = createClient(supabaseUrl, supabaseServiceKey, { auth: { autoRefreshToken: false, - persistSession: false - } + persistSession: false, + }, }); async function resetUserPassword() { const email = 'test@orangecat.ch'; const newPassword = 'TestPassword123!'; - + try { console.log('๐ง Resetting user password...'); - + // Method 1: Update user password using admin API const { data: user, error: updateError } = await supabase.auth.admin.updateUserById( 'e954df8f-6a9b-4cb1-8c3f-8518dc78f846', { password: newPassword } ); - + if (updateError) { console.error('โ Error updating password:', updateError.message); - + // Method 2: Delete and recreate user console.log('๐ Trying to delete and recreate user...'); - + // Delete user const { error: deleteError } = await supabase.auth.admin.deleteUser( 'e954df8f-6a9b-4cb1-8c3f-8518dc78f846' ); - + if (deleteError && !deleteError.message.includes('User not found')) { console.error('โ Error deleting user:', deleteError.message); } - + // Create new user const { data: newUser, error: createError } = await supabase.auth.admin.createUser({ email: email, password: newPassword, - email_confirm: true + email_confirm: true, }); - + if (createError) { console.error('โ Error creating user:', createError.message); return; } - + console.log('โ User recreated successfully'); console.log('User ID:', newUser.user.id); - + // Create/update profile - const { error: profileError } = await supabase - .from('profiles') - .upsert({ - id: newUser.user.id, - username: email, - full_name: 'Test User', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }); - + const { error: profileError } = await supabase.from('profiles').upsert({ + id: newUser.user.id, + // Never the raw email โ a public, crawlable handle republishing + // someone's address is the exact leak fixed in + // supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql. + // Mirrors src/lib/profile/neutral-username.ts (plain JS script, can't import it). + username: 'user_' + newUser.user.id.replace(/-/g, '').slice(0, 12), + full_name: 'Test User', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + if (profileError) { console.error('โ Error creating profile:', profileError.message); } else { console.log('โ Profile created successfully'); } - } else { console.log('โ Password updated successfully'); console.log('User ID:', user.user.id); } - + // Test authentication console.log('๐งช Testing authentication...'); - + // Create a new client instance for testing const testClient = createClient(supabaseUrl, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY); - + const { data: signInData, error: signInError } = await testClient.auth.signInWithPassword({ email: email, - password: newPassword + password: newPassword, }); - + if (signInError) { console.error('โ Authentication test failed:', signInError.message); } else { console.log('โ Authentication test successful!'); console.log('Authenticated user:', signInData.user.email); - + // Sign out to clean up await testClient.auth.signOut(); } - } catch (error) { console.error('โ Unexpected error:', error.message); } } -resetUserPassword(); \ No newline at end of file +resetUserPassword(); diff --git a/scripts/db/setup-db.ts b/scripts/db/setup-db.ts index 59e53e95d..27de94f2f 100644 --- a/scripts/db/setup-db.ts +++ b/scripts/db/setup-db.ts @@ -1,10 +1,11 @@ -import 'dotenv/config' -import { createClient } from '@supabase/supabase-js' +import 'dotenv/config'; +import { createClient } from '@supabase/supabase-js'; +import { neutralUsernameFor } from '@/lib/profile/neutral-username'; -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! -const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; -const supabase = createClient(supabaseUrl, supabaseKey) +const supabase = createClient(supabaseUrl, supabaseKey); async function setupDatabase() { try { @@ -13,33 +14,35 @@ async function setupDatabase() { // Sign in const { error: signInError } = await supabase.auth.signInWithPassword({ email: 'butaeff@gmail.com', - password: process.env.USER_PASSWORD || 'your-password' - }) - if (signInError) throw signInError - if (process.env.NODE_ENV === 'development') console.log('โ Signed in') + password: process.env.USER_PASSWORD || 'your-password', + }); + if (signInError) throw signInError; + if (process.env.NODE_ENV === 'development') console.log('โ Signed in'); // Create initial profile - const { data: { user }, error: userError } = await supabase.auth.getUser() - if (userError) throw userError + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + if (userError) throw userError; if (user) { - const { error: profileError } = await supabase - .from('profiles') - .upsert({ - id: user.id, - username: user.email?.split('@')[0] || 'user', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }) - if (profileError) throw profileError + const { error: profileError } = await supabase.from('profiles').upsert({ + id: user.id, + // Never the email local part โ see src/lib/profile/neutral-username.ts. + username: neutralUsernameFor(user.id), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + if (profileError) throw profileError; // REMOVED: console.log statement } // REMOVED: console.log statement } catch (error) { - console.error('Error setting up database:', error) - process.exit(1) + console.error('Error setting up database:', error); + process.exit(1); } } -setupDatabase() \ No newline at end of file +setupDatabase(); diff --git a/src/app/api/profiles/route.ts b/src/app/api/profiles/route.ts index ee5e1cbc2..4c9e2fe2d 100644 --- a/src/app/api/profiles/route.ts +++ b/src/app/api/profiles/route.ts @@ -15,7 +15,7 @@ export const GET = withAuth(async (request: AuthenticatedRequest) => { let query = supabase .from(DATABASE_TABLES.PROFILES) .select( - `id, username, name, bio, avatar_url, bitcoin_address, lightning_address, created_at, updated_at`, + `id, username, name, bio, avatar_url, bitcoin_address, lightning_address, created_at, updated_at, email`, { count: 'exact' } ) .order('created_at', { ascending: false }) @@ -38,8 +38,18 @@ export const GET = withAuth(async (request: AuthenticatedRequest) => { throw error; } - const { isFixtureProfile } = await import('@/config/public-directory'); - const profiles = (data || []).filter(p => !isFixtureProfile(p)); + const { isFixtureProfile, isEmailDerivedHandle } = await import('@/config/public-directory'); + const rows = (data || []).filter(p => !isFixtureProfile(p)); + // Defense in depth for accounts not yet through the (manual, deliberate โ + // see scripts/rename-email-derived-usernames.sql) backfill: keep them out + // of the unprompted default suggestion list, the surface this was + // reported from. An explicit search still finds them โ hiding a real + // person from someone typing their exact name would be an availability + // regression, not a privacy fix. + const filtered = search ? rows : rows.filter(p => !isEmailDerivedHandle(p)); + // Never ship the email column to the client โ it was only selected for + // the filter above. + const profiles = filtered.map(({ email: _email, ...rest }) => rest); return apiSuccessPaginated(profiles, page, limit, count ?? profiles.length); } catch (error) { return handleApiError(error); diff --git a/src/components/messaging/NewConversationModal.tsx b/src/components/messaging/NewConversationModal.tsx index 3d7fd1d66..6380f620c 100644 --- a/src/components/messaging/NewConversationModal.tsx +++ b/src/components/messaging/NewConversationModal.tsx @@ -249,7 +249,11 @@ export default function NewConversationModal({