update auth for subroom paths

This commit is contained in:
Devin Zuczek
2026-07-28 23:28:25 -04:00
parent 9c8bf7087c
commit 6a910b27bd
3 changed files with 82 additions and 74 deletions
+16 -31
View File
@@ -79,7 +79,6 @@ import {
ServiceStatus,
stringQuery,
SubRoomAccessibilityRequest,
SubRoomDto,
subRoomIdParam,
SubRoomSavesPage,
TagRequest,
@@ -1422,37 +1421,10 @@ const app = new Hono<App>()
}
)
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
// array). Public — the client fetches it while loading the room. 404 when the
// room or subroom is unknown.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
describeRoute({
tags: ['Subrooms'],
summary: 'A subrooms data descriptor',
description: [
'The `SubRoom` object from the rooms `SubRooms` array — the descriptor the client',
'fetches while loading the room, carrying the scene id and the saved-data blob keys.',
'Public; an unknown room or subroom is a 404.',
].join(' '),
parameters: [roomIdParam, subRoomIdParam],
responses: {
200: json(SubRoomDto, 'The subroom'),
404: { description: 'No such room or subroom' },
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
const sub = room ? findSubRoom(room, subRoomId) : undefined
return sub ? c.json(sub) : c.notFound()
}
)
// A subroom's saved-data versions — the room-history / "restore a save" list. Every
// save is its own `subroom_save` row (nothing is overwritten), so this is real
// history, newest first, paged by skip/take.
// history, newest first, paged by skip/take. Auth-gated (401) and creator-only (403):
// the list exposes unpublished saves, which only the owner is entitled to see.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
describeRoute({
@@ -1464,9 +1436,14 @@ const app = new Hono<App>()
'only when the subroom has never been saved.',
'`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
'',
'Owner-only (403 otherwise) — the list includes STAGED saves that were never',
'published, so it is not public. It is what the client reads to offer the owner',
'“load the latest or the published version?” when they enter a private instance.',
'',
'`TotalResults` and `TotalCount` carry the same number: the clients paged DTO and',
'the reference disagree on the name, so both are emitted.',
].join(' '),
security: AUTHED,
parameters: [
roomIdParam,
subRoomIdParam,
@@ -1475,9 +1452,16 @@ const app = new Hono<App>()
stringQuery('skip', 'How many saves to skip (default 0)'),
stringQuery('take', 'How many saves to return (default all)'),
],
responses: { 200: json(SubRoomSavesPage, 'The subrooms saves, newest first') },
responses: {
200: json(SubRoomSavesPage, 'The subrooms saves, newest first'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't read its saves.
@@ -1485,6 +1469,7 @@ const app = new Hono<App>()
if (!room || !findSubRoom(room, subRoomId)) {
return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
}
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
+63 -42
View File
@@ -583,6 +583,18 @@ describe('rooms endpoints', () => {
type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null }
const envOf = async (res: Response) => (await res.json()) as RoomEnv
// A subroom as the client sees it. There is no GET for a single subroom — the client
// reads them off the room — so tests do the same.
const subRoomOf = async (
roomId: number,
subRoomId: number
): Promise<Record<string, unknown> | undefined> => {
const res = await SELF.fetch(`${ORIGIN}/rooms/${roomId}`)
if (res.status !== 200) return undefined
const room = (await res.json()) as { SubRooms?: Array<Record<string, unknown>> }
return (room.SubRooms ?? []).find((s) => s.SubRoomId === subRoomId)
}
it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401)
@@ -929,16 +941,11 @@ describe('rooms endpoints', () => {
expect(pub.value?.Accessibility).toBe(1)
})
it('GET /rooms/:id/subrooms/:sid/data returns the subroom descriptor (404 when unknown)', async () => {
// Room 2 has SubRoomId 2 in the seed.
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)
expect(res.status).toBe(200)
expect((await res.json()) as { SubRoomId: number }).toMatchObject({ SubRoomId: 2 })
// Unknown subroom → 404.
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/9999/data`)).status).toBe(404)
// Unknown room → 404.
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/subrooms/2/data`)).status).toBe(404)
it('there is no GET for a single subroom — only the room carries them', async () => {
// The real API has no `GET …/subrooms/{id}/data`; the client reads subrooms off the
// room. Only the POST (the room save) exists on that path, and it is auth-gated.
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).status).toBe(404)
expect(await subRoomOf(2, 2)).toMatchObject({ SubRoomId: 2 })
})
it('POST /rooms/:id/subrooms/:sid/data is auth-gated, owner-only, and saves the blobs', async () => {
@@ -1003,8 +1010,8 @@ describe('rooms endpoints', () => {
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
})
// It also persists — the GET returns the subroom with the save live.
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
// It also persists — reading the room back shows the save live.
const sub = (await subRoomOf(2, 2)) as unknown as {
SubRoomId: number
CreatorAccountId: number
CurrentSave: {
@@ -1073,8 +1080,7 @@ describe('rooms endpoints', () => {
CurrentSave: Record<string, unknown> | null
StagedSubRoomDataSaveId: number | null
}
const subOf = async () =>
(await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`)).json()) as Sub
const subOf = async () => (await subRoomOf(5, 5)) as unknown as Sub
const publish = async (saveId: number, sub = '1') =>
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/publish_save`, {
method: 'POST',
@@ -1140,7 +1146,9 @@ describe('rooms endpoints', () => {
expect(afterSecond.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId })
// Both saves are in the history, newest first — the first one is not lost.
const history = (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`)).json()) as {
const history = (await (
await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`, { headers: await bearer('1') })
).json()) as {
Results: Array<{ DataBlob: string; Description: string }>
TotalResults: number
}
@@ -1162,8 +1170,7 @@ describe('rooms endpoints', () => {
// A save id from a different subroom is rejected, even though ids are global.
const foreign = (await (
await publish(
((await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as Sub).CurrentSave!
.SubRoomDataSaveId as number
((await subRoomOf(2, 2)) as unknown as Sub).CurrentSave!.SubRoomDataSaveId as number
)
).json()) as { success: boolean; error: string }
expect(foreign.success).toBe(false)
@@ -1199,7 +1206,7 @@ describe('rooms endpoints', () => {
],
})
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/820/subrooms/830/data`)).json()) as {
const sub = (await subRoomOf(820, 830)) as unknown as {
CurrentSave: Record<string, unknown>
}
expect(sub.CurrentSave).toMatchObject({
@@ -1212,7 +1219,7 @@ describe('rooms endpoints', () => {
Tags: [],
})
// Stable across reads — it's a stored row now, not something rebuilt per request.
const again = (await (await SELF.fetch(`${ORIGIN}/rooms/820/subrooms/830/data`)).json()) as {
const again = (await subRoomOf(820, 830)) as unknown as {
CurrentSave: { SubRoomDataSaveId: number }
}
expect(again.CurrentSave.SubRoomDataSaveId).toBe(sub.CurrentSave.SubRoomDataSaveId)
@@ -1229,7 +1236,7 @@ describe('rooms endpoints', () => {
})
expect(res.status).toBe(200)
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/1/subrooms/1/data`)).json()) as {
const sub = (await subRoomOf(1, 1)) as unknown as {
CurrentSave: { DataBlob: string } | null
StagedSubRoomDataSaveId: number | null
}
@@ -1247,10 +1254,9 @@ describe('rooms endpoints', () => {
body: JSON.stringify({ SubRoomData: { Filename: `blob-${subRoomId}.room` } }),
})
// Non-dorm saves stage, so the fresh id lands on StagedSubRoomDataSaveId.
const idOf = async (roomId: number, subRoomId: number) => {
const res = await SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`)
return ((await res.json()) as { StagedSubRoomDataSaveId: number }).StagedSubRoomDataSaveId
}
const idOf = async (roomId: number, subRoomId: number) =>
((await subRoomOf(roomId, subRoomId)) as unknown as { StagedSubRoomDataSaveId: number })
.StagedSubRoomDataSaveId
// Two different subrooms, each getting their FIRST save.
await save(6, 6)
@@ -1586,7 +1592,7 @@ describe('rooms endpoints', () => {
const ok = await putForm('/rooms/2/subrooms/2/modify', fields, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
const sub = (await subRoomOf(2, 2)) as unknown as {
Name: string
Accessibility: number
MaxPlayers: number
@@ -1597,11 +1603,7 @@ describe('rooms endpoints', () => {
it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => {
const path = '/rooms/2/subrooms/2/accessibility'
const accessibilityOf = async () =>
(
(await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
Accessibility: number
}
).Accessibility
((await subRoomOf(2, 2)) as unknown as { Accessibility: number }).Accessibility
// No token → 401.
expect((await putForm(path, { accessibility: 'Private' })).status).toBe(401)
@@ -1684,10 +1686,9 @@ describe('rooms endpoints', () => {
expect(added[0]!.CreatorAccountId).toBe(1)
// A fresh id, and fetchable as a subroom of the room.
expect(added[0]!.SubRoomId).not.toBe(2)
const fetched = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${added[0]!.SubRoomId}/data`)
).json()) as { SubRoomId: number }
expect(fetched.SubRoomId).toBe(added[0]!.SubRoomId)
expect(await subRoomOf(2, added[0]!.SubRoomId)).toMatchObject({
SubRoomId: added[0]!.SubRoomId,
})
})
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
@@ -1758,9 +1759,11 @@ describe('rooms endpoints', () => {
expect(created).toMatchObject({ RoomId: 2, Name: 'ffff', CreatorAccountId: 1 })
expect(created!.SubRoomId).toBeGreaterThan(maxBefore)
const fetched = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${created?.SubRoomId}/data`)
).json()) as { SubRoomId: number; Name: string; UnitySceneId: string }
const fetched = (await subRoomOf(2, created!.SubRoomId)) as unknown as {
SubRoomId: number
Name: string
UnitySceneId: string
}
expect(fetched).toMatchObject({ SubRoomId: created?.SubRoomId, Name: 'ffff' })
// It inherits room 2's own existing (first) subroom scene.
@@ -1803,7 +1806,7 @@ describe('rooms endpoints', () => {
const body = await envelope(await del(2, newId, '1'))
expect(body.success).toBe(true)
expect(body.value?.SubRooms.some((s) => s.SubRoomId === newId)).toBe(false)
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${newId}/data`)).status).toBe(404)
expect(await subRoomOf(2, newId)).toBeUndefined()
// A room's only subroom can't be deleted (would leave it with no scene). Seed a
// dedicated single-subroom room owned by account 1 to exercise the guard.
@@ -1815,7 +1818,7 @@ describe('rooms endpoints', () => {
})
expect((await envelope(await del(700, 900, '1'))).success).toBe(false)
// The lone subroom survives the refused delete.
expect((await SELF.fetch(`${ORIGIN}/rooms/700/subrooms/900/data`)).status).toBe(200)
expect(await subRoomOf(700, 900)).toBeDefined()
})
it('GET /rooms/:id/subrooms/:sid/saves pages the save history, newest first', async () => {
@@ -1825,7 +1828,11 @@ describe('rooms endpoints', () => {
TotalCount: number
}
const page = async (query: string) =>
(await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves${query}`)).json()) as Page
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves${query}`, {
headers: await bearer('1'),
})
).json()) as Page
// Room 2's subroom is saved several times by the tests above — each save appended.
const all = await page('?unityAssetTarget=0&unityAssetVersion=1')
@@ -1845,8 +1852,23 @@ describe('rooms endpoints', () => {
expect(paged.TotalResults).toBe(all.TotalResults)
// A never-saved subroom pages empty rather than 404ing.
const empty = await SELF.fetch(`${ORIGIN}/rooms/3/subrooms/3/saves`)
const empty = await SELF.fetch(`${ORIGIN}/rooms/3/subrooms/3/saves`, {
headers: await bearer('1'),
})
expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 })
// The list exposes unpublished saves, so it is owner-only: no token → 401, and a
// valid token that isn't the room's creator → 403.
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`)).status).toBe(401)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(403)
// Even a co-owner (account 2 holds Role 30 on the seeded rooms) is refused.
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('2') }))
.status
).toBe(403)
})
it('GET /openapi.json documents every route', async () => {
@@ -1892,7 +1914,6 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/data',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /roomserver/photon_access_token',
'GET /roomserver/rooms/createdby/me',