Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions __tests__/unit/config/public-directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isFixtureDisplayName,
isFixtureProfile,
isFixtureGroupTitle,
isEmailDerivedHandle,
} from '@/config/public-directory';

describe('public-directory', () => {
Expand Down Expand Up @@ -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);
});
});
64 changes: 32 additions & 32 deletions scripts/db/reset-user-password.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
resetUserPassword();
49 changes: 26 additions & 23 deletions scripts/db/setup-db.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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()
setupDatabase();
16 changes: 13 additions & 3 deletions src/app/api/profiles/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/components/messaging/NewConversationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,11 @@ export default function NewConversationModal({
<div className="font-medium text-fg-primary truncate">
{p.name || p.username || 'User'}
</div>
{p.username && (
{/* Only when the handle adds information beyond the title above —
a profile with no name already shows its handle there, so
repeating "@handle" underneath is pure noise, worse on
narrow screens where both lines truncate. */}
{p.username && p.name && p.name !== p.username && (
<div className="text-sm text-fg-secondary truncate">@{p.username}</div>
)}
</div>
Expand Down
27 changes: 27 additions & 0 deletions src/config/public-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,30 @@ export function isFixtureGroupTitle(title: string | null | undefined): boolean {
const t = (title ?? '').trim();
return FIXTURE_GROUP_TITLE.test(t) || FIXTURE_GROUP_STAMPED.test(t);
}

/**
* True when a profile's public handle still republishes its owner's email
* local part — the leak fixed at the write path in
* supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql
* and backfilled by scripts/rename-email-derived-usernames.sql, but not every
* affected row has been through that backfill yet (it's a deliberate,
* checked-by-hand operation, not something a schema migration runs
* automatically — see that script's own header for why).
*
* Same predicate as the SQL side's `count_email_derived_usernames()`,
* including the `.invalid` exception (RFC 2606 — an undeliverable address has
* no mailbox, so no owner, so no personal information in its local part; see
* 20260828070000_system_accounts_keep_their_handles.sql for the incident that
* taught us to carry the exception here too).
*/
export function isEmailDerivedHandle(profile: {
username?: string | null;
email?: string | null;
}): boolean {
const email = (profile.email ?? '').trim().toLowerCase();
if (!email || email.endsWith('.invalid')) {
return false;
}
const localPart = email.split('@')[0];
return !!localPart && (profile.username ?? '').trim().toLowerCase() === localPart;
}
Loading