From 4f361469122e4ac4e52024ba6bb2212b7a539ff1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 13 Oct 2026 15:50:52 -0400 Subject: [PATCH] [accounts] mirror regex from old client for display name --- apps/accounts/src/accounts.app.ts | 3 +- apps/accounts/src/openapi.ts | 11 ++--- .../accounts/src/test/integration/api.test.ts | 44 ++++++++++++++----- apps/api/src/sanitize.ts | 12 ++--- packages/domain/src/validation.ts | 28 ++++++++++++ 5 files changed, 75 insertions(+), 23 deletions(-) diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index d351336..cf2727e 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -505,7 +505,8 @@ const app = new Hono() 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, }, diff --git a/apps/accounts/src/openapi.ts b/apps/accounts/src/openapi.ts index 474b000..0f394dc 100644 --- a/apps/accounts/src/openapi.ts +++ b/apps/accounts/src/openapi.ts @@ -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' diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 9c438ba..50b8fca 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -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 diff --git a/apps/api/src/sanitize.ts b/apps/api/src/sanitize.ts index 73f4e6f..020078e 100644 --- a/apps/api/src/sanitize.ts +++ b/apps/api/src/sanitize.ts @@ -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`, diff --git a/packages/domain/src/validation.ts b/packages/domain/src/validation.ts index ae62476..723a695 100644 --- a/packages/domain/src/validation.ts +++ b/packages/domain/src/validation.ts @@ -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