DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A team name is an identity, a recovery code and a display label, and the only thing we validate is the length

Nobody signs in to play a quiz at PubTrivia. Players scan the code on their table, a web page opens, they type a name, and they are in. No account, no app, no email. A hundred strangers in a pub, zero credentials between them.

Which means the name they type is doing three unrelated jobs at once, and I did not notice that until the third one bit.

Job one: validation, and there is almost none

export function validatePlayerName(
    name: string,
): { valid: true } | { valid: false; error: 'invalid_name' } {
    const trimmed = name.trim()
    if (trimmed.length >= 1 && trimmed.length <= 30) {
        return { valid: true }
    }
    return { valid: false, error: GameError.INVALID_NAME }
}
Enter fullscreen mode Exit fullscreen mode

That is the entire rule. Trim, then between 1 and 30 characters. No character class, no word list, no profanity filter.

The 30 is a layout number, not a safety number. Team names get read out loud and they go on a leaderboard that has to be legible from the other side of a room, and a name that does not fit the row is a worse problem than a name that is too short. There is no minimum beyond "not empty" because a table that wants to be called "7" has earned that.

What is deliberately absent is a filter. A pub quiz team will call itself something rude, that is most of the fun, and the host already has the only enforcement mechanism that works in a room: knowing whose table it is. Shipping a block list would mean maintaining a block list, in every language a tourist might bring in, forever, to solve a problem the person holding the microphone solves in four words.

Just above the validator in the join action there is a second length constant that is doing a genuinely defensive job, and the contrast is the point:

/** Longest answer text accepted. Options are short; this only bounds abuse. */
const MAX_ANSWER_LENGTH = 500
Enter fullscreen mode Exit fullscreen mode

One number is about a leaderboard row. The other is about somebody with curl.

Job two: identity, enforced in application code

Names are unique per quiz, case insensitively, so the leaderboard never shows two of the same thing and nobody can pass themselves off as somebody else. The normalisation happens once, in the action:

const name = playerName.trim().replace(/\s+/g, ' ')
const sameName = (other: string) => other.toLowerCase() === name.toLowerCase()
Enter fullscreen mode Exit fullscreen mode

Leading and trailing whitespace goes, and runs of internal whitespace collapse to one space, so "The Quiz Team" and "The Quiz Team" are the same entry rather than two rows that look identical on screen. That second rule is the one people forget, and a leaderboard is exactly where it shows.

The uniqueness itself is not a database constraint. There is no unique index on (session_id, lower(player_name)). It is one select, inside a transaction, behind an advisory lock:

await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join:${sessionId}`}, 0))`)

const clashes = await tx
    .select({ ... })
    .from(tableParticipantsTable)
    .where(
        and(
            eq(tableParticipantsTable.session_id, sessionId),
            or(
                eq(tableParticipantsTable.table_id, tableId),
                sql`lower(${tableParticipantsTable.player_name}) = lower(${name})`,
            ),
        ),
    )
Enter fullscreen mode Exit fullscreen mode

One query fetches everybody this join could possibly collide with: the same table, or the same name, in either mode. The lock is there because the check is read-then-write, and two phones at the same table pressing Join in the same second both used to pass it.

It is worth being clear about what this does and does not catch, because "unique, case insensitive" sounds stronger than it is. There is no Unicode normalisation, so a name typed in NFC and the same name in NFD are two rows. There is no confusable folding, so Cyrillic "а" is not Latin "a". Zero-width characters are not stripped, and a name that is one zero-width space is 1 character and therefore valid. None of that matters for the leaderboard, and all of it matters for the next job.

Job three: the recovery code

Phones die. A phone that was holding a team's place in team mode is a real problem, because team mode gives one shared score to the whole table and the table has already joined. So re-typing the team name at the table's own code gives the place back:

if (config.team_mode) {
    const team = clashes.find((p) => p.table_id === tableId)
    if (team) {
        if (sameName(team.player_name)) {
            return { participantId: team.id, playerName: team.player_name, reclaimed: true }
        }
        return GameError.TABLE_ALREADY_JOINED
    }
}
Enter fullscreen mode Exit fullscreen mode

The error message that goes with the second branch spells the rule out, because a player staring at a borrowed phone needs to be told exactly what to type:

This table has already joined. To rejoin on a new phone, enter your team name exactly as before.
Enter fullscreen mode Exit fullscreen mode

So the name is a bearer credential. Knowing it, plus being able to scan the code on that table, hands you that table's participant row and its score.

Written down like that it sounds alarming, and then you remember where the system is. The attacker has to be in the room, at that table, with the card in front of them, typing the name of the team sitting there, while that team watches. The threat model of a pub quiz is not a threat model. The thing that made this design correct is the same thing that makes a paper answer sheet correct: the room is the authentication.

The usual case does not go near any of it. The device remembers:

const storageKey = (tableId: string) => `pub-trivia:player:${tableId}`
Enter fullscreen mode Exit fullscreen mode

A refresh, a crashed tab, or iOS evicting the page while the phone was locked restores { sessionId, participantId, playerName } from localStorage and resumes, keyed per table so one phone can legitimately hold a place at two tables. Every read and write of it is wrapped in try, because private mode throws on access and a player in private mode should get a working game that merely cannot resume. The name-based reclaim only runs when the storage is gone with the phone.

Four refusals, four sentences

The join action can say no in four ways, and each error code maps to one sentence a stranger can act on:

case 'invalid_name':
    return `Please enter a ${teamMode ? 'team name' : 'name'} between 1 and 30 characters.`
case 'name_taken':
    return `That ${teamMode ? 'team name' : 'name'} is already taken in this quiz. Try another.`
case 'late_join_disabled':
    return 'The quiz has started and late joining is disabled. Wait for the next round.'
case 'table_already_joined':
    return 'This table has already joined. To rejoin on a new phone, enter your team name exactly as before.'
Enter fullscreen mode Exit fullscreen mode

The mode-dependent wording is not decoration. In team mode the thing being rejected belongs to four people who chose it together, and "that name is taken" versus "that team name is taken" is the difference between a player retyping and a player assuming the app is broken.

The part I would generalise

The useful question is not "how do I validate a name". It is: how many jobs is this field doing?

Ours does three, and they want opposite things. As a label it wants to be short, loud and rude. As an identity it wants to be unique and normalised. As a recovery code it wants to be unguessable, which is the one property a name chosen to be shouted across a pub can never have.

We resolved that by letting the room carry the security and keeping the field cheerful. That is a correct answer for a quiz night and an obviously wrong one for anything with money in it. The mistake is not picking either, it is not noticing the field had been quietly promoted from label to credential and still being validated like a label.

Top comments (0)