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
73 changes: 73 additions & 0 deletions __tests__/unit/profile/neutral-username.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Public handles must not be minted from email addresses.
*
* `/profiles/<username>` is served with no auth and robots.txt has no
* `/profiles` rule. Every profile-creation path used to set the handle to
* `email.split('@')[0]`, which published 77 people's email local parts as
* crawlable identifiers — reported through OrangeCat's own feedback widget.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { neutralUsernameFor } from '@/lib/profile/neutral-username';
import { isValidUsername } from '@/lib/validation/base';

describe('neutralUsernameFor', () => {
const id = '3f2a1b9c-7d4e-4a52-9c8b-0e1f2a3b4c5d';

it('carries nothing about the person', () => {
expect(neutralUsernameFor(id)).toBe('user_3f2a1b9c7d4e');
});

it('is accepted by the app own username rules', () => {
expect(isValidUsername(neutralUsernameFor(id))).toBe(true);
});

it('mirrors the SQL trigger: user_ + 12 hex of the id, dashes stripped', () => {
// supabase/migrations/20260826130000: 'user_' || left(replace(id::text,'-',''), 12)
expect(neutralUsernameFor(id)).toBe(`user_${id.replace(/-/g, '').slice(0, 12)}`);
});

it('differs per user', () => {
expect(neutralUsernameFor(id)).not.toBe(
neutralUsernameFor('99999999-7d4e-4a52-9c8b-0e1f2a3b4c5d')
);
});
});

/**
* The class, closed. Fixing the DB trigger alone was NOT enough — `ensureProfile`
* and two form pre-fills each derived the handle from the email independently,
* and the count rose 72 -> 77 while the trigger fix was being written. A comment
* would not have caught that; this does.
*/
describe('no code derives a public handle from an email', () => {
const SRC = join(process.cwd(), 'src');
const ALLOWED = join('lib', 'profile', 'neutral-username.ts'); // documents the bug in prose

function sourceFiles(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) sourceFiles(full, out);
else if (/\.tsx?$/.test(name)) out.push(full);
}
return out;
}

it('has no site assigning a username from an email local part', () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
if (file.endsWith(ALLOWED)) continue;
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*')) return;
// Same line mentions a handle AND splits an address on '@'.
if (/username/i.test(line) && /split\(\s*['"]@['"]\s*\)/.test(line)) {
offenders.push(`${file}:${i + 1}: ${trimmed}`);
}
});
}
expect(offenders).toEqual([]);
});
});
8 changes: 7 additions & 1 deletion scripts/check-data-invariants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,13 @@ async function checkSilentlyDroppedCatTurns() {
* number RISES, which means a new write path started deriving handles from
* emails again.
*/
const EMAIL_DERIVED_USERNAME_BASELINE = 72; // measured on production 2026-08-26
// Measured on production 2026-08-26 AFTER the trigger fix landed. The first
// reading taken while writing that fix said 72; five accounts signed up in the
// hour between, so a baseline of 72 would have failed on its very first
// nightly run — a gate red about code that is fine, which is the habit this
// ratchet exists to avoid. The number is a floor now rather than a moving
// target: handle_new_user no longer mints these, so nothing can add to it.
const EMAIL_DERIVED_USERNAME_BASELINE = 77;

async function checkEmailDerivedUsernames() {
const count = Number(await rpc('count_email_derived_usernames'));
Expand Down
10 changes: 5 additions & 5 deletions src/components/profile/ProfileWizard/hooks/useProfileWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export function useProfileWizard(
resolver: zodResolver(profileSchema),
mode: 'onChange',
defaultValues: {
username:
profile.username ||
(typeof userEmail === 'string' && userEmail.includes('@')
? userEmail.split('@')[0]
: userEmail || ''),
// No email fallback: username is NOT NULL and always set by a creation
// path, so this only ever fired on a half-loaded profile — and when it
// did it pre-filled the PUBLIC handle field with the user's email local
// part, one Save away from publishing it.
username: profile.username || '',
name: profile.name || '',
bio: profile.bio || '',
location_country: profile.location_country || '',
Expand Down
10 changes: 5 additions & 5 deletions src/components/profile/hooks/useProfileEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ export function useProfileEditor({
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
username:
profile.username ||
(typeof userEmail === 'string' && userEmail.includes('@')
? userEmail.split('@')[0]
: userEmail || ''),
// No email fallback: username is NOT NULL and always set by a creation
// path, so this only ever fired on a half-loaded profile — and when it
// did it pre-filled the PUBLIC handle field with the user's email local
// part, one Save away from publishing it.
username: profile.username || '',
name: profile.name || '',
bio: profile.bio || '',
location_country: profile.location_country || '',
Expand Down
24 changes: 24 additions & 0 deletions src/lib/profile/neutral-username.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* The handle a new account gets before its owner picks one.
*
* It must carry no personal information. `/profiles/<username>` is served with
* no auth and robots.txt has no `/profiles` rule, so whatever goes here is
* published and crawlable. Deriving it from the email address — as every
* creation path used to — put 77 people's email local parts on the public web,
* reconstructable into real addresses with a handful of common domains.
*
* MIRRORS the SQL in
* supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql.
* Profiles are created from two independent places — the `handle_new_user`
* trigger on auth.users, and `ensureProfile()` when a profile is missing — and
* they must not disagree about what a fresh handle looks like. If you change
* the shape here, change it there and vice versa; the invariant gate
* (`count_email_derived_usernames`) catches the email case, not a cosmetic
* drift between the two.
*/

/** `user_` + 12 hex chars of the id. Unique by construction, says nothing about
* the person, and satisfies the app's username pattern (^[a-zA-Z0-9_-]+$). */
export function neutralUsernameFor(userId: string): string {
return `user_${String(userId).replace(/-/g, '').slice(0, 12)}`;
}
16 changes: 8 additions & 8 deletions src/services/profile/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DATABASE_TABLES } from '@/config/database-tables';
import { getTableName } from '@/config/entity-registry';
import { getOrCreateUserActor } from '@/services/actors/getOrCreateUserActor';
import { STATUS } from '@/config/database-constants';
import { neutralUsernameFor } from '@/lib/profile/neutral-username';

type ProfileRow = Database['public']['Tables']['profiles']['Row'];
type ProfileInsert = Database['public']['Tables']['profiles']['Insert'];
Expand Down Expand Up @@ -138,18 +139,17 @@ export class ProfileServerService {

// Profile doesn't exist, create it
const safeEmail = typeof userEmail === 'string' ? userEmail : null;
const emailName = safeEmail && safeEmail.includes('@') ? safeEmail.split('@')[0] : null;
// Sanitize: replace dots and other invalid chars with underscores, keep only letters/numbers/underscores/hyphens
const sanitizedEmailName = emailName ? emailName.replace(/[^a-zA-Z0-9_-]/g, '_') : null;
const username =
sanitizedEmailName && sanitizedEmailName.length > 0
? sanitizedEmailName
: `user_${String(userId).slice(0, 8)}`;
// Never the email local part. This path creates profiles independently of
// the handle_new_user trigger, so fixing only the trigger left this one
// still publishing people's email prefixes as crawlable handles — which
// is what took the count from 72 to 77 while the trigger fix was being
// written. The display name gets no email fallback either: a name quietly
// set to someone's email prefix is the same leak wearing another label.
const username = neutralUsernameFor(userId);
const name =
(userMetadata?.full_name as string | undefined) ||
(userMetadata?.name as string | undefined) ||
(userMetadata?.display_name as string | undefined) ||
(emailName && emailName.length > 0 ? emailName : null) ||
'User';

const profileData: ProfileInsert = {
Expand Down
Loading