[accounts] mirror regex from old client for display name

This commit is contained in:
Devin Zuczek
2026-10-13 15:50:52 -04:00
parent 5ba57210b9
commit 4f36146912
5 changed files with 75 additions and 23 deletions
+2 -1
View File
@@ -505,7 +505,8 @@ const app = new Hono<App>()
responses: {
200: json(SuccessResponse, 'Updated'),
400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
description:
'Empty, over 15 characters, outside the display name charset, or profane (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
+6 -5
View File
@@ -2,6 +2,7 @@ import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
displayNameRejection,
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
@@ -181,12 +182,12 @@ export const CreateAccountRequest = z.object({
* sentence that explains it rather than two, and the swear check never sees the
* punctuation the charset rule has already refused.
*/
const nameCheck = (label: string, max: number) =>
const nameCheck = (label: string, rejectionFor: (value: string) => string | null) =>
z
.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
const rejection = rejectionFor(value)
if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) {
@@ -197,15 +198,15 @@ const nameCheck = (label: string, max: number) =>
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
displayName: nameCheck('display name', displayNameRejection)
.min(1)
.describe(
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
`Trimmed; Latin letters, digits, spaces and .,'!?_&()#@:+- only, max ${MAX_DISPLAY_NAME_LENGTH}, no profanity. Empty or invalid is rejected (400)`
),
})
export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH)
username: nameCheck('username', (value) => nameRejection(value, 'username', MAX_USERNAME_LENGTH))
.min(1, 'You must enter a username.')
.describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
+32 -12
View File
@@ -272,7 +272,7 @@ describe('auth-gated endpoints', () => {
...(await bearer('895')),
'Content-Type': 'application/x-www-form-urlencoded',
}
// A name carries no spaces, so the filter has to find the swear at the seam the
// These names carry no spaces, so the filter has to find the swear at the seam the
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
@@ -741,8 +741,9 @@ describe('auth-gated endpoints', () => {
})
})
// The names a player chooses are alphanumeric and length-capped, by the same rule the
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
// The names a player chooses are charset- and length-capped by the rules in @repo/domain:
// a username is alphanumeric (`nameRejection`), a display name takes the wider charset
// the client's own box does (`displayNameRejection`). The limits come
// from the client's own input boxes rather than a round number, so anything stored is
// something the game can render and re-edit.
//
@@ -787,25 +788,44 @@ describe('name, email and bio validation', () => {
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
})
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
test('PUT /account/me/displayname holds a name to the client charset, max 15', async () => {
const headers = await authed('8802')
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
const put = (displayName: string) =>
exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
// The Rec Room build's own rule: ^[\p{Latin}0-9 .,'!?_&()#@:+-]+$
for (const displayName of ['has space', 'punct!', 'café', "O'Neil_Jr.", '(a)&b#1@c:+-,?']) {
expect((await put(displayName)).status, displayName).toBe(200)
}
for (const displayName of [
'semi;colon',
'star*',
'sla/sh',
'Привет',
'名前',
'smile😀',
'a'.repeat(16),
]) {
// An empty 400, matching what this route already answers for an empty name —
// it acks with a bare `{ success: true }` and has never sent the client a body
// on failure.
expect(res.status, displayName).toBe(400)
expect((await put(displayName)).status, displayName).toBe(400)
}
// Trimmed before it is checked, so a name of nothing but spaces is an empty one.
expect((await put(' ')).status).toBe(400)
// A swear set off by the punctuation the charset now allows is still a swear.
for (const displayName of ['shit lord', 'Shit_Lord', 'fuck.you', 'fuck!']) {
expect((await put(displayName)).status, displayName).toBe(400)
}
// 15 is the client's box, so it must fit.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName: 'a'.repeat(15) }),
headers,
})
expect(ok.status).toBe(200)
expect((await put('a'.repeat(15))).status).toBe(200)
})
// Syntax comes from the `isemail` package rather than a pattern written here — this is
+7 -5
View File
@@ -75,11 +75,13 @@ const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
/**
* Whether `value`, read as a NAME, contains a swear.
*
* A username or display name is letters and digits only (`nameRejection`), so it carries
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
* of a space, and the pieces are checked as words.
* A username is letters and digits only (`nameRejection`), so it carries no spaces — and
* the filter matches whole words. Handing one to {@link containsSwears} as-is therefore
* only refuses a name that IS a swear and nothing else: `Fucker123` and `ShitLord` sail
* through. So the name is split at the boundaries a player types instead of a space, and
* the pieces are checked as words. A display name may carry real spaces and punctuation
* (`displayNameRejection`), which the filter already breaks words at; the split still
* applies to the runs in between.
*
* That keeps the library's trade-off rather than reaching for substring matching, which
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
+28
View File
@@ -115,6 +115,34 @@ export function nameRejection(value: string, label: string, max: number): string
return null
}
/**
* The display name charset, taken from the Rec Room build's own check rather than chosen
* here: Latin-script letters (accented ones included), digits, spaces and the punctuation
* listed. Wider than `NAME_PATTERN` because a display name is a label other players read,
* not something typed into a sign-in box — the username stays on the narrow rule.
*
* Matching the client matters in both directions: a narrower rule refuses a name the
* game's own box accepted, and a wider one stores a name the game can't re-enter. The
* dash stays last in the class so it reads as a literal rather than a range.
*/
const DISPLAY_NAME_PATTERN = /^[\p{Script=Latin}0-9 .,'!?_&()#@:+-]+$/u
/**
* Why a player-supplied display name is unacceptable, or `null` when it's fine.
*
* Callers pass the TRIMMED name. Length is counted in code points, since the charset now
* reaches letters outside the basic plane.
*/
export function displayNameRejection(value: string): string | null {
if (glyphLength(value) > MAX_DISPLAY_NAME_LENGTH) {
return `Your display name can be at most ${MAX_DISPLAY_NAME_LENGTH} characters.`
}
if (!DISPLAY_NAME_PATTERN.test(value)) {
return "Your display name can only contain letters, numbers, spaces and .,'!?_&()#@:+-"
}
return null
}
/**
* Letters, digits and underscores — `NAME_PATTERN` plus the one separator a room name is
* allowed. A room name is a label other players read in a browse tile rather than