rooms openapi

This commit is contained in:
Devin Zuczek
2026-07-24 21:32:43 -04:00
parent 27c45792b8
commit 460839458c
7 changed files with 2082 additions and 604 deletions
+480
View File
@@ -0,0 +1,480 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the rooms worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/match/econ/clubs workers: a reverse-engineered
* protocol, lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* The 401 the auth-gated routes return. Most answer `{ error: 'Unauthorized' }`; the
* subroom writes answer an empty body (see UNAUTHORIZED_EMPTY / UNAUTHORIZED_ENVELOPE).
*/
export const UNAUTHORIZED_RESPONSE = json(
z.object({ error: z.literal('Unauthorized') }),
'Missing or invalid bearer token'
)
/** The empty-body 401 the subroom-save route returns. */
export const UNAUTHORIZED_EMPTY = { description: 'Missing or invalid bearer token (empty body)' }
/** The 403 the owner/co-owner-gated routes return (empty body). */
export const FORBIDDEN_RESPONSE = {
description: 'A valid token, but not the rooms creator or a co-owner (empty body)',
}
// ---- Parameters ------------------------------------------------------------
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
return {
name,
in: 'path',
required: true,
description,
schema: { type: 'string', pattern: '^[0-9]+$' },
}
}
/** The `:roomId` path parameter. */
export const roomIdParam = idParam('roomId', 'Room id')
/** The `:subRoomId` path parameter. */
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
/** An optional string query parameter. */
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
}
/** An optional integer query parameter. */
function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
}
/** The `skip`/`take` pair every paginated room list accepts. */
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
return [
intQuery('skip', 'How many rooms to skip (default 0)'),
intQuery('take', `How many rooms to return (default ${defaultTake})`),
]
}
// ---- Core entities ---------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('rooms'),
status: z.literal('ok'),
})
/** A room-role assignment. `Role`: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator. */
export const RoomRoleDto = z.object({
AccountId: z.int(),
Role: z.int().describe('10 = Host, 20 = Moderator, 30 = CoOwner, 255 = Creator'),
LastChangedByAccountId: z.int().nullable(),
InvitedRole: z.int(),
})
/** A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`). */
export const RoomTagDto = z.object({
Tag: z.string(),
Type: z.int().describe('0 = owner-set, 2 = auto'),
})
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
export const RoomStatsDto = z.object({
CheerCount: z.int(),
FavoriteCount: z.int(),
VisitorCount: z.int(),
VisitCount: z.int(),
})
/** One of the images shown while the room loads. */
export const LoadScreenDto = z.object({
ImageName: z.string().describe('A CDN bucket key under `room/`'),
Title: z.string(),
Subtitle: z.string(),
})
/**
* A subroom — a room's individual scene. Subrooms are their own table with a globally
* unique, autoincrementing `SubRoomId` (the original game mints them from a single
* sequence, not per-room); a room's `SubRooms` array is reconstructed on read.
*
* `CreatorAccountId` starts null on the seeded rooms and is filled in on the first save —
* the client NREs on a null one. The `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields only
* appear once the subroom has been saved at least once.
*/
export const SubRoomDto = z.object({
SubRoomId: z.int(),
RoomId: z.int(),
CreatorAccountId: z.int().nullable().describe('Null until the subrooms first save'),
UnitySceneId: z.string().describe('The Unity scene the client loads'),
Name: z.string(),
LastModeratedSaveModerationState: z.int(),
IsSandbox: z.boolean(),
MaxPlayers: z.int(),
Accessibility: z.int().describe('0 = Private, 1 = Public, 2 = Unlisted'),
ShouldAutoStageSaves: z.boolean(),
StagedSubRoomDataSaveId: z.int().nullable(),
DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'),
RoomDataBlob: z.string().optional().describe('Uploaded room-data key; absent until first save'),
DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'),
PersistenceVersion: z.int().optional(),
})
/** A room's localization settings — carried through verbatim; nothing localizes yet. */
export const LocalizationContextDto = z.object({
TargetLocale: z.string().nullable(),
Scope: z.string().nullable(),
LocalizedFields: z.array(z.string()),
})
/**
* A room, exactly as stored: each room is a single JSON blob in D1 and every read
* serves it verbatim (PascalCase, the client-facing shape), with `SubRooms` re-attached
* from the subroom table. The seed data comes from `static/ImportRooms.json`.
*/
export const RoomDto = z.object({
RoomId: z.int(),
Name: z.string().describe('Unique, case-insensitively'),
Description: z.string(),
ImageName: z.string().describe('A CDN bucket key served back under `room/`'),
WarningMask: z.int().describe('Content-warning bit flags'),
CustomWarning: z.string().nullable(),
CreatorAccountId: z.int().describe('The rooms owner — always passes the role checks'),
State: z.int(),
Accessibility: z
.int()
.describe('0 = Private, 1 = Public, 2 = Unlisted. The room-browse feeds serve Public only'),
PublishState: z.int(),
SupportsLevelVoting: z.boolean(),
IsRRO: z.boolean().describe('A Rec Room Original — the client renders a virtual `rro` tag'),
IsRecRoomApproved: z.boolean(),
ExcludeFromLists: z.boolean(),
ExcludeFromSearch: z.boolean(),
SupportsScreens: z.boolean(),
SupportsWalkVR: z.boolean(),
SupportsTeleportVR: z.boolean(),
SupportsVRLow: z.boolean(),
SupportsQuest2: z.boolean(),
SupportsMobile: z.boolean(),
SupportsJuniors: z.boolean(),
MinLevel: z.int(),
AgeRating: z.int(),
CreatedAt: z.string(),
PublishedAt: z.string(),
BecameRRStudioRoomAt: z.string().nullable(),
Stats: RoomStatsDto,
RankingContext: z.unknown().nullable(),
IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'),
IsPlacePlay: z.boolean(),
MaxPlayerCalculationMode: z.int(),
MaxPlayers: z.int(),
CloningAllowed: z.boolean().describe('False blocks `POST /rooms/{roomId}/clone`'),
DisableMicAutoMute: z.boolean(),
DisableRoomComments: z.boolean(),
EncryptVoiceChat: z.boolean(),
ToxmodEnabled: z.boolean(),
LoadScreenLocked: z.boolean(),
UgcVersion: z.int(),
PersistenceVersion: z.int(),
UgcSubVersion: z.int().nullable(),
MinUgcSubVersion: z.int().nullable(),
AutoLocalizeRoom: z.boolean(),
LocalizationContext: LocalizationContextDto,
IsDeveloperOwned: z.boolean(),
RankedEntityId: z.string(),
SubRooms: z.array(SubRoomDto).describe('Re-attached from the subroom table on every read'),
Roles: z.array(RoomRoleDto),
IsJuniorCreated: z.boolean(),
Tags: z.array(RoomTagDto),
PromoImages: z.array(z.unknown()),
PromoExternalContent: z.array(z.unknown()),
LoadScreens: z.array(LoadScreenDto),
RestrictedCircuitsAllowListNames: z.array(z.string()),
InventionUsage: z.string().optional().describe('Recorded by a room save; absent until then'),
})
/** A paged room list (`PagedResultsDTO<RoomDTO>`) — search, hot, similar. */
export const PagedRooms = z.object({
Results: z.array(RoomDto),
TotalResults: z.int().describe('The full match count, not the page size'),
})
/**
* A room lookup result: the room, or `{}` when nothing matched. The by-id/by-name
* lookups answer an empty object rather than a 404 — the client reads that as "no room".
*/
export const RoomLookup = z.union([RoomDto, z.object({})])
/** The bare JSON string the lookup routes answer with when neither `id` nor `name` is given. */
export const MissingLookupParam = z
.string()
.describe("`\"Either 'id' or 'name' query parameter is required\"`")
// ---- Interaction -----------------------------------------------------------
/**
* A player's own state on a room: whether they've cheered/favorited it, plus the last
* visit. `LastVisitedAt` is stamped with "now" on every read rather than served from the
* stored value — the client only uses it to order the recently-visited list.
*/
export const InteractionDto = z.object({
Cheered: z.boolean(),
Favorited: z.boolean(),
LastVisitedAt: z.string().describe('Always "now" — not the stored visit time'),
})
// ---- Featured rooms --------------------------------------------------------
/** The compact room projection a featured-room group carries. */
export const FeaturedRoomDto = z.object({
RoomId: z.int(),
RoomName: z.string(),
ImageName: z.string(),
IsRecRoomApproved: z.boolean(),
ExcludeFromLists: z.boolean(),
ExcludeFromSearch: z.boolean(),
})
/** A time-boxed group of featured rooms. There's one, and it's always active. */
export const FeaturedRoomGroupDto = z.object({
FeaturedRoomGroupId: z.int(),
name: z.string(),
StartAt: z.string(),
EndAt: z.string(),
Rooms: z.array(FeaturedRoomDto).describe('Randomly ordered — no editorial curation yet'),
})
// ---- Envelopes -------------------------------------------------------------
//
// The room writes answer one of two envelopes, both at HTTP 200 — the client reads the
// success flag, not the status. Which one a route uses is not ours to choose: it's what
// the client's deserializer for that call expects, so the two live side by side.
/**
* The PascalCase result envelope (`Results.Ok(new RoomResult{...})`): a bare
* success/failure with a message, carrying no entity. `ErrorId` is a stable code the
* client may branch on; `Error` is the text it shows.
*/
export const RoomResultEnvelope = z.object({
Success: z.boolean(),
Value: z.unknown().nullable().describe('Always null — these routes carry no entity'),
ErrorId: z
.string()
.nullable()
.describe('e.g. `Rooms.DoesntExist`, `Rooms.NotOwner`; null on success'),
Error: z.string().nullable().describe('The message shown to the player; null on success'),
})
/** The lowercase envelope carrying the updated room — the client re-renders from `value`. */
export const RoomEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty on success'),
value: RoomDto.nullable(),
})
/**
* What a room save answers: the saved subroom on success (no envelope — the client
* deserializes the body directly as the subroom), or the PascalCase result envelope when
* the room or subroom doesn't exist. Both at HTTP 200.
*/
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. */
export const UNAUTHORIZED_ENVELOPE = json(
z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }),
'Missing or invalid bearer token'
)
// ---- Request bodies --------------------------------------------------------
/** `POST /rooms/{roomId}/clone` — also accepted as a `?name=` query param. */
export const CloneRoomRequest = z.object({
name: z.string().describe('The new rooms name; must be unique'),
})
/** `PUT /rooms/{roomId}/description`. */
export const DescriptionRequest = z.object({
description: z.string().describe('An absent field clears the description'),
})
/** `PUT /rooms/{roomId}/name`. */
export const NameRequest = z.object({
name: z.string().describe('Non-empty, and not already taken by another room'),
})
/** `PUT /rooms/{roomId}/tags` — a toggle, not a set. */
export const TagRequest = z.object({
tag: z.string().describe('Added when absent, removed when present'),
})
/** `PUT /rooms/{roomId}/image`. */
export const ImageRequest = z.object({
imageName: z.string().describe('A key from the storage upload, stored un-prefixed'),
})
/** `PUT /rooms/{roomId}/roles/{accountId}`. */
export const RoleRequest = z.object({
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
})
/** `PUT /rooms/{roomId}/warning`. */
export const WarningRequest = z.object({
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
customWarning: z.string().optional().describe('Set when present; an empty value clears it'),
})
/** `PUT /rooms/{roomId}/cloning`. */
export const CloningRequest = z.object({
cloningAllowed: z.string().describe('`True` / `False`'),
})
/**
* `PUT /rooms/{roomId}/restrictions` — the room's platform/movement support flags. Only
* the fields actually posted are changed, and the names are matched case-insensitively.
*/
export const RestrictionsRequest = z.object({
supportsScreens: z.string().optional().describe('`True` / `False`'),
supportsWalkVR: z.string().optional().describe('`True` / `False`'),
supportsTeleportVR: z.string().optional().describe('`True` / `False`'),
supportsVRLow: z.string().optional().describe('`True` / `False`'),
supportsQuest2: z.string().optional().describe('`True` / `False`'),
supportsMobile: z.string().optional().describe('`True` / `False`'),
supportsJuniors: z.string().optional().describe('`True` / `False`'),
})
/** `PUT /rooms/{roomId}/loadscreen` — appends one screen to the list. */
export const LoadScreenRequest = z.object({
imageName: z.string().describe('A key from the storage upload'),
title: z.string().optional(),
subtitle: z.string().optional(),
})
/** `PUT /rooms/{roomId}/accessibility`. */
export const AccessibilityRequest = z.object({
accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'),
})
/** `POST /rooms/{roomId}/subrooms`. */
export const CreateSubRoomRequest = z.object({
name: z.string().describe('The new subrooms name'),
})
/** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */
export const ModifySubRoomRequest = z.object({
name: z.string().describe('Required — an empty name is rejected'),
accessibility: z.string().optional().describe('0 = Private, 1 = Public, 2 = Unlisted'),
maxPlayers: z.string().optional().describe('Ignored when not a positive integer'),
})
/**
* `POST /rooms/{roomId}/subrooms/{subRoomId}/data` — the room save. The blobs are
* uploaded to the CDN through the `storage` worker first; this call points the subroom
* at them. Every field is optional: a save that carries only `SubRoomData` still stamps
* the save time.
*/
export const SaveSubRoomDataRequest = z.object({
SubRoomData: z
.object({ Filename: z.string() })
.optional()
.describe('The uploaded scene-data blob — becomes the subrooms `DataBlob`'),
RoomData: z
.object({ Filename: z.string() })
.optional()
.describe('The uploaded room-level data blob — becomes `RoomDataBlob`'),
Description: z.string().optional().describe('Written to the ROOM, not the subroom'),
PersistenceVersion: z.int().optional(),
InventionUsage: z.string().optional().describe('Written to the room'),
})
/**
* `GET /rooms/{roomId}/subrooms/{subRoomId}/saves` — the room-history page. We keep no
* save history (a save overwrites the subroom's blob inline), so it's always empty.
*/
export const SubRoomSavesPage = z.object({
Results: z.array(z.unknown()).describe('Always empty — no save history is kept'),
TotalResults: z.int(),
})
// ---- Session ---------------------------------------------------------------
/** One entry of the permission table the client applies when it spawns into a room. */
export const RoomPermissionDto = z.object({
Override: z.boolean(),
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
Type: z.int(),
Value: z.string().describe('Always `True` — a permission is present or absent'),
})
/**
* The permissions + Photon credentials the client needs to spawn into a room.
*
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The
* global (Role 0) maker pen is granted only to the hardcoded dev accounts.
*/
export const PhotonAccessTokenDto = z.object({
Permissions: z.array(RoomPermissionDto),
PhotonAccessToken: z.string().describe('Always empty — see above'),
RoomInstanceId: z
.int()
.nullable()
.describe('The callers current instance, from presence; null when theyre in none'),
})
/** `GET /rooms/{roomId}/playerdata/me` — per-room player data. Nothing stores any yet. */
export const PlayerDataDto = z.object({
Data: z.string().describe('Always empty — no per-room player data is stored'),
})