diff --git a/CLAUDE.md b/CLAUDE.md index 5d2af0a..a564e84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index fcf66bf..e8ba043 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -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 save’s 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(), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 61ecc49..dc4618b 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() } ) + // 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 subroom’s 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 another’s', + '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, diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index e91966c..f6aab4f 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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',