[accounts] profanity filter on user/display name

This commit is contained in:
Devin Zuczek
2026-09-09 11:02:31 -04:00
parent 438475e326
commit 261ff21e0d
6 changed files with 120 additions and 6 deletions
+5 -2
View File
@@ -448,7 +448,9 @@ const app = new Hono<App>()
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -475,7 +477,8 @@ const app = new Hono<App>()
tags: ['Profile'],
summary: 'Change username',
description: [
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'Letters and digits only, at most 50 characters, and free of profanity (the same',
'word list as `api`s `POST /api/sanitize/v1/isPure`). Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).',
+27 -4
View File
@@ -9,6 +9,11 @@ import {
nameRejection,
} from '@repo/domain'
// The profanity filter behind `api`'s `POST /api/sanitize/v1/isPure`, imported rather
// than copied so a name is held to the very same word list every other player-typed
// string is.
import { nameContainsSwears } from '../../api/src/sanitize'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
@@ -158,26 +163,44 @@ export const CreateAccountRequest = z.object({
* writes the player-facing sentence and there's no reason to write it twice.
*/
/** Zod check that defers to the shared name rule, message and all. */
/**
* Zod check that defers to the shared name rule, message and all, and then refuses a name
* with a swear in it — the same filter, and the same word list, as `api`'s
* `POST /api/sanitize/v1/isPure`.
*
* Shape first, profanity second: a name that already broke the charset rule gets the one
* 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) =>
z
.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) {
// Deliberately vague about WHICH word: naming it back to the player prints the
// swear in the UI, and the player knows what they typed.
ctx.addIssue({ code: 'custom', message: `Your ${label} can't contain that word.` })
}
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
.describe(
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
),
})
export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH)
.min(1, 'You must enter a username.')
.describe('Trimmed; letters and digits only, max 50. Must be unique and changes must remain'),
.describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
),
})
export const EmailRequest = z.object({
@@ -223,6 +223,32 @@ describe('auth-gated endpoints', () => {
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
})
test('PUT /account/me/displayname 400s on a name with a swear in it', async () => {
const headers = {
...(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
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(400)
}
// And the words that merely contain one still get through — refusing these is worse
// than missing a swear, because the player can't see why.
for (const displayName of ['Scunthorpe', 'ClassicCar', 'Cumberland']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(200)
}
})
test('PUT /account/me/username 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'whoever' }),
@@ -244,6 +270,30 @@ describe('auth-gated endpoints', () => {
expect(body.value).toBe('')
})
test('PUT /account/me/username refuses a swear without spending a change', async () => {
const headers = {
...(await bearer('894')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'ShitLord' }),
headers,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
// Vague on purpose — the message must not print the swear back at the player.
expect(body.error).toMatch(/can't contain that word/i)
expect(body.value).toBe('')
// The schema runs before the handler, so a refused name costs none of the account's
// rationed changes.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(3)
})
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
const headers = {
...(await bearer('892')),