Files
recflare/packages/domain/src/validation.ts
T
2026-08-06 12:40:38 -04:00

206 lines
8.7 KiB
TypeScript

import isEmail from 'isemail'
/**
* Limits on the free text a player can put into their account and their rooms.
*
* Shared by `accounts` and `rooms` so one rule can't drift from the other — a username
* and a room name are held to nearly the same shape (a room name also takes underscores),
* and both are typed into the same client.
*
* These check only what a player SUPPLIES. Names the server generates go around them:
* a dorm is called `@<username>'s Dorm` (see `rooms-db.ts`), which the name rule below
* would reject, and auto-assigned usernames (`SwiftFox4821`, `Player42`) happen to
* satisfy it. So validate at the request handler, never inside the db helpers.
*
* Emptiness is deliberately NOT checked here. Every caller already rejects an empty
* value in its own words, and those sentences reach players through response envelopes
* the client renders verbatim — see the client-contract notes in CLAUDE.md.
*/
/**
* Name lengths. All three come from what the CLIENT will accept in the matching input
* box, not from a round number: accepting more here would store a name the game can't
* re-enter or edit, so the server matches the box rather than being generous.
*/
export const MAX_USERNAME_LENGTH = 50
export const MAX_DISPLAY_NAME_LENGTH = 15
export const MAX_ROOM_NAME_LENGTH = 32
/**
* Club and event limits. Longer than the name limits above because these aren't
* identifiers — a club name and an event name are titles, and both allow the
* punctuation and spaces a title needs (clubs enforce their own charset rule; events
* enforce none at all, since an event is called things like "Building a Better Room
* Using Trigonometry").
*/
export const MAX_CLUB_NAME_LENGTH = 40
export const MAX_CLUB_DESCRIPTION_LENGTH = 512
export const MAX_EVENT_NAME_LENGTH = 64
export const MAX_EVENT_DESCRIPTION_LENGTH = 512
/**
* Invention limits. A name is a title a player types into the invention-save box and
* reads back in a browse tile, so it allows the punctuation a title needs — but
* nothing else, since it is also what invention search matches on. The minimum is real:
* one- and two-character names are unsearchable and unreadable in a tile, and the client
* offers `Untitled` rather than an empty box.
*/
export const MIN_INVENTION_NAME_LENGTH = 3
export const MAX_INVENTION_NAME_LENGTH = 24
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
/**
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
* browse chips are derived from (see `getInventionTagFilters`) — a tag with digits,
* punctuation or spaces makes a chip nobody else will ever type again. Tags are stored
* lowercased, so the rule is checked against the normalized form, not what was typed.
*/
export const MAX_INVENTION_TAG_LENGTH = 15
/**
* Length in code points rather than UTF-16 units, so an emoji or other astral character
* counts once instead of twice — the way a player counts what they typed.
*/
export const glyphLength = (value: string): number => Array.from(value).length
/** Max length of a profile bio. */
export const MAX_BIO_LENGTH = 255
/**
* Letters and digits only — no spaces, punctuation, or accents.
*
* Deliberately narrow: these names are shown to other players, used to search, and (for
* usernames) typed into a sign-in box, so anything that can be confused for another name
* is worth refusing. It also rules out the homoglyph and right-to-left tricks that come
* with allowing arbitrary Unicode.
*/
const NAME_PATTERN = /^[A-Za-z0-9]+$/
/**
* Why a player-supplied name is unacceptable, or `null` when it's fine.
*
* `label` names the thing in the returned sentence ('username', 'room name'), so the
* message reads correctly wherever it's surfaced. `max` is required rather than
* defaulted: the three limits differ, and a caller that forgets which one it wants
* should have to say so instead of silently taking someone else's.
*/
export function nameRejection(value: string, label: string, max: number): string | null {
if (value.length > max) {
return `Your ${label} can be at most ${max} characters.`
}
if (!NAME_PATTERN.test(value)) {
return `Your ${label} can only contain letters and numbers.`
}
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
* something typed into a sign-in box, and the underscore is how players write the space
* the rule still refuses (`Laser_Tag`). It carries none of the homoglyph or
* right-to-left risk that widening to arbitrary Unicode would.
*/
const ROOM_NAME_PATTERN = /^[A-Za-z0-9_]+$/
/**
* Why a player-supplied room or subroom name is unacceptable, or `null` when it's fine.
*
* Separate from `nameRejection` rather than a flag on it: usernames are held to the
* narrower rule, and the two limits differ. `label` names the thing in the returned
* sentence ('room name', 'subroom name'), which the client renders verbatim.
*/
export function roomNameRejection(value: string, label: string): string | null {
if (value.length > MAX_ROOM_NAME_LENGTH) {
return `Your ${label} can be at most ${MAX_ROOM_NAME_LENGTH} characters.`
}
if (!ROOM_NAME_PATTERN.test(value)) {
return `Your ${label} can only contain letters, numbers and underscores.`
}
return null
}
/**
* Letters, digits, spaces, dashes and colons — the title charset. Wider than
* `NAME_PATTERN` because an invention is a thing with a name ("Grappling Hook v2",
* "Speed-Boost Pad"), not an identifier someone types into a sign-in box. Still no
* arbitrary Unicode, for the same homoglyph reasons.
*
* The colon is not decorative: an invention the player never named is called after the
* moment it was saved (`071126 13:10:50`), generated by the CLIENT, so a rule without it
* would refuse every unnamed save the game makes. The dash stays last in the class so it
* reads as a literal rather than a range.
*/
const INVENTION_NAME_PATTERN = /^[A-Za-z0-9 :-]+$/
/** Lowercase letters only — the normalized form a tag is stored in. */
const INVENTION_TAG_PATTERN = /^[a-z]+$/
/**
* Why a player-supplied invention name is unacceptable, or `null` when it's fine.
*
* Callers pass the TRIMMED name: leading and trailing spaces are the player's typing,
* not part of what they named the thing, and counting them toward the minimum would let
* `" a "` through.
*/
export function inventionNameRejection(value: string): string | null {
if (glyphLength(value) < MIN_INVENTION_NAME_LENGTH) {
return `Invention names must be at least ${MIN_INVENTION_NAME_LENGTH} characters.`
}
if (glyphLength(value) > MAX_INVENTION_NAME_LENGTH) {
return `Invention names can be at most ${MAX_INVENTION_NAME_LENGTH} characters.`
}
if (!INVENTION_NAME_PATTERN.test(value)) {
return 'Invention names can only contain letters, numbers, spaces, dashes and colons.'
}
return null
}
/**
* Why an invention description is unacceptable, or `null` when it's fine. Length only —
* a description is prose, so nothing is refused for the characters it's made of, and an
* empty one is fine (it's how a creator clears the field).
*/
export function inventionDescriptionRejection(value: string): string | null {
if (glyphLength(value) > MAX_INVENTION_DESCRIPTION_LENGTH) {
return `Invention descriptions can be at most ${MAX_INVENTION_DESCRIPTION_LENGTH} characters.`
}
return null
}
/**
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
* tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed
* instead would refuse `Racing` for a capital that never reaches the database.
*/
export function inventionTagRejection(value: string): string | null {
if (value.length > MAX_INVENTION_TAG_LENGTH) {
return `Invention tags can be at most ${MAX_INVENTION_TAG_LENGTH} characters.`
}
if (!INVENTION_TAG_PATTERN.test(value)) {
return 'Invention tags can only contain letters.'
}
return null
}
/**
* Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`.
*
* A hand-rolled pattern is the wrong shape of work here: this is a contact address
* nothing is ever sent to in order to prove it, so the only thing a stricter regex buys
* is more edge cases to get wrong. Note it also enforces the RFC's 254-character maximum
* itself, which is why there's no separate length cap.
*
* It accepts a dotless domain (`someone@localhost`), which a dotted-domain rule would
* refuse. That's the RFC being right and the shortcut being wrong, and an undeliverable
* address costs nothing here.
*/
export function isValidEmail(value: string): boolean {
return isEmail.validate(value)
}
/** Whether a supplied bio is within the stored length. */
export function isValidBio(value: string): boolean {
return value.length <= MAX_BIO_LENGTH
}