From 7f75b201c171d4d30edda6692f3654c3ecdd1894 Mon Sep 17 00:00:00 2001 From: Rob Simmons Date: Fri, 30 Oct 2026 08:23:59 -0400 Subject: [PATCH] feat: auto-renaming of conflicting usernames --- shared/shared.test.ts | 74 +++++++++++++++++++++++++++++++++++++++++- shared/shared.ts | 30 +++++++++++++++++ src/lib/server/auth.ts | 38 ++++++++++++++++++---- 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/shared/shared.test.ts b/shared/shared.test.ts index d7b66c7..a183ceb 100644 --- a/shared/shared.test.ts +++ b/shared/shared.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, test } from 'vitest' -import { bwrapProjectDir, describeValue, leanVersionCompare, toolchainHasModules } from './shared.ts' +import { + bwrapProjectDir, + describeValue, + findSuitableAlphaNumName, + leanVersionCompare, + toolchainHasModules, +} from './shared.ts' describe('leanVersionCompare', () => { it('should throw on non-lean version numbers', () => { @@ -47,3 +53,69 @@ test('describeValue', () => { expect(describeValue([])).toBe('array') expect(describeValue(true)).toBe('boolean') }) + +describe('findSuitableAlphaNumName', () => { + it('should let acceptable identifiers through', async () => { + expect(await findSuitableAlphaNumName('abc', async () => true)).toBe('abc') + expect(await findSuitableAlphaNumName('ab_12_-', async () => true)).toBe('ab_12_-') + expect(await findSuitableAlphaNumName('0x123-', async () => true)).toBe('0x123-') + }) + + it('should add numeric postfixes to collisions', async () => { + expect(await findSuitableAlphaNumName('abc', async c => c !== 'abc')).toBe('abc1') + expect(await findSuitableAlphaNumName('abc', async c => c !== 'abc' && c !== 'abc1')).toBe('abc2') + expect(await findSuitableAlphaNumName('0x123', async c => c !== '0x123')).toBe('0x1') + expect(await findSuitableAlphaNumName('123test', async c => c !== '123test')).toBe('123test1') + }) + + it('should truncate unacceptable characters', async () => { + expect(await findSuitableAlphaNumName('abc@example.com', async () => true)).toBe('abc') + }) + + it('should anonymize candidates that start with unacceptable characters', async () => { + expect(await findSuitableAlphaNumName('../root', async () => true)).toBe('anon') + expect(await findSuitableAlphaNumName('../root', async c => c !== 'anon')).toBe('anon1') + expect(await findSuitableAlphaNumName('../root', async c => c.length >= 6)).toBe('anon10') + }) + + it('should truncate overlong names', async () => { + expect( + await findSuitableAlphaNumName( + 'a23456789012345678902234567890323456789042345678905234567890623456789072345678908234567890923456789xxx', + async () => true, + ), + ).toBe('a23456789012345678902234567890323456789042345678905234567890623456789072345678908234567890923456789x') + }) + + it('should fill in gaps between names', async () => { + expect( + await findSuitableAlphaNumName('fred3', async a => ['fred', 'fred1', 'fred3', 'fred4'].every(b => a !== b)), + ).toBe('fred2') + }) + + it('should accept non-conflicting numeric identifiers', async () => { + expect(await findSuitableAlphaNumName('123', async () => true)).toBe('123') + expect(await findSuitableAlphaNumName('0xabc', async () => true)).toBe('0xabc') + }) + + it('should turn conflicting numeric identifiers into "anon" identifiers', async () => { + expect(await findSuitableAlphaNumName('123', async c => c !== '123' && c !== 'anon')).toBe('anon1') + }) + + it('should trim long names to keep them under 100 characters', async () => { + expect( + await findSuitableAlphaNumName( + 'a234567890123456789022345678903234567890423456789052345678906234567890723456789082345678909234abcde', + async c => c.endsWith('100'), + ), + ).toBe('a234567890123456789022345678903234567890423456789052345678906234567890723456789082345678909234abc100') + }) + + it('should fall back to the "anon" prefix for a test that rejects most inputs', async () => { + expect(await findSuitableAlphaNumName('test', async c => !/^test/.test(c))).toMatch(/^anon/) + }) + + it('should throw if there are no suitable names', async () => { + await expect(findSuitableAlphaNumName('test', async () => false)).rejects.toThrow() + }) +}) diff --git a/shared/shared.ts b/shared/shared.ts index 0f1e3fd..5865e17 100644 --- a/shared/shared.ts +++ b/shared/shared.ts @@ -208,3 +208,33 @@ export type BaseProject = z.output /** Filesystem location of the unix domain socket that connects the next.js server to the shard manager */ export const SHARD_MANAGER_SOCK = '/tmp/lean-workbench/shard-manager.sock' + +/** + * Given a candidate string (e.g. a username) and a test to see if a string is ok, + * return a string matching `ALPHANUM_NAME_RE` and starting with a non-numeric character. + */ +export async function findSuitableAlphaNumName(candidate: string, isOk: (str: string) => Promise) { + const candidateAlphaNum = candidate.match(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}/)?.[0] ?? 'anon' + + // Try to just accept this candidate + if (await isOk(candidateAlphaNum)) { + return candidateAlphaNum + } + + // Remove any numeric postfix, and try successive numeric postfixes + // An entirely numeric id will become "anon" + let candidatePrefix = candidateAlphaNum.match(/^([a-zA-Z0-9_-]*[^0-9])[0-9]*$/)?.[1] ?? 'anon' + for (let i = 1; ; i++) { + if (`${candidatePrefix}${i}`.length > 100) { + candidatePrefix = candidatePrefix.slice(0, -1) + } + const newCandidate = `${candidatePrefix}${i}` + if (await isOk(newCandidate)) return newCandidate + + // Protect against non-termination due to an isOk test that's overbroad + if (i > 1_000) candidatePrefix = 'anon' + + // Protect harder + if (i > 10_000) throw new Error('findSuitableAlphaNumName() unable to find an acceptable candidate') + } +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 7614d24..12b71c5 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,6 +1,6 @@ import 'server-only' -import { adminEmail, devModePassword, zUserName } from '@leanprover/workbench-shared' +import { adminEmail, devModePassword, findSuitableAlphaNumName } from '@leanprover/workbench-shared' import { isDevMode } from '@leanprover/workbench-shared/node' import { betterAuth, generateId, type SocialProviders } from 'better-auth' import { prismaAdapter } from 'better-auth/adapters/prisma' @@ -33,6 +33,35 @@ async function ensureAdminUserExists() { } } +/** + * Usernames that might conflict with route components, or that we choose to disallow for other reasons. + */ +const FORBIDDEN_USERNAMES = new Set([ + 'admin', // route conflict between /admin and /[userName] + 'api', // route conflict between /api and /[userName] + 'components', // potential route conflict between /components (if a page.tsx ends up in that directory) and `/[userName]` + 'dev', // reserve for future use + 'error', // reserve for future use + 'lean', // reserve for future use + 'lean4', // reserve for future use + 'pub', // potential future conflict with .example.com-style subdomains + 'profile', // potential future route conflict between /profile /[userName] + 'settings', // potential future route conflict between /settings and /[userName] + 'setup', // route conflict between /setup and /[userName] + 'view', // reserve for future use + 'www', // potential future conflict with .example.com-style subdomains +]) + +/** Returns true if a candidate name is not in the forbidden usernames list and is not a currently registered username. */ +async function isAvailableUsername(candidate: string) { + const candidateLower = candidate.toLowerCase() + if (FORBIDDEN_USERNAMES.has(candidateLower)) return false + // potential future route conflict with .example.com-style subdomains + if (/^shard\d+$/.test(candidateLower)) return false + if (/^worker\d+$/.test(candidateLower)) return false + return null === (await getDb().user.findUnique({ where: { name: candidate } })) +} + async function createAuth() { const config = getConfig() const socialProviders: SocialProviders = {} @@ -113,16 +142,13 @@ async function createAuth() { user: { create: { before: async user => { - const parsed = zUserName.safeParse(user.name) - if (!parsed.success) return false - const name = parsed.data if (getConfig().registrationMode === 'restricted') { const allowed = await getDb().allowedGithubUser.findUnique({ - where: { githubUsername: name }, + where: { githubUsername: user.name }, }) if (!allowed) return false } - return { data: { ...user, name } } + return { data: { ...user, name: await findSuitableAlphaNumName(user.name, isAvailableUsername) } } }, }, },