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
+31 -1
View File
@@ -1,6 +1,36 @@
# rooms
A Cloudflare Workers application using Hono
Room Worker served on the `rooms` subdomain (`rooms.recflare.net`) — a Hono app owning
room storage, the browse/search feeds, per-player cheers and favorites, the owner's room
settings, and subrooms.
Rooms live in the shared `recflare` D1 as one JSON blob per room (queryable fields are
SQLite generated columns); reads serve that blob verbatim, which is why every shape is
the client's PascalCase one. Subrooms are their own table — their ids come from a single
global sequence, not per room — and are re-attached to each room on read. The seed rooms,
including the dorm, come from `static/ImportRooms.json`.
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`. It's also aggregated into
the docs page www serves at `/docs`.
**The spec is descriptive, not enforced** — same rationale as the `auth`/`match`/`clubs`
workers: a reverse-engineered protocol, lenient handlers, no runtime validation. A test
asserts every route appears in the spec, so adding one without documenting it fails.
## Response envelopes
Two envelopes appear side by side, and which one a route uses is dictated by the client's
deserializer for that call — not a choice, and not something to unify:
- `{ Success, Value, ErrorId, Error }` (PascalCase) — a bare result with a message.
- `{ success, error, value }` (lowercase) — carries the updated room, which the client
re-renders from.
Both answer HTTP 200 even for a rejection: the client reads the flag, not the status.
Only a missing or invalid token is a real 401, and only the owner/co-owner gate is a 403.
## Development
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+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'),
})
+1470 -601
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
@@ -38,9 +39,53 @@ import {
toggleRoomTag,
updateRoomFields,
} from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AccessibilityRequest,
AUTHED,
CloneRoomRequest,
CloningRequest,
CreateSubRoomRequest,
DescriptionRequest,
FeaturedRoomGroupDto,
FORBIDDEN_RESPONSE,
form,
ImageRequest,
InteractionDto,
json,
jsonBody,
LoadScreenRequest,
MissingLookupParam,
ModifySubRoomRequest,
NameRequest,
PagedRooms,
pageParams,
PhotonAccessTokenDto,
PlayerDataDto,
RestrictionsRequest,
RoleRequest,
RoomDto,
RoomEnvelope,
roomIdParam,
RoomLookup,
RoomResultEnvelope,
SaveSubRoomDataRequest,
ServiceStatus,
stringQuery,
SubRoomDto,
SubRoomEnvelope,
subRoomIdParam,
SubRoomSaveResult,
SubRoomSavesPage,
TagRequest,
UNAUTHORIZED_EMPTY,
UNAUTHORIZED_ENVELOPE,
UNAUTHORIZED_RESPONSE,
WarningRequest,
} from './openapi'
import type { Context } from 'hono'
import type { App } from './context'
@@ -245,63 +290,152 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'rooms', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "rooms", status: "ok" }`') },
}),
(c) => c.json({ service: 'rooms', status: 'ok' })
)
// Room lookup by `id` (first match wins) or `name`. 400s when neither is
// supplied and returns `{}` when nothing matches.
.get('/rooms', async (c) => {
const idParam = c.req.query('id')
const nameParam = c.req.query('name')
if (!idParam && !nameParam) {
return c.json("Either 'id' or 'name' query parameter is required", 400)
}
if (idParam) {
const id = firstId(idParam)
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
.get(
'/rooms',
describeRoute({
tags: ['Rooms'],
summary: 'Look up a room by id or name',
description: [
'A single room by `id` or `name`. `id` may be a comma-separated list — the first',
'valid integer wins. An unknown room is `{}`, not a 404: the client reads an empty',
'object as “no such room”.',
].join(' '),
parameters: [
stringQuery('id', 'Room id (comma-separated; the first valid one is used)'),
stringQuery('name', 'Room name (matched case-insensitively). Ignored when `id` is given'),
],
responses: {
200: json(RoomLookup, 'The room, or `{}` when theres no match'),
400: json(MissingLookupParam, 'Neither `id` nor `name` was supplied'),
},
}),
async (c) => {
const idParam = c.req.query('id')
const nameParam = c.req.query('name')
if (!idParam && !nameParam) {
return c.json("Either 'id' or 'name' query parameter is required", 400)
}
if (idParam) {
const id = firstId(idParam)
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
return c.json(room ?? {})
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ?? {})
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ?? {})
})
)
// Room search: `query` is space/`+`-separated terms — `#tag` matches room tags,
// plain terms match the name. Public, non-dorm rooms only. Paginated via
// skip/take. Returns `{ Results, TotalResults }`.
.get('/rooms/search', async (c) => {
const query = c.req.query('query') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
return c.json(await searchRooms(c.env.DB, query, skip, take))
})
.get(
'/rooms/search',
describeRoute({
tags: ['Discovery'],
summary: 'Search rooms',
description: [
'Full room search. `query` is space- or `+`-separated terms: a `#tag` term matches the',
'rooms tags, a plain term matches its name. Public, non-dorm rooms only.',
].join(' '),
parameters: [
stringQuery('query', 'Search terms — `#tag` matches tags, plain terms match the name'),
...pageParams(30),
],
responses: { 200: json(PagedRooms, 'The matching rooms') },
}),
async (c) => {
const query = c.req.query('query') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
return c.json(await searchRooms(c.env.DB, query, skip, take))
}
)
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
// defaults to 100). Returns `{ Results, TotalResults }` like search.
.get('/rooms/hot', async (c) => {
const tag = c.req.query('tag') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getHotRooms(c.env.DB, tag, skip, take))
})
.get(
'/rooms/hot',
describeRoute({
tags: ['Discovery'],
summary: 'The “hot” rooms feed',
description: [
'Public, non-dorm rooms ordered by engagement, optionally narrowed to a single `tag`',
'(the browse screens filter chips post one, e.g. `rro`).',
].join(' '),
parameters: [stringQuery('tag', 'Restrict to rooms carrying this tag'), ...pageParams(100)],
responses: { 200: json(PagedRooms, 'The feed page') },
}),
async (c) => {
const tag = c.req.query('tag') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getHotRooms(c.env.DB, tag, skip, take))
}
)
// "Base" rooms — template rooms (tagged `base`) the client offers when creating
// a room. Returned regardless of accessibility. Paginated via skip/take (take
// defaults to 100). Returns a bare array.
.get('/rooms/base', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getBaseRooms(c.env.DB, skip, take))
})
.get(
'/rooms/base',
describeRoute({
tags: ['Discovery'],
summary: 'Base (template) rooms',
description: [
'The template rooms — those tagged `base` — the client offers when a player creates a',
'room. Served regardless of accessibility, and as a bare array rather than a page.',
].join(' '),
parameters: pageParams(100),
responses: { 200: json(RoomDto.array(), 'The template rooms') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getBaseRooms(c.env.DB, skip, take))
}
)
// Recommended rooms feed — public, non-dorm rooms ranked by engagement, returned
// as a bare array (the client's recommendation room-source expects a plain list).
// The `splitTestId`/`splitTestValue` A/B params are accepted and ignored.
// Paginated via skip/take (take defaults to 100).
.get('/rooms/recommendations', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getRecommendedRooms(c.env.DB, skip, take))
})
.get(
'/rooms/recommendations',
describeRoute({
tags: ['Discovery'],
summary: 'Recommended rooms',
description: [
'Public, non-dorm rooms ranked by engagement. Unlike search and hot this is a BARE',
'array — the clients recommendation room-source expects a plain list. The',
'`splitTestId`/`splitTestValue` A/B params are accepted and ignored.',
].join(' '),
parameters: [
stringQuery('splitTestId', 'A/B test id — accepted and ignored'),
stringQuery('splitTestValue', 'A/B test bucket — accepted and ignored'),
...pageParams(100),
],
responses: { 200: json(RoomDto.array(), 'The recommended rooms') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getRecommendedRooms(c.env.DB, skip, take))
}
)
// Featured rooms — a single always-active group whose `Rooms` are a randomly
// ordered set of public, non-dorm rooms. No real curation yet, so `current`
@@ -310,232 +444,496 @@ const app = new Hono<App>()
// completely with NREs. I think it is the featured room load that is somehow
// corrupting the room cache. I tried sending the normal room shape but that
// did not seem to work.
.get('/XXXfeaturedrooms/current', async (c) => {
return c.json(await getFeaturedRooms(c.env.DB))
})
.get(
'/XXXfeaturedrooms/current',
describeRoute({
tags: ['Discovery'],
summary: 'Featured rooms (parked — the path is deliberately broken)',
description: [
'A single always-active group of featured rooms: a random shuffle of eligible public',
'rooms, since there is no editorial curation yet.',
'',
'**Parked.** The path the client calls is `/featuredrooms/current`; this is registered',
'under an `XXX` prefix so the client never reaches it. Serving it made the OTHER room',
'listings fail with NREs in the client, apparently by corrupting its room cache —',
'sending the normal room shape instead did not help. It stays registered so the shape',
'is documented and the route is one rename away once the cause is found.',
].join('\n'),
responses: { 200: json(FeaturedRoomGroupDto, 'The featured-room group') },
}),
async (c) => {
return c.json(await getFeaturedRooms(c.env.DB))
}
)
// Bulk room lookup by `id` or `name` — returns an array of matched rooms (the
// client calls this bare on the rooms host). Rooms not in D1 are simply absent
// from the result; the client treats an empty result as NoSuchRoom.
.get('/rooms/bulk', async (c) => {
const idParam = c.req.query('id')
const nameParam = c.req.query('name')
if (!idParam && !nameParam) {
return c.json("Either 'id' or 'name' query parameter is required", 400)
.get(
'/rooms/bulk',
describeRoute({
tags: ['Rooms'],
summary: 'Look up several rooms at once',
description: [
'Rooms by a comma-separated `id` list, or a single `name`. Ids that arent in D1 are',
'simply absent from the result rather than an error — the client reads an empty result',
'as NoSuchRoom.',
].join(' '),
parameters: [
stringQuery('id', 'Comma-separated room ids'),
stringQuery('name', 'A single room name. Ignored when `id` is given'),
],
responses: {
200: json(RoomDto.array(), 'The rooms that matched (missing ids are omitted)'),
400: json(MissingLookupParam, 'Neither `id` nor `name` was supplied'),
},
}),
async (c) => {
const idParam = c.req.query('id')
const nameParam = c.req.query('name')
if (!idParam && !nameParam) {
return c.json("Either 'id' or 'name' query parameter is required", 400)
}
if (idParam) {
return c.json(await getRoomsByIds(c.env.DB, allIds(idParam)))
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ? [room] : [])
}
if (idParam) {
return c.json(await getRoomsByIds(c.env.DB, allIds(idParam)))
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ? [room] : [])
})
)
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
// the `createdby` variants return everything the account created.
.get('/roomserver/rooms/createdby/me', ownedRooms)
.get('/rooms/ownedby/me', ownedRoomsExcludingDorm)
.get('/rooms/createdby/me', ownedRooms)
.get(
'/roomserver/rooms/createdby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller created (legacy path)',
description: [
'Every room the caller created, dorm included. Identical to',
'`GET /rooms/createdby/me` — the 2023 client calls this one under the `/roomserver`',
'prefix, so both forms are registered.',
].join(' '),
security: AUTHED,
responses: { 200: json(RoomDto.array(), 'The callers rooms'), 401: UNAUTHORIZED_RESPONSE },
}),
ownedRooms
)
.get(
'/rooms/ownedby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller owns (excluding their dorm)',
description: [
'The callers own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
'room the player made, so it doesnt belong in the “rooms you own” list. Use',
'`createdby/me` for everything the account created.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomDto.array(), 'The callers rooms, dorm excluded'),
401: UNAUTHORIZED_RESPONSE,
},
}),
ownedRoomsExcludingDorm
)
.get(
'/rooms/createdby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller created',
description: 'Every room the caller created, dorm included.',
security: AUTHED,
responses: { 200: json(RoomDto.array(), 'The callers rooms'), 401: UNAUTHORIZED_RESPONSE },
}),
ownedRooms
)
// Public: the rooms a given account owns that are publicly viewable. No auth —
// returns a bare array (empty when the account owns no public rooms).
.get('/rooms/ownedby/:accountId{[0-9]+}', async (c) =>
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
.get(
'/rooms/ownedby/:accountId{[0-9]+}',
describeRoute({
tags: ['Rooms'],
summary: 'Another players public rooms',
description: [
'The rooms an account owns that are publicly viewable — what the client shows on a',
'players profile. No auth; empty when the account owns no public rooms.',
].join(' '),
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'The account whose rooms to list',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: { 200: json(RoomDto.array(), 'That accounts public rooms') },
}),
async (c) =>
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
)
// Rooms the caller has favorited (from the interaction table). Auth-gated.
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
// other room-source `*by/me` lists the client loads.
.get('/rooms/favoritedby/me', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
})
.get(
'/rooms/favoritedby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller favorited',
description:
'The rooms the caller has favorited (from the interaction table), as a bare array.',
security: AUTHED,
parameters: pageParams(100),
responses: {
200: json(RoomDto.array(), 'The favorited rooms'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
}
)
// Rooms the caller has visited (interaction rows with a last-visited time).
// Auth-gated. Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/rooms/visitedby/me', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
})
.get(
'/rooms/visitedby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller visited',
description: [
'The rooms the caller has visited — interaction rows carrying a last-visited time —',
'as a bare array.',
].join(' '),
security: AUTHED,
parameters: pageParams(100),
responses: { 200: json(RoomDto.array(), 'The visited rooms'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
}
)
// The current player's interaction state with a room (cheered/favorited/last
// visited), read from the `interaction` table. Auth-gated.
.get('/rooms/:roomId{[0-9]+}/interactionby/me', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await getInteraction(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
.get(
'/rooms/:roomId{[0-9]+}/interactionby/me',
describeRoute({
tags: ['Interaction'],
summary: 'The callers state on a room',
description: [
'Whether the caller has cheered/favorited the room. An unknown room (or one the caller',
'has never touched) reads as all-false rather than 404. `LastVisitedAt` is stamped',
'with “now” on every read, not served from storage.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await getInteraction(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
}
)
// Toggle the player's cheer/favorite on a room. Both are auth-gated PUTs that
// flip the stored flag and return the updated interaction.
.put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await toggleCheer(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
.put(
'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
describeRoute({
tags: ['Interaction'],
summary: 'Toggle the callers cheer on a room',
description: 'Flips the stored cheer flag and answers the updated interaction.',
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(InteractionDto, 'The interaction after the toggle'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await toggleCheer(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
}
)
// Explicitly un-cheer a room (DELETE clears the cheer, vs the PUT toggle).
// Auth-gated; idempotent — un-cheering when there's no cheer is a no-op.
.delete('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await removeCheer(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
.put('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await toggleFavorite(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
.delete(
'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
describeRoute({
tags: ['Interaction'],
summary: 'Un-cheer a room',
description: [
'Clears the cheer outright, where the PUT toggles it. Idempotent — un-cheering a room',
'that isnt cheered is a no-op.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await removeCheer(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
}
)
.put(
'/rooms/:roomId{[0-9]+}/interactionby/me/favorite',
describeRoute({
tags: ['Interaction'],
summary: 'Toggle the callers favorite on a room',
description: 'Flips the stored favorite flag and answers the updated interaction.',
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(InteractionDto, 'The interaction after the toggle'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await toggleFavorite(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
}
)
// Explicitly un-favorite a room (DELETE clears the favorite, vs the PUT toggle).
// Auth-gated; idempotent — un-favoriting when there's no favorite is a no-op.
.delete('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await removeFavorite(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
.delete(
'/rooms/:roomId{[0-9]+}/interactionby/me/favorite',
describeRoute({
tags: ['Interaction'],
summary: 'Un-favorite a room',
description: [
'Clears the favorite outright, where the PUT toggles it. Idempotent — un-favoriting a',
'room that isnt favorited is a no-op.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await removeFavorite(
c.env.DB,
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
}
)
// Clone a room into a new one owned by the caller, using the `name` form field
// (also accepted as a query param). Auth is required — no valid token is a 401,
// with no stub-account fallback. Returns the `{ success, error, value }` envelope
// the client expects; business failures are 200 with success:false.
.post('/rooms/:roomId{[0-9]+}/clone', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
}
.post(
'/rooms/:roomId{[0-9]+}/clone',
describeRoute({
tags: ['Room settings'],
summary: 'Clone a room',
description: [
'Copies a rooms content (scene, subrooms, settings) into a new room owned by the',
'caller. Cloning is the only way to make a room, so the per-account room cap is',
'enforced here — it counts the rooms the account created, minus their auto-provisioned',
'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags and',
'`IsRRO` cleared.',
'',
'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
'HTTP 200 with `success: false` and the message the client shows.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(CloneRoomRequest, 'The new rooms name (also read from `?name=`)'),
responses: {
200: json(RoomEnvelope, 'The new 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 body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const raw = body.name ?? c.req.query('name') ?? ''
const name = typeof raw === 'string' ? raw.trim() : ''
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const raw = body.name ?? c.req.query('name') ?? ''
const name = typeof raw === 'string' ? raw.trim() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
if (await getRoomByName(c.env.DB, name)) {
return roomEnvelope(c, null, 'A room with that name already exists!')
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
if (await getRoomByName(c.env.DB, name)) {
return roomEnvelope(c, null, 'A room with that name already exists!')
}
// Cloning is how a player makes a room, so the per-account cap belongs here.
// Checked after the cheap validations so a rejected name costs no extra D1 read.
const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms) {
logger.info('room create rejected: per-account room limit', { accountId })
return roomEnvelope(c, null, `You can only have ${maxRooms} rooms.`)
}
const room = await cloneRoom(
c.env.DB,
Number.parseInt(c.req.param('roomId'), 10),
name,
accountId
)
if (!room) return roomEnvelope(c, null, "You can't clone this room!")
return roomEnvelope(c, room)
}
// Cloning is how a player makes a room, so the per-account cap belongs here.
// Checked after the cheap validations so a rejected name costs no extra D1 read.
const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms) {
logger.info('room create rejected: per-account room limit', { accountId })
return roomEnvelope(c, null, `You can only have ${maxRooms} rooms.`)
}
const room = await cloneRoom(
c.env.DB,
Number.parseInt(c.req.param('roomId'), 10),
name,
accountId
)
if (!room) return roomEnvelope(c, null, "You can't clone this room!")
return roomEnvelope(c, room)
})
)
// Update a room's description. Auth-gated (401) and owner-only. Business results
// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
.put('/rooms/:roomId{[0-9]+}/description', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/description',
describeRoute({
tags: ['Room settings'],
summary: 'Set a rooms description',
description: [
'Owner-only (the rooms `CreatorAccountId` — co-owners cannot). An unknown room or a',
'non-owner is HTTP 200 with `Success: false` and an `ErrorId`; only a missing token is',
'a real 401. An absent `description` field clears the description.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(DescriptionRequest, 'The new description'),
responses: {
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const description = typeof body.description === 'string' ? body.description : ''
await setRoomDescription(c.env.DB, roomId, description)
return roomResult(c, { Success: true })
})
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const description = typeof body.description === 'string' ? body.description : ''
await setRoomDescription(c.env.DB, roomId, description)
return roomResult(c, { Success: true })
}
)
// Rename a room. Auth-gated (401) and owner-only; the new name must be non-empty
// and not already taken by another room. Business results use the
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
.put('/rooms/:roomId{[0-9]+}/name', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/name',
describeRoute({
tags: ['Room settings'],
summary: 'Rename a room',
description: [
'Owner-only. The new name must be non-empty and not already taken by another room',
'(names are compared case-insensitively). Rejections are HTTP 200 with',
'`Success: false`.',
'',
'NOTE: the `ErrorId` strings other than `Rooms.DoesntExist` are best guesses — the',
'client only renders `Error`, so they have never been confirmed against the real one.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(NameRequest, 'The new name'),
responses: {
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidName',
Error: 'You must enter a name for your room!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidName',
Error: 'You must enter a name for your room!',
})
}
// Reject if a different room already uses this name (case-insensitive).
const existing = await getRoomByName(c.env.DB, name)
if (existing && existing.RoomId !== roomId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.AlreadyExists',
Error: 'A room with that name already exists!',
})
}
// Reject if a different room already uses this name (case-insensitive).
const existing = await getRoomByName(c.env.DB, name)
if (existing && existing.RoomId !== roomId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.AlreadyExists',
Error: 'A room with that name already exists!',
})
}
await setRoomName(c.env.DB, roomId, name)
return roomResult(c, { Success: true })
})
await setRoomName(c.env.DB, roomId, name)
return roomResult(c, { Success: true })
}
)
// Toggle a tag on a room. Auth-gated (401) and owner-only. Body is the `tag`
// form field. There's no delete/patch endpoint, so this call toggles: it adds
@@ -543,103 +941,161 @@ const app = new Hono<App>()
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
// others. Returns the `{ success, error, value }` envelope with the updated
// room as `value`; business failures are 200 with success:false.
.put('/rooms/:roomId{[0-9]+}/tags', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/tags',
describeRoute({
tags: ['Room settings'],
summary: 'Toggle a tag on a room',
description: [
'Owner-only. There is no delete/patch counterpart, so this call TOGGLES: it adds the',
'tag (Type 0) when absent and removes it when present. The “main” tags',
'(`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio buttons — setting one clears',
'the others. Answers the lowercase envelope with the updated room, which the client',
're-renders from.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(TagRequest, 'The tag to toggle'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 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!')
const roomId = Number.parseInt(c.req.param('roomId'), 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!')
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
const updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
return roomEnvelope(c, updated)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
const updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
return roomEnvelope(c, updated)
})
)
// Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName`
// form field (a key from the storage/image upload). Business results use the
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
.put('/rooms/:roomId{[0-9]+}/image', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/image',
describeRoute({
tags: ['Room settings'],
summary: 'Set a rooms image',
description: [
'Owner-only. `imageName` is a key from the storage upload, stored un-prefixed (the',
'`cdn` worker serves it back under `room/`). Pushes a `RoomUpdate` to the owner so',
'their client re-renders with the new image.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(ImageRequest, 'The uploaded image key'),
responses: {
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
if (imageName === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidImage',
Error: 'You must provide an image!',
})
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
if (imageName === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidImage',
Error: 'You must provide an image!',
})
}
await setRoomImage(c.env.DB, roomId, imageName)
// Notify the owner so their client refreshes the room (RoomUpdate carries the
// updated room). The reference sends the post-update room, so merge the change.
await pushRoomUpdate(c, accountId, { ...room, ImageName: imageName })
return roomResult(c, { Success: true })
}
await setRoomImage(c.env.DB, roomId, imageName)
// Notify the owner so their client refreshes the room (RoomUpdate carries the
// updated room). The reference sends the post-update room, so merge the change.
await pushRoomUpdate(c, accountId, { ...room, ImageName: imageName })
return roomResult(c, { Success: true })
})
)
// Delete a room. Auth-gated (401) and owner-only (the room's CreatorAccountId).
// Removes the room record (and per-player interactions with it) and the room's
// image object from the shared CDN bucket. Images players *took* in the room are
// left alone — they live in the api/img world and outlast the room.
.delete('/rooms/:roomId{[0-9]+}', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.delete(
'/rooms/:roomId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Delete a room',
description: [
'Owner-only. Removes the room record, the per-player interactions with it, and the',
'rooms image object from the shared CDN bucket. Photos players TOOK in the room are',
'left alone — those live in the api/img world and outlive the room.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
await deleteRoom(c.env.DB, roomId)
// Remove the room image from the CDN bucket. The stored ImageName is the
// un-prefixed key the `cdn` worker serves back under `room/` (see storage
// upload + the `GET /room/:dataBlob` route), so the object key is `room/<name>`.
// R2 deletes are idempotent, so a canonical/static or already-gone image is fine.
const imageName = typeof room.ImageName === 'string' ? room.ImageName : ''
if (imageName !== '') {
await c.env.CDN_ASSETS.delete(`room/${imageName}`)
}
return roomResult(c, { Success: true })
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
await deleteRoom(c.env.DB, roomId)
// Remove the room image from the CDN bucket. The stored ImageName is the
// un-prefixed key the `cdn` worker serves back under `room/` (see storage
// upload + the `GET /room/:dataBlob` route), so the object key is `room/<name>`.
// R2 deletes are idempotent, so a canonical/static or already-gone image is fine.
const imageName = typeof room.ImageName === 'string' ? room.ImageName : ''
if (imageName !== '') {
await c.env.CDN_ASSETS.delete(`room/${imageName}`)
}
return roomResult(c, { Success: true })
})
)
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
@@ -647,191 +1103,362 @@ const app = new Hono<App>()
// tier). Updates the target account's existing role entry or adds one, notifies the
// affected member so their client refreshes permissions, and returns the updated
// room in the lowercase `{ success, error, value }` envelope.
.put('/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Set a members role in a room',
description: [
'Updates the target accounts entry in the rooms `Roles` (or adds one). Gated to the',
'rooms creator or a co-owner — a valid token from anyone else is a 403. The affected',
'MEMBER gets the `RoomUpdate` push, not the caller, so their client refreshes the',
'permissions it just gained or lost.',
].join(' '),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'accountId',
in: 'path',
required: true,
description: 'The member whose role changes',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(RoleRequest, 'The role tier to grant'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
// Notify the member whose role changed so their client refreshes the room
// (and the permissions it grants them).
await pushRoomUpdate(c, targetAccountId, updated)
return roomEnvelope(c, updated)
})
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
// Notify the member whose role changed so their client refreshes the room
// (and the permissions it grants them).
await pushRoomUpdate(c, targetAccountId, updated)
return roomEnvelope(c, updated)
}
)
// Set a room's content warning: the `WarningMask` bit flags plus an optional
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
// the `warningMask` form field (an integer) and an optional `customWarning` string
// (set when present — an empty value clears it). Returns the updated room in the
// `{ success, error, value }` envelope.
.put('/rooms/:roomId{[0-9]+}/warning', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/warning',
describeRoute({
tags: ['Room settings'],
summary: 'Set a rooms content warning',
description: [
'The `WarningMask` bit flags plus an optional free-text `CustomWarning`. Owner or',
'co-owner only (403 otherwise). `CustomWarning` is only touched when the field is',
'present — sending it empty clears it, omitting it leaves it alone.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(WarningRequest, 'The warning flags and optional custom text'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const warningMask =
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
if (Number.isNaN(warningMask))
return roomEnvelope(c, null, 'You must provide a valid warning mask!')
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const warningMask =
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
if (Number.isNaN(warningMask))
return roomEnvelope(c, null, 'You must provide a valid warning mask!')
const patch: Record<string, unknown> = { WarningMask: warningMask }
// Only touch CustomWarning when the field is present (an empty string clears it).
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
const patch: Record<string, unknown> = { WarningMask: warningMask }
// Only touch CustomWarning when the field is present (an empty string clears it).
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
// Notify the owner so their client refreshes the room with the updated warning.
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
// Notify the owner so their client refreshes the room with the updated warning.
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
)
// Toggle whether a room may be cloned (`CloningAllowed`). Auth-gated (401) and
// owner/co-owner-only (403). Body is the `cloningAllowed` form field (`True`/`False`).
// Returns the updated room in the `{ success, error, value }` envelope.
.put('/rooms/:roomId{[0-9]+}/cloning', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/cloning',
describeRoute({
tags: ['Room settings'],
summary: 'Allow or block cloning of a room',
description: [
'Sets `CloningAllowed` — false makes `POST /rooms/{roomId}/clone` refuse. Owner or',
'co-owner only (403 otherwise).',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(CloningRequest, 'Whether cloning is allowed'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
if (typeof body.cloningAllowed !== 'string') {
return roomEnvelope(c, null, 'You must provide cloningAllowed.')
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
if (typeof body.cloningAllowed !== 'string') {
return roomEnvelope(c, null, 'You must provide cloningAllowed.')
}
const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'
const updated = await updateRoomFields(c.env.DB, roomId, room, {
CloningAllowed: cloningAllowed,
})
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'
const updated = await updateRoomFields(c.env.DB, roomId, room, {
CloningAllowed: cloningAllowed,
})
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
)
// Set a room's platform/movement support flags (its `Supports*` restrictions).
// Auth-gated (401) and owner/co-owner-only (403). Body is a form of
// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
.put('/rooms/:roomId{[0-9]+}/restrictions', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/restrictions',
describeRoute({
tags: ['Room settings'],
summary: 'Set a rooms platform/movement support flags',
description: [
'The rooms `Supports*` restrictions — which platforms and movement modes may enter.',
'Owner or co-owner only (403 otherwise). Only the fields actually posted are changed,',
'and field names are matched case-insensitively.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(RestrictionsRequest, 'The flags to change'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const patch: Record<string, boolean> = {}
for (const [key, value] of Object.entries(body)) {
const field = RESTRICTION_FIELDS[key.toLowerCase()]
if (field !== undefined && typeof value === 'string') {
patch[field] = value.toLowerCase() === 'true'
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const patch: Record<string, boolean> = {}
for (const [key, value] of Object.entries(body)) {
const field = RESTRICTION_FIELDS[key.toLowerCase()]
if (field !== undefined && typeof value === 'string') {
patch[field] = value.toLowerCase() === 'true'
}
}
}
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
)
// Add a load screen to a room (`LoadScreens[]` — the images shown while the room
// loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName`
// form field plus optional `title`/`subtitle`. Appends one
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
// room in the `{ success, error, value }` envelope.
.put('/rooms/:roomId{[0-9]+}/loadscreen', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/loadscreen',
describeRoute({
tags: ['Room settings'],
summary: 'Add a load screen to a room',
description: [
'APPENDS one `{ ImageName, Title, Subtitle }` to the rooms `LoadScreens` — the images',
'shown while the room loads. There is no remove or replace counterpart. Owner or',
'co-owner only (403 otherwise).',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(LoadScreenRequest, 'The load screen to append'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
const title = typeof body.title === 'string' ? body.title : ''
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
const title = typeof body.title === 'string' ? body.title : ''
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
)
// Set a room's top-level `Accessibility` (the visibility the public-room/search
// filters key on — see the RoomAccessibility enum). Auth-gated (401) and
// owner/co-owner-only (403). Body is the `accessibility` form field (an integer).
// Returns the updated room in the `{ success, error, value }` envelope.
.put('/rooms/:roomId{[0-9]+}/accessibility', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/accessibility',
describeRoute({
tags: ['Room settings'],
summary: 'Set a rooms accessibility',
description: [
'The rooms top-level visibility — the field the public-room and search filters key',
'on (0 Private, 1 Public, 2 Unlisted). Owner or co-owner only (403 otherwise).',
'Subrooms carry their own `Accessibility`, set through the subroom `modify` call.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(AccessibilityRequest, 'The new accessibility'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const accessibility =
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
if (Number.isNaN(accessibility)) {
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const accessibility =
typeof body.accessibility === 'string'
? Number.parseInt(body.accessibility, 10)
: Number.NaN
if (Number.isNaN(accessibility)) {
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
}
const updated = await updateRoomFields(c.env.DB, roomId, room, {
Accessibility: accessibility,
})
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
const updated = await updateRoomFields(c.env.DB, roomId, room, { Accessibility: accessibility })
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
)
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
// array). Public — the client fetches it while loading the room. 404 when the
// room or subroom is unknown.
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
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)
const sub = room ? findSubRoom(room, subRoomId) : undefined
return sub ? c.json(sub) : c.notFound()
})
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
describeRoute({
tags: ['Subrooms'],
summary: 'A subrooms data descriptor',
description: [
'The `SubRoom` object from the rooms `SubRooms` array — the descriptor the client',
'fetches while loading the room, carrying the scene id and the saved-data blob keys.',
'Public; an unknown room or subroom is a 404.',
].join(' '),
parameters: [roomIdParam, subRoomIdParam],
responses: {
200: json(SubRoomDto, 'The subroom'),
404: { description: 'No such room or subroom' },
},
}),
async (c) => {
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)
const sub = room ? findSubRoom(room, subRoomId) : undefined
return sub ? c.json(sub) : c.notFound()
}
)
// A subroom's saved-data versions — the room-history / "restore a save" list, paged as
// PagedResultsDTO<SubRoomDataSaveDTO> (`{ Results, TotalResults }`). We don't keep a save
// history yet: a save (POST …/data) overwrites the current blob inline on the subroom, so
// there are no distinct versions to list — this returns an empty page. The
// unityAssetTarget/unityAssetVersion/skip/take query params are accepted and ignored.
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', (c) =>
c.json({ Results: [], TotalResults: 0 })
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
describeRoute({
tags: ['Subrooms'],
summary: 'A subrooms saved-data versions',
description: [
'The room-history / “restore a save” list, paged as',
'`PagedResultsDTO<SubRoomDataSaveDTO>`. We keep no save history — a save (`POST',
'…/data`) overwrites the subrooms current blob inline, so there are no distinct',
'versions to list — and this is always an empty page. The',
'`unityAssetTarget`/`unityAssetVersion`/`skip`/`take` params are accepted and ignored.',
].join(' '),
parameters: [
roomIdParam,
subRoomIdParam,
stringQuery('unityAssetTarget', 'Accepted and ignored'),
stringQuery('unityAssetVersion', 'Accepted and ignored'),
stringQuery('skip', 'Accepted and ignored — the page is always empty'),
stringQuery('take', 'Accepted and ignored — the page is always empty'),
],
responses: { 200: json(SubRoomSavesPage, 'Always an empty page') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
)
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
@@ -839,234 +1466,476 @@ const app = new Hono<App>()
// the uploaded data blobs and records the room-level save fields, notifies the
// owner, and returns the updated ROOM in the lowercase `{ success, error, value }`
// envelope the reference's SetRoomData uses.
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return c.body(null, 401)
.post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
describeRoute({
tags: ['Subrooms'],
summary: 'Save a subrooms data (room save)',
description: [
'Points the subroom at the blobs the client has already uploaded through the `storage`',
'worker and stamps the save; the room-level fields the save carries (`Description`,',
'`PersistenceVersion`, `InventionUsage`) are written to the room. Editable by the',
'rooms creator or a co-owner (403 otherwise); a missing token is an EMPTY-body 401,',
'unlike the other room writes.',
'',
'The push notification carries the whole room, but the RESPONSE is the saved SUBROOM',
'itself with no envelope — the client deserializes the body directly as the subroom.',
'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the',
'savers id here, because the client NREs on a null one.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'),
responses: {
200: json(
SubRoomSaveResult,
'The saved subroom, or the result envelope when the room/subroom is unknown'
),
401: UNAUTHORIZED_EMPTY,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return c.body(null, 401)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
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 roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string }
SubRoomData?: { Filename?: string }
Description?: string
PersistenceVersion?: number
InventionUsage?: string
}
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
subRoomDataFilename: body.SubRoomData?.Filename,
roomDataFilename: body.RoomData?.Filename,
description: typeof body.Description === 'string' ? body.Description : undefined,
persistenceVersion:
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
})
}
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
if (!updated) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string }
SubRoomData?: { Filename?: string }
Description?: string
PersistenceVersion?: number
InventionUsage?: string
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM
// itself — no envelope. The client deserializes the body directly as the subroom.
await pushRoomUpdate(c, accountId, updated)
return c.json(findSubRoom(updated, subRoomId) ?? {})
}
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
subRoomDataFilename: body.SubRoomData?.Filename,
roomDataFilename: body.RoomData?.Filename,
description: typeof body.Description === 'string' ? body.Description : undefined,
persistenceVersion:
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
})
if (!updated) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM
// itself — no envelope. The client deserializes the body directly as the subroom.
await pushRoomUpdate(c, accountId, updated)
return c.json(findSubRoom(updated, subRoomId) ?? {})
})
)
// Modify a subroom's settings (Name/Accessibility/MaxPlayers) from the form body.
// Auth-gated (401) and owner-only — only the room creator may change its subrooms.
// Notifies the owner (RoomUpdate) and returns the `{ Success, Value, ErrorId, Error }`
// envelope at HTTP 200, matching the other owner-gated room mutations.
.put('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
.put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify',
describeRoute({
tags: ['Subrooms'],
summary: 'Modify a subrooms settings',
description: [
'Sets a subrooms `Name`, `Accessibility` and `MaxPlayers`. Owner-only — only the',
'rooms creator may change its subrooms, not co-owners. `name` is required;',
'`accessibility` and `maxPlayers` are applied only when supplied, and a non-positive',
'`maxPlayers` is ignored rather than rejected.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: form(ModifySubRoomRequest, 'The settings to change'),
responses: {
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
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 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 roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
if (!findSubRoom(room, subRoomId)) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This subroom does not exist!',
})
}
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
if (!findSubRoom(room, subRoomId)) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This subroom does not exist!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidName',
Error: 'You must enter a name for your room!',
})
}
const accessibility =
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidName',
Error: 'You must enter a name for your room!',
})
}
const accessibility =
typeof body.accessibility === 'string'
? Number.parseInt(body.accessibility, 10)
: Number.NaN
const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
name,
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
})
if (!updated) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This subroom does not exist!',
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
name,
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
})
}
if (!updated) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This subroom does not exist!',
})
}
await pushRoomUpdate(c, accountId, updated)
return roomResult(c, { Success: true })
})
await pushRoomUpdate(c, accountId, updated)
return roomResult(c, { Success: true })
}
)
// 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
// returns the `{ success, error, value }` envelope with the new subroom as `value`,
// mirroring the room-level `/clone`. Response shape is a best guess (the real
// client's expected body is unknown).
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
.post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone',
describeRoute({
tags: ['Subrooms'],
summary: 'Clone a subroom',
description: [
'Copies a subroom into a new subroom of the SAME room — same scene, settings and saved',
'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.',
'Owner-only.',
'',
'The response shape is a best guess: it mirrors the room-level `/clone` envelope, but',
'the real clients expected body for this call is unknown.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
responses: {
200: json(SubRoomEnvelope, 'The new subroom, 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!')
}
const result = await cloneSubRoom(c.env.DB, roomId, subRoomId, accountId)
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.subRoom)
}
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!')
}
const result = await cloneSubRoom(c.env.DB, roomId, subRoomId, accountId)
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.subRoom)
})
)
// Create a new (empty) subroom in a room (form body `name`). Auth-gated (401) and
// owner-only. Mints a fresh globally-unique SubRoomId, bases the scene/capacity on the
// room's first subroom, notifies the owner (RoomUpdate), and returns the updated ROOM
// in the `{ success, error, value }` envelope (the client re-renders the room's subroom
// list from `value`, so it's the whole room, not the bare subroom).
.post('/rooms/:roomId{[0-9]+}/subrooms', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
.post(
'/rooms/:roomId{[0-9]+}/subrooms',
describeRoute({
tags: ['Subrooms'],
summary: 'Create a subroom',
description: [
'Adds an empty subroom to a room. Owner-only. It mints a fresh globally-unique',
'`SubRoomId` (the game numbers subrooms from one sequence, not per room) and inherits',
'the scene and capacity of the rooms first existing subroom.',
'',
'Answers the updated ROOM, not the bare subroom — the client re-renders the rooms',
'subroom list from `value`. Delete answers the same shape.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(CreateSubRoomRequest, 'The new subrooms name'),
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 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!')
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.room)
}
const roomId = Number.parseInt(c.req.param('roomId'), 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!')
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.room)
})
)
// Delete a subroom from a room. Auth-gated (401) and owner-only. Refuses to remove a
// room's only subroom. Notifies the owner (RoomUpdate) and returns the updated ROOM in
// the `{ success, error, value }` envelope (same shape as create, so the client
// re-renders the subroom list from `value`).
.delete('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
.delete(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}',
describeRoute({
tags: ['Subrooms'],
summary: 'Delete a subroom',
description: [
'Removes a subroom from a room. Owner-only, and it refuses to remove a rooms only',
'subroom — that would leave the room with no scene to load. Any saved-data blob the',
'subroom pointed at is left in R2, the same way deleting a room leaves the photos',
'taken in it. Answers the updated ROOM, like create.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
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!')
}
const result = await deleteSubRoom(c.env.DB, roomId, subRoomId)
if (!result.ok) {
return roomEnvelope(
c,
null,
result.reason === 'last_subroom'
? "You can't delete a room's only subroom!"
: 'This subroom does not exist!'
)
}
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.room)
}
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!')
}
const result = await deleteSubRoom(c.env.DB, roomId, subRoomId)
if (!result.ok) {
return roomEnvelope(
c,
null,
result.reason === 'last_subroom'
? "You can't delete a room's only subroom!"
: 'This subroom does not exist!'
)
}
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.room)
})
)
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
// unknown/untagged.
.get('/rooms/:roomId{[0-9]+}/similar', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
)
})
.get(
'/rooms/:roomId{[0-9]+}/similar',
describeRoute({
tags: ['Discovery'],
summary: 'Rooms similar to a room',
description: [
'Rooms sharing tags with the given one — the “more like this” rail. Empty when the',
'room is unknown or carries no tags.',
].join(' '),
parameters: [roomIdParam, ...pageParams(100)],
responses: { 200: json(PagedRooms, 'The similar rooms') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
)
}
)
// The caller's per-room player data. Stub → empty blob (client reads `Data`).
.get('/rooms/:roomId{[0-9]+}/playerdata/me', (c) => c.json({ Data: '' }))
.get(
'/rooms/:roomId{[0-9]+}/playerdata/me',
describeRoute({
tags: ['Rooms'],
summary: 'The callers per-room player data',
description: [
'Per-room save data for the calling player. Nothing stores any yet, so this is a stub',
'serving an empty blob — which the client reads as “no saved data”. No auth: theres',
'no caller-specific state to protect until something writes here.',
].join(' '),
parameters: [roomIdParam],
responses: { 200: json(PlayerDataDto, 'An empty data blob') },
}),
(c) => c.json({ Data: '' })
)
// Single room by id. 404 when the room isn't in D1. Ignores the
// include/unityAsset* query params.
.get('/rooms/:roomId{[0-9]+}', async (c) => {
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return room ? c.json(room) : c.notFound()
})
.get(
'/rooms/:roomId{[0-9]+}',
describeRoute({
tags: ['Rooms'],
summary: 'A room by id',
description: [
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
'unknown room here is a 404, not `{}`. The `include`/`unityAsset*` query params the',
'client sends are accepted and ignored.',
].join(' '),
parameters: [
roomIdParam,
stringQuery('include', 'Accepted and ignored'),
stringQuery('unityAssetTarget', 'Accepted and ignored'),
stringQuery('unityAssetVersion', 'Accepted and ignored'),
],
responses: { 200: json(RoomDto, 'The room'), 404: { description: 'No such room' } },
}),
async (c) => {
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return room ? c.json(room) : c.notFound()
}
)
// Photon access token + room permissions the client needs to spawn into a
// room. The client calls it on the rooms host both bare and under `/roomserver`.
.get('/photon_access_token', handlePhotonAccessToken)
.get('/roomserver/photon_access_token', handlePhotonAccessToken)
.get(
'/photon_access_token',
describeRoute({
tags: ['Session'],
summary: 'Photon token + room permissions',
description: [
'The permission table and Photon credentials the client needs to spawn into a room.',
'`RoomInstanceId` is the callers current instance, read from the shared `presence`',
'table (null when theyre in none).',
'',
'`PhotonAccessToken` is always empty: the reference server signs it with a',
'secret/algorithm we dont have, and our Photon setup accepts an empty token. The',
'global (Role 0) maker pen is granted only to the hardcoded dev accounts.',
].join('\n'),
security: AUTHED,
responses: {
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
401: UNAUTHORIZED_RESPONSE,
},
}),
handlePhotonAccessToken
)
.get(
'/roomserver/photon_access_token',
describeRoute({
tags: ['Session'],
summary: 'Photon token + room permissions (legacy path)',
description: [
'Identical to `GET /photon_access_token` — the client calls it both bare and under the',
'`/roomserver` prefix, so both forms are registered.',
].join(' '),
security: AUTHED,
responses: {
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
401: UNAUTHORIZED_RESPONSE,
},
}),
handlePhotonAccessToken
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare rooms',
version: '1.0.0',
description: [
'The room server for recflare, a private-server reimplementation of the Rec Room',
'backend: room storage, the browse/search feeds, per-player cheers and favorites,',
'the owners room settings, and subrooms.',
'',
'A room is a single JSON blob in the shared `recflare` D1, with generated columns',
'for the queryable fields; reads serve that blob verbatim, which is why the shapes',
'here are the clients PascalCase ones. Subrooms live in their own table (their ids',
'come from one global sequence, not per room) and are re-attached to each room on',
'read. The seed rooms — including the dorm — come from `static/ImportRooms.json`.',
'',
'Two response envelopes appear side by side: a PascalCase',
'`{ Success, Value, ErrorId, Error }` and a lowercase `{ success, error, value }`.',
'Which one a route uses is dictated by the clients deserializer for that call, so',
'the inconsistency is deliberate. Both answer HTTP 200 even for a rejection — the',
'client reads the flag, not the status.',
].join('\n'),
},
servers: [{ url: 'https://rooms.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+79 -1
View File
@@ -349,7 +349,12 @@ describe('rooms endpoints', () => {
expect(body.length).toBeLessThanOrEqual(3)
})
it('GET /featuredrooms/current returns a featured-room group of public rooms', async () => {
// Skipped: the endpoint is disabled. Serving it broke the client — the other room
// listings started failing with NREs, apparently because the featured-room load
// corrupts the client's room cache — so the route is registered under an `XXX`
// prefix (see rooms.app.ts) and this path 404s. The handler and its test are kept
// intact for whenever the cause is found; un-prefix the route to re-enable both.
it.skip('GET /featuredrooms/current returns a featured-room group of public rooms', async () => {
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
@@ -1493,4 +1498,77 @@ describe('rooms endpoints', () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /rooms/{roomId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite',
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
'GET /',
'GET /XXXfeaturedrooms/current',
'GET /photon_access_token',
'GET /rooms',
'GET /rooms/base',
'GET /rooms/bulk',
'GET /rooms/createdby/me',
'GET /rooms/favoritedby/me',
'GET /rooms/hot',
'GET /rooms/ownedby/me',
'GET /rooms/ownedby/{accountId}',
'GET /rooms/recommendations',
'GET /rooms/search',
'GET /rooms/visitedby/me',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/data',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /roomserver/photon_access_token',
'GET /roomserver/rooms/createdby/me',
'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
'PUT /rooms/{roomId}/accessibility',
'PUT /rooms/{roomId}/cloning',
'PUT /rooms/{roomId}/description',
'PUT /rooms/{roomId}/image',
'PUT /rooms/{roomId}/interactionby/me/cheer',
'PUT /rooms/{roomId}/interactionby/me/favorite',
'PUT /rooms/{roomId}/loadscreen',
'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/tags',
'PUT /rooms/{roomId}/warning',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
+1
View File
@@ -19,6 +19,7 @@ import type { Env } from './context'
export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> = [
{ slug: 'auth', title: 'auth — authentication & tokens' },
{ slug: 'accounts', title: 'accounts — profiles & lookups' },
{ slug: 'rooms', title: 'rooms — rooms, subrooms & browse feeds' },
{ slug: 'match', title: 'match — matchmaking & presence' },
{ slug: 'econ', title: 'econ — avatar & economy' },
{ slug: 'clubs', title: 'clubs — clubs & clubhouses' },
+15
View File
@@ -703,12 +703,27 @@ importers:
'@repo/jwt':
specifier: workspace:*
version: link:../../packages/jwt
'@standard-community/standard-json':
specifier: 0.3.5
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
'@standard-community/standard-openapi':
specifier: 0.2.9
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
hono:
specifier: 4.12.27
version: 4.12.27
hono-openapi:
specifier: 1.3.1
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
openapi-types:
specifier: 12.1.3
version: 12.1.3
workers-tagged-logger:
specifier: 1.0.1
version: 1.0.1
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@cloudflare/vitest-pool-workers':
specifier: 0.16.20