add subroom accessibility endpoint

This commit is contained in:
Devin Zuczek
2026-07-28 17:22:08 -04:00
parent 8c773da137
commit a28b9b4561
5 changed files with 207 additions and 32 deletions
+10
View File
@@ -73,6 +73,16 @@ inconsistency here without checking the client first.
- Endpoints the client re-renders from must return the updated entity, not - Endpoints the client re-renders from must return the updated entity, not
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old `{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
clubhouse on screen until it answered the full details envelope. clubhouse on screen until it answered the full details envelope.
- Every subroom mutation (`rooms`: create, delete, `/subrooms/:sid/clone`,
`/subrooms/:sid/accessibility`) answers `{ success, error, value }` with the whole
updated ROOM — the client re-renders the room's subroom list from `value`. Notably
`value` is the room even for `clone`, whose product is a new SUBROOM; only the
room-level `POST /rooms/:id/clone` returns the thing it created.
- Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
(Private, Public, Unlisted, Dev_only, Dev_Unlisted); parse via `parseAccessibility`,
which accepts either form.
</client-contract-notes> </client-contract-notes>
<critical-notes> <critical-notes>
+21 -9
View File
@@ -156,7 +156,9 @@ export const SubRoomDto = z.object({
LastModeratedSaveModerationState: z.int(), LastModeratedSaveModerationState: z.int(),
IsSandbox: z.boolean(), IsSandbox: z.boolean(),
MaxPlayers: z.int(), MaxPlayers: z.int(),
Accessibility: z.int().describe('0 = Private, 1 = Public, 2 = Unlisted'), Accessibility: z
.int()
.describe('0 Private, 1 Public, 2 Unlisted, 3 Dev_only, 4 Dev_Unlisted — set independently'),
ShouldAutoStageSaves: z.boolean(), ShouldAutoStageSaves: z.boolean(),
StagedSubRoomDataSaveId: z.int().nullable(), StagedSubRoomDataSaveId: z.int().nullable(),
DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'), DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'),
@@ -324,13 +326,6 @@ export const RoomEnvelope = z.object({
*/ */
export const SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope]) export const SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope])
/** The same envelope carrying a subroom (`POST …/subrooms/{subRoomId}/clone`). */
export const SubRoomEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty on success'),
value: SubRoomDto.nullable(),
})
/** The 401 the envelope-returning routes answer with — the only one that isnt HTTP 200. */ /** The 401 the envelope-returning routes answer with — the only one that isnt HTTP 200. */
export const UNAUTHORIZED_ENVELOPE = json( export const UNAUTHORIZED_ENVELOPE = json(
z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }), z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }),
@@ -406,6 +401,20 @@ export const AccessibilityRequest = z.object({
accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'), accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'),
}) })
/**
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility`. Unlike the room-level route
* above, the client sends the enum NAME here (`accessibility=Private`), so both the name
* and the number are accepted.
*/
export const SubRoomAccessibilityRequest = z.object({
accessibility: z
.string()
.describe(
'A `RoomAccessibility` name — `Private`, `Public`, `Unlisted`, `Dev_only`, ' +
'`Dev_Unlisted` (case-insensitive) — or its ordinal 04'
),
})
/** `POST /rooms/{roomId}/subrooms`. */ /** `POST /rooms/{roomId}/subrooms`. */
export const CreateSubRoomRequest = z.object({ export const CreateSubRoomRequest = z.object({
name: z.string().describe('The new subrooms name'), name: z.string().describe('The new subrooms name'),
@@ -414,7 +423,10 @@ export const CreateSubRoomRequest = z.object({
/** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */ /** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */
export const ModifySubRoomRequest = z.object({ export const ModifySubRoomRequest = z.object({
name: z.string().describe('Required — an empty name is rejected'), name: z.string().describe('Required — an empty name is rejected'),
accessibility: z.string().optional().describe('0 = Private, 1 = Public, 2 = Unlisted'), accessibility: z
.string()
.optional()
.describe('A `RoomAccessibility` name (case-insensitive) or its ordinal 04'),
maxPlayers: z.string().optional().describe('Ignored when not a positive integer'), maxPlayers: z.string().optional().describe('Ignored when not a positive integer'),
}) })
+92 -13
View File
@@ -3,6 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
Accessibility,
canManageRoom, canManageRoom,
cloneRoom, cloneRoom,
cloneSubRoom, cloneSubRoom,
@@ -74,8 +75,8 @@ import {
SaveSubRoomDataRequest, SaveSubRoomDataRequest,
ServiceStatus, ServiceStatus,
stringQuery, stringQuery,
SubRoomAccessibilityRequest,
SubRoomDto, SubRoomDto,
SubRoomEnvelope,
subRoomIdParam, subRoomIdParam,
SubRoomSaveResult, SubRoomSaveResult,
SubRoomSavesPage, SubRoomSavesPage,
@@ -195,6 +196,22 @@ function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401) return c.json({ error: 'Unauthorized' }, 401)
} }
/**
* Parse an `accessibility` form field into a `RoomAccessibility` value. The client
* sends the enum NAME on the subroom route (`accessibility=Private`), not the number
* the room-level route takes, so both forms are accepted. Returns undefined when the
* field is missing or names nothing in the enum.
*/
function parseAccessibility(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined
const raw = value.trim()
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10)
const named = Object.entries(Accessibility).find(
([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase()
)
return named ? (named[1] as number) : undefined
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */ /** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global' const HUB_INSTANCE = 'global'
@@ -1607,16 +1624,14 @@ const app = new Hono<App>()
Error: 'You must enter a name for your room!', Error: 'You must enter a name for your room!',
}) })
} }
const accessibility =
typeof body.accessibility === 'string'
? Number.parseInt(body.accessibility, 10)
: Number.NaN
const maxPlayers = const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
name, name,
accessibility: Number.isNaN(accessibility) ? undefined : accessibility, // Accepts the enum name as well as the ordinal — the dedicated
// `/accessibility` route below is sent names, so this may be too.
accessibility: parseAccessibility(body.accessibility),
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers, maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
}) })
if (!updated) { if (!updated) {
@@ -1632,11 +1647,74 @@ const app = new Hono<App>()
} }
) )
// Set a single subroom's `Accessibility`. Same effect as the `accessibility` field of
// the subroom `modify` call, but this is what the client actually calls when the
// player flips one subroom's visibility, and the body carries the enum NAME
// (`accessibility=Private`), not the number the room-level `/accessibility` takes.
// Auth-gated (401) and owner-only, like the other subroom mutations. Answers the
// updated ROOM in the `{ success, error, value }` envelope — the client re-renders
// the room's subroom list from `value`, the same as subroom create/delete.
.put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/accessibility',
describeRoute({
tags: ['Subrooms'],
summary: 'Set a subrooms accessibility',
description: [
'A subrooms own visibility, independent of the rooms top-level `Accessibility`.',
'The client sends the `RoomAccessibility` NAME here (`accessibility=Private`) rather',
'than the ordinal the room-level route takes, so both forms are accepted; an',
'unrecognised value is rejected. Owner-only — only the rooms creator may change',
'its subrooms, not co-owners.',
'',
'Answers the updated ROOM, not the bare subroom, so the client can re-render the',
'rooms subroom list from `value`.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: form(SubRoomAccessibilityRequest, 'The new accessibility'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_ENVELOPE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
}
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)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
if (room.CreatorAccountId !== accountId) {
return roomEnvelope(c, null, 'You are not the owner of this room!')
}
if (!findSubRoom(room, subRoomId)) {
return roomEnvelope(c, null, 'This subroom does not exist!')
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const accessibility = parseAccessibility(body.accessibility)
if (accessibility === undefined) {
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
}
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { accessibility })
if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
)
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same // Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and // scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
// returns the `{ success, error, value }` envelope with the new subroom as `value`, // returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
// mirroring the room-level `/clone`. Response shape is a best guess (the real // subroom, even though the new subroom is what the call produces. The client
// client's expected body is unknown). // re-renders the room's subroom list from `value`, the same as subroom
// create/delete/accessibility.
.post( .post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone',
describeRoute({ describeRoute({
@@ -1647,13 +1725,14 @@ const app = new Hono<App>()
'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.', 'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.',
'Owner-only.', 'Owner-only.',
'', '',
'The response shape is a best guess: it mirrors the room-level `/clone` envelope, but', 'Answers the updated ROOM, not the new subroom — the client re-renders the rooms',
'the real clients expected body for this call is unknown.', 'subroom list from `value`. Unlike the room-level `/clone`, whose `value` IS the new',
'room, the thing this call creates is not what comes back.',
].join('\n'), ].join('\n'),
security: AUTHED, security: AUTHED,
parameters: [roomIdParam, subRoomIdParam], parameters: [roomIdParam, subRoomIdParam],
responses: { responses: {
200: json(SubRoomEnvelope, 'The new subroom, or a rejection with `success: false`'), 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_ENVELOPE, 401: UNAUTHORIZED_ENVELOPE,
}, },
}), }),
@@ -1676,7 +1755,7 @@ const app = new Hono<App>()
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!') if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, result.room) await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.subRoom) return roomEnvelope(c, result.room)
} }
) )
+77 -9
View File
@@ -1334,17 +1334,69 @@ describe('rooms endpoints', () => {
expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 }) expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 })
}) })
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
// No token → 401.
expect((await putForm(path, { accessibility: 'Private' })).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → failure envelope.
expect(await envOf(await putForm(path, { accessibility: 'Private' }, '999'))).toMatchObject({
success: false,
error: 'You are not the owner of this room!',
})
// Unknown room / unknown subroom → failure envelope.
expect(
await envOf(
await putForm('/rooms/99999/subrooms/2/accessibility', { accessibility: '0' }, '1')
)
).toMatchObject({ success: false })
expect(
await envOf(
await putForm('/rooms/2/subrooms/9999/accessibility', { accessibility: '0' }, '1')
)
).toMatchObject({ success: false })
// A value that names nothing in the enum → rejected, not silently stored.
expect(await envOf(await putForm(path, { accessibility: 'Nonsense' }, '1'))).toMatchObject({
success: false,
error: 'You must provide a valid accessibility!',
})
// The name form is what the live client sends.
const priv = await envOf(await putForm(path, { accessibility: 'Private' }, '1'))
expect(priv.success).toBe(true)
// The envelope carries the updated ROOM, so the client can re-render the subroom list.
expect(priv.value).toMatchObject({ RoomId: 2 })
expect(await accessibilityOf()).toBe(0)
// Case-insensitive, and the later enum members resolve too.
expect((await envOf(await putForm(path, { accessibility: 'dev_unlisted' }, '1'))).success).toBe(
true
)
expect(await accessibilityOf()).toBe(4)
// The ordinal still works.
expect((await envOf(await putForm(path, { accessibility: '1' }, '1'))).success).toBe(true)
expect(await accessibilityOf()).toBe(1)
})
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => { it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
const clone = async (roomId: number, subRoomId: number, sub?: string) => const clone = async (roomId: number, subRoomId: number, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, { SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
method: 'POST', method: 'POST',
headers: sub ? await bearer(sub) : {}, headers: sub ? await bearer(sub) : {},
}) })
type SubRoom = { SubRoomId: number; CreatorAccountId: number }
const envelope = async (res: Response) => const envelope = async (res: Response) =>
(await res.json()) as { (await res.json()) as {
success: boolean success: boolean
error: string error: string
value: { SubRoomId: number; CreatorAccountId: number } | null value: { RoomId: number; SubRooms: SubRoom[] } | null
} }
// No token → 401. // No token → 401.
@@ -1354,17 +1406,28 @@ describe('rooms endpoints', () => {
// Unknown subroom → success:false envelope. // Unknown subroom → success:false envelope.
expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false) expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false)
// Owner clones → success, a fresh SubRoomId owned by the caller, fetchable on the room. const before = new Set(
(
(await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { SubRooms: SubRoom[] }
).SubRooms.map((s) => s.SubRoomId)
)
// Owner clones → success. `value` is the updated ROOM, not the new subroom, so the
// clone shows up as one extra entry in its re-attached SubRooms list.
const res = await clone(2, 2, '1') const res = await clone(2, 2, '1')
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = await envelope(res) const body = await envelope(res)
expect(body.success).toBe(true) expect(body.success).toBe(true)
expect(body.value?.SubRoomId).not.toBe(2) expect(body.value?.RoomId).toBe(2)
expect(body.value?.CreatorAccountId).toBe(1) const added = body.value!.SubRooms.filter((s) => !before.has(s.SubRoomId))
expect(added).toHaveLength(1)
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 ( const fetched = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${body.value?.SubRoomId}/data`) await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${added[0]!.SubRoomId}/data`)
).json()) as { SubRoomId: number } ).json()) as { SubRoomId: number }
expect(fetched.SubRoomId).toBe(body.value?.SubRoomId) expect(fetched.SubRoomId).toBe(added[0]!.SubRoomId)
}) })
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => { it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
@@ -1379,14 +1442,18 @@ describe('rooms endpoints', () => {
method: 'POST', method: 'POST',
headers: await bearer('1'), headers: await bearer('1'),
}) })
const body = (await res.json()) as { value: { SubRoomId: number; RoomId: number } } // `value` is the updated room; the clone is its highest-numbered subroom.
const body = (await res.json()) as {
value: { RoomId: number; SubRooms: Array<{ SubRoomId: number }> }
}
const cloned = Math.max(...body.value.SubRooms.map((s) => s.SubRoomId))
// Above every prior subroom id — a fresh global id, not a per-room collision. // Above every prior subroom id — a fresh global id, not a per-room collision.
expect(body.value.SubRoomId).toBeGreaterThan(maxBefore) expect(cloned).toBeGreaterThan(maxBefore)
expect(body.value.RoomId).toBe(2) expect(body.value.RoomId).toBe(2)
// The id is unique across the whole table (exactly one row owns it). // The id is unique across the whole table (exactly one row owns it).
const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1') const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1')
.bind(body.value.SubRoomId) .bind(cloned)
.first<{ n: number }>())!.n .first<{ n: number }>())!.n
expect(dupes).toBe(1) expect(dupes).toBe(1)
}) })
@@ -1560,6 +1627,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/tags', 'PUT /rooms/{roomId}/tags',
'PUT /rooms/{roomId}/warning', 'PUT /rooms/{roomId}/warning',
+7 -1
View File
@@ -64,11 +64,17 @@ export enum MessageType {
VirtualRoomNotification = 100008, VirtualRoomNotification = 100008,
} }
/** A room's (or image's) visibility, matching the client's `RoomAccessibility`. */ /**
* A room's (or image's) visibility, matching the client's `RoomAccessibility`. The
* client declares the enum without explicit values, so these are its ordinals — and
* it sends the NAME, not the number, on the subroom accessibility route.
*/
export enum Accessibility { export enum Accessibility {
Private = 0, Private = 0,
Public = 1, Public = 1,
Unlisted = 2, Unlisted = 2,
Dev_only = 3,
Dev_Unlisted = 4,
} }
/** /**