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 room’s 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 subroom’s 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, 3 Dev_only, 4 Dev_Unlisted — set independently'), 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 room’s 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`) — 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 401 the envelope-returning routes answer with — the only one that isn’t 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 room’s 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'), }) /** * `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 0–4' ), }) /** `POST /rooms/{roomId}/subrooms`. */ export const CreateSubRoomRequest = z.object({ name: z.string().describe('The new subroom’s 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('A `RoomAccessibility` name (case-insensitive) or its ordinal 0–4'), 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 subroom’s `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 caller’s current instance, from presence; null when they’re 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'), })