add missing room save endpoint

This commit is contained in:
Devin Zuczek
2026-08-06 17:44:42 -04:00
parent 51364e482c
commit 368ca252c1
4 changed files with 112 additions and 0 deletions
+3
View File
@@ -102,6 +102,9 @@ inconsistency here without checking the client first.
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
`GET …/saves/:saveId` is the detail behind a list row, under the same creator-only gate,
but in the CAMELCASE projection the room save's response uses — not the PascalCase rows
the list serves. Three shapes of one save; keep them straight.
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
the body carries describes that one revision: `Description` is the save comment shown in
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
+8
View File
@@ -89,6 +89,9 @@ export const roomIdParam = idParam('roomId', 'Room id')
/** The `:subRoomId` path parameter. */
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
/** The `:saveId` path parameter — a `subroom_save` id (globally unique, not per-subroom). */
export const saveIdParam = idParam('saveId', 'The saves id, as `…/saves` lists it')
/** The `:playerId` path parameter (an account id). */
export const playerIdParam = idParam('playerId', 'The account whose list to read')
@@ -159,6 +162,11 @@ export const LoadScreenDto = z.object({
* from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC versions,
* no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`/`dataBlobHash`
* that `CurrentSave` doesn't show). The two are deliberately not unified.
*
* Also what `GET …/subrooms/{subRoomId}/saves/{saveId}` answers — one save fetched by id
* is the same thing the save that created it returned, so both go through
* `toSaveResponse`. Note the `…/saves` LIST is the third shape here: it serves the raw
* PascalCase rows ({@link SubRoomDataSaveDto}), not this.
*/
export const SubRoomDataSaveResponseDto = z.object({
subRoomDataSaveId: z.int(),
+49
View File
@@ -29,6 +29,7 @@ import {
getRoomsByIds,
getSimilarRooms,
getSubRoomPermissions,
getSubRoomSaveById,
getSubRoomSaves,
getVisitedRooms,
modifySubRoom,
@@ -92,10 +93,12 @@ import {
RoomLookup,
RoomResultEnvelope,
RoomSaveEnvelope,
saveIdParam,
SaveSubRoomDataRequest,
ServiceStatus,
stringQuery,
SubRoomAccessibilityRequest,
SubRoomDataSaveResponseDto,
subRoomIdParam,
SubRoomPermissionsRequest,
SubRoomSavesPage,
@@ -1929,6 +1932,52 @@ const app = new Hono<App>()
}
)
// One of a subroom's saves by id — the detail behind a row of the `…/saves` list.
// Same gate as that list (auth-gated, creator-only): a save id resolves whether or not
// it was ever published, so this exposes the same unpublished work the list does.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/:saveId{[0-9]+}',
describeRoute({
tags: ['Subrooms'],
summary: 'One of a subrooms saves by id',
description: [
'A single save, in the SAME camelCase projection the room save that created it',
'returned — not the PascalCase rows `…/saves` lists. Save ids are globally',
'unique but resolved scoped to the subroom, so one subroom cannot read anothers',
'save by guessing an id: a save that belongs elsewhere is a 404, same as an unknown',
'one.',
'',
'Creator-only, like the list it details — a save id resolves whether or not it was',
'ever published, so this reads unpublished work.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam, saveIdParam],
responses: {
200: json(SubRoomDataSaveResponseDto, 'The save'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: { description: 'No such room, subroom, or save on that subroom' },
},
}),
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)
const saveId = Number.parseInt(c.req.param('saveId'), 10)
// Scoped through the room, like the list, so a subroom id from another room can't
// be used to read its saves.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
return save ? c.json(toSaveResponse(save)) : c.notFound()
}
)
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
// by the room creator or a Creator/CoOwner role holder. Points the subroom at
// the uploaded data blobs and records the revision's fields against that SUBROOM,
@@ -2593,6 +2593,57 @@ describe('rooms endpoints', () => {
).toBe(403)
})
it('GET /rooms/:id/subrooms/:sid/saves/:saveId is the detail behind a history row', async () => {
const get = async (path: string, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, sub === undefined ? {} : { headers: await bearer(sub) })
// Pick a real save off the history the previous test paged.
const list = (await (await get('/rooms/2/subrooms/2/saves', '1')).json()) as {
Results: Array<{ SubRoomDataSaveId: number; DataBlob: string; Description: string }>
}
const row = list.Results[0]!
const res = await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '1')
expect(res.status).toBe(200)
// The camelCase projection the room save returns — NOT the PascalCase row the list
// serves. Same field set, exactly: no persistence/OM/UGC versions, no asset arrays.
expect(await res.json()).toEqual({
subRoomDataSaveId: row.SubRoomDataSaveId,
subRoomId: 2,
unityAssetId: null,
unityAsset: null,
unityAssetHash: null,
dataBlob: row.DataBlob,
dataBlobHash: null,
savedByAccountId: expect.any(Number),
savedOnPlatform: 0,
savedOnDeviceClass: 0,
description: row.Description,
createdAt: expect.any(String),
})
// Unknown save, and a save that exists but belongs to ANOTHER subroom (ids are
// global, so an unscoped lookup would happily resolve this one) — both 404.
expect((await get('/rooms/2/subrooms/2/saves/99999', '1')).status).toBe(404)
const foreign = (
(await subRoomOf(5, 5)) as unknown as { CurrentSave: { SubRoomDataSaveId: number } }
).CurrentSave.SubRoomDataSaveId
expect((await get(`/rooms/2/subrooms/2/saves/${foreign}`, '1')).status).toBe(404)
// …and it does resolve on its own subroom, so the 404 above is the scoping, not a
// missing row.
expect((await get(`/rooms/5/subrooms/5/saves/${foreign}`, '1')).status).toBe(200)
// Unknown room or subroom is a 404 too (the LIST answers an empty page instead).
expect((await get('/rooms/99999/subrooms/2/saves/1', '1')).status).toBe(404)
expect((await get('/rooms/2/subrooms/99999/saves/1', '1')).status).toBe(404)
// Same gate as the list it details: 401 unauthed, 403 for a non-creator, and 403
// even for a co-owner — it reads unpublished saves.
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`)).status).toBe(401)
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '999')).status).toBe(403)
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '2')).status).toBe(403)
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -2640,6 +2691,7 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
'GET /roomserver/rooms/createdby/me',
'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone',