[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: { responses: {
200: json(SuccessResponse, 'Updated'), 200: json(SuccessResponse, 'Updated'),
400: { 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, 401: UNAUTHORIZED_RESPONSE,
}, },
+6 -5
View File
@@ -2,6 +2,7 @@ import { resolver } from 'hono-openapi'
import { z } from 'zod' import { z } from 'zod'
import { import {
displayNameRejection,
isValidBio, isValidBio,
isValidEmail, isValidEmail,
MAX_DISPLAY_NAME_LENGTH, 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 * sentence that explains it rather than two, and the swear check never sees the
* punctuation the charset rule has already refused. * punctuation the charset rule has already refused.
*/ */
const nameCheck = (label: string, max: number) => const nameCheck = (label: string, rejectionFor: (value: string) => string | null) =>
z z
.string() .string()
.trim() .trim()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max) const rejection = rejectionFor(value)
if (rejection !== null) { if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection }) ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) { } else if (nameContainsSwears(value)) {
@@ -197,15 +198,15 @@ const nameCheck = (label: string, max: number) =>
}) })
export const DisplayNameRequest = z.object({ export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH) displayName: nameCheck('display name', displayNameRejection)
.min(1) .min(1)
.describe( .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({ 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.') .min(1, 'You must enter a username.')
.describe( .describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain' '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')), ...(await bearer('895')),
'Content-Type': 'application/x-www-form-urlencoded', '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. // player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) { for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, { 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 // The names a player chooses are charset- and length-capped by the rules in @repo/domain:
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come // 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 // from the client's own input boxes rather than a round number, so anything stored is
// something the game can render and re-edit. // 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) 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') const headers = await authed('8802')
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) { const put = (displayName: string) =>
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, { exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }), ...form({ displayName }),
headers, 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 — // 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 // it acks with a bare `{ success: true }` and has never sent the client a body
// on failure. // 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. // 15 is the client's box, so it must fit.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, { expect((await put('a'.repeat(15))).status).toBe(200)
...form({ displayName: 'a'.repeat(15) }),
headers,
})
expect(ok.status).toBe(200)
}) })
// Syntax comes from the `isemail` package rather than a pattern written here — this is // 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. * Whether `value`, read as a NAME, contains a swear.
* *
* A username or display name is letters and digits only (`nameRejection`), so it carries * A username is letters and digits only (`nameRejection`), so it carries no spaces — and
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears} * the filter matches whole words. Handing one to {@link containsSwears} as-is therefore
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and * only refuses a name that IS a swear and nothing else: `Fucker123` and `ShitLord` sail
* `ShitLord` sail through. So the name is split at the boundaries a player types instead * through. So the name is split at the boundaries a player types instead of a space, and
* of a space, and the pieces are checked as words. * 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 * 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`, * 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 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 * 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 * allowed. A room name is a label other players read in a browse tile rather than