validation in some areas, maybe move this to schema later

This commit is contained in:
Devin Zuczek
2026-08-05 12:08:04 -04:00
parent 079c889ccb
commit 6bfd4d9e50
16 changed files with 553 additions and 39 deletions
+17
View File
@@ -31,7 +31,9 @@ import {
getSubRoomPermissions,
getSubRoomSaves,
getVisitedRooms,
MAX_ROOM_NAME_LENGTH,
modifySubRoom,
nameRejection,
publishSubRoomSave,
removeCheer,
removeFavorite,
@@ -1071,6 +1073,9 @@ const app = new Hono<App>()
const name = typeof raw === 'string' ? raw.trim() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
// Shape before availability, so a rejected name costs no D1 read.
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) return roomEnvelope(c, null, badName)
if (await getRoomByName(c.env.DB, name)) {
return roomEnvelope(c, null, 'A room with that name already exists!')
}
@@ -1195,6 +1200,12 @@ const app = new Hono<App>()
Error: 'You must enter a name for your room!',
})
}
// Same ErrorId as the empty case — the client keys off it to mark the field, and
// both are the name being unusable. The sentence is what tells them which.
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) {
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
}
// Reject if a different room already uses this name (case-insensitive).
const existing = await getRoomByName(c.env.DB, name)
@@ -2075,6 +2086,10 @@ const app = new Hono<App>()
Error: 'You must enter a name for your room!',
})
}
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) {
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
}
const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
@@ -2383,6 +2398,8 @@ const app = new Hono<App>()
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) return roomEnvelope(c, null, badName)
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
+82 -4
View File
@@ -2026,7 +2026,7 @@ describe('rooms endpoints', () => {
})
it('PUT /rooms/:id/subrooms/:sid/modify is auth-gated, owner-only, and persists subroom settings', async () => {
const fields = { name: 'My Cool Subroom', accessibility: '1', maxPlayers: '20' }
const fields = { name: 'MyCoolSubroom', accessibility: '1', maxPlayers: '20' }
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → NotOwner.
@@ -2056,7 +2056,7 @@ describe('rooms endpoints', () => {
Accessibility: number
MaxPlayers: number
}
expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 })
expect(sub).toMatchObject({ Name: 'MyCoolSubroom', Accessibility: 1, MaxPlayers: 20 })
})
it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => {
@@ -2499,10 +2499,10 @@ describe('rooms endpoints', () => {
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: 'to-delete' }).toString(),
body: new URLSearchParams({ name: 'ToDelete' }).toString(),
})
).json()) as { value: { SubRooms: SubRoom[] } }
const newId = created.value.SubRooms.find((s) => s.Name === 'to-delete')!.SubRoomId
const newId = created.value.SubRooms.find((s) => s.Name === 'ToDelete')!.SubRoomId
// No token → 401. Not the owner → success:false. Unknown subroom → success:false.
expect((await del(2, newId)).status).toBe(401)
@@ -2656,3 +2656,81 @@ describe('rooms endpoints', () => {
}
})
})
// Room and subroom names are held to the same rule as usernames — letters and digits,
// at most 32 (see `nameRejection` in @repo/domain). All four routes that take a
// player-supplied name enforce it, and each keeps its OWN refusal shape: the create
// paths answer the lowercase `{ success, error, value }` envelope, the two settings
// routes answer `{ Success, ErrorId, Error }` with the same `Rooms.InvalidName` id they
// already used for an empty name. The client keys off those, so the rule had to fit the
// existing shapes rather than introduce a fifth one.
//
// Names the SERVER generates are exempt on purpose — a dorm is `@<username>'s Dorm`,
// which this rule would reject. That's why the check lives in the handlers.
describe('room name validation', () => {
const bad = ['My Room', 'under_score', 'punct!', 'a'.repeat(33)]
const post = async (path: string, fields: Record<string, string>, sub: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
const put = async (path: string, fields: Record<string, string>, sub: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
it('refuses a bad name when a player clones a room into existence', async () => {
for (const name of bad) {
const res = await post('/rooms/2/clone', { name }, '1')
const body = (await res.json()) as { success: boolean; error: string; value: unknown }
expect(body.success, name).toBe(false)
expect(body.error).toMatch(/letters and numbers|at most 32 characters/)
expect(body.value).toBeNull()
}
})
it('refuses a bad name on rename, with the id the client already handles', async () => {
for (const name of bad) {
const res = await put('/rooms/2/name', { name }, '1')
const body = (await res.json()) as { Success: boolean; ErrorId: string; Error: string }
expect(body.Success, name).toBe(false)
expect(body.ErrorId).toBe('Rooms.InvalidName')
expect(body.Error).toMatch(/letters and numbers|at most 32 characters/)
}
// Unchanged: the refusals above never reached the write.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
expect(room.Name).not.toMatch(/[^A-Za-z0-9]/)
})
it('refuses a bad name when creating or modifying a subroom', async () => {
for (const name of bad) {
const created = await post('/rooms/2/subrooms', { name }, '1')
const env1 = (await created.json()) as { success: boolean; error: string }
expect(env1.success, name).toBe(false)
expect(env1.error).toMatch(/letters and numbers|at most 32 characters/)
const modified = await put(
'/rooms/2/subrooms/2/modify',
{ name, accessibility: '1', maxPlayers: '20' },
'1'
)
const res2 = (await modified.json()) as { Success: boolean; ErrorId: string }
expect(res2.Success, name).toBe(false)
expect(res2.ErrorId).toBe('Rooms.InvalidName')
}
})
it('accepts a 32-character alphanumeric name', async () => {
const name = 'a'.repeat(32)
const res = await post('/rooms/2/subrooms', { name }, '1')
const body = (await res.json()) as { success: boolean; value: { SubRooms: Array<{ Name: string }> } }
expect(body.success).toBe(true)
expect(body.value.SubRooms.some((s) => s.Name === name)).toBe(true)
})
})