Files
recflare/apps/rooms/src/rooms.app.ts
T
dukedotdev a3a5851373 [rooms] Support 2026 Studio loading of legacy baked bundles (#129)
* [rooms] Preserve legacy Unity bundle save DTOs for the Studio loader

* [rooms] Resolve JSON baked bundle requests with exact target and version
2026-10-12 14:17:43 -04:00

5198 lines
222 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
answerRoomRoleInvite,
applyRoomTagEdit,
attachStudioUnityAssets,
autocompleteRoomSearch,
bakedUnityAssets,
banPlayerFromRoom,
canManageRoom,
canManageRoomById,
cloneRoom,
cloneSubRoom,
countRoomsByCreator,
createNotification,
createSubRoom,
deleteExpiredRoomInvites,
deleteRoom,
deleteRoomLeaderboard,
deleteSubRoom,
findSubRoom,
getAccount,
getBakedUnityAssets,
getBaseRooms,
getContributedRooms,
getFavoritedRooms,
getFeaturedRooms,
getHotRooms,
getInteraction,
getOrCreateDormRoom,
getPlayerData,
getPlayerIdsInRoom,
getPresence,
getPublicRoomsByCreator,
getPublicVisitedRooms,
getRecommendedRooms,
getRoomBanHistory,
getRoomBans,
getRoomById,
getRoomByName,
getRoomExperience,
getRoomsByCreator,
getRoomsByIds,
getSimilarRooms,
getStudioUnityAsset,
getSubRoom,
getSubRoomPermissions,
getSubRoomSaveById,
getSubRoomSaves,
getTrendingRooms,
getVisitedRooms,
incrementRoomExperience,
inviteRoomRole,
isPlayerBannedFromRoom,
isRoomOwner,
MessageType,
modifySubRoom,
movePlayerToDorm,
moveSubRoom,
publishSubRoomSave,
removeCheer,
removeFavorite,
removeRoomRole,
Role,
roomNameRejection,
roomProgression,
roomRoles,
saveSubRoomData,
searchRooms,
setPlayerData,
setRoomDescription,
setRoomImage,
setRoomLeaderboard,
setRoomName,
setRoomProgression,
setRoomRole,
setSubRoomPermissions,
toggleCheer,
toggleFavorite,
transferRoomOwnership,
unbanPlayerFromRoom,
updateRoomFields,
writeAuditLog,
} from '@repo/domain'
import {
intVar,
logger,
withCleanSpec,
withDefaultCors,
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
import {
AccessibilityRequest,
AUTHED,
BakedUnityAssetBulkList,
BakedUnityAssetBulkRequest,
bannedPlayerIdParam,
BanRequest,
BulkRoomsRequest,
CloneRoomRequest,
CloningRequest,
CreateSubRoomRequest,
CreatorRequest,
CuratedPlaylists,
DescriptionRequest,
DormRoomId,
ExcludeRequest,
ExperienceIncrementRequest,
ExperienceRequest,
FeaturedRoomGroupDto,
FORBIDDEN_RESPONSE,
form,
ImageRequest,
InteractionDto,
intQuery,
InviteRoleRequest,
IsBannedEnvelope,
IsBannedPascalEnvelope,
json,
jsonBody,
leaderboardIdParam,
LeaderboardRequest,
LeaderboardResultEnvelope,
LoadScreenRequest,
MaxPlayerCalculationModeRequest,
MissingLookupParam,
ModifySubRoomRequest,
MoveSubRoomRequest,
NameRequest,
PagedRooms,
pageParams,
PhotonAccessTokenDto,
PlayerDataDto,
PlayerDataRequest,
playerIdParam,
PublishSaveRequest,
PublishStateConfigsEnvelope,
RestrictionsRequest,
RoleRequest,
RoomBanEntryDto,
RoomBanEnvelope,
RoomBanHistoryEnvelope,
RoomBansRemovedEnvelope,
RoomDto,
RoomEnvelope,
RoomExperience,
RoomExperiencePlayer,
RoomExperiencePlayerEnvelope,
roomIdParam,
RoomLookup,
RoomPascalEnvelope,
RoomResultEnvelope,
RoomRoleDto,
RoomSaveEnvelope,
saveIdParam,
SaveSubRoomDataRequest,
SearchSuggestions,
ServiceStatus,
ShowcasedRooms,
stringQuery,
SubRoomAccessibilityRequest,
SubRoomDataSaveDetailDto,
subRoomIdParam,
SubRoomPermissionsRequest,
SubRoomSavesNoUnityAssetsPage,
SubRoomSavesPage,
SuccessEnvelope,
TagRequest,
TooManyLookupIds,
UNAUTHORIZED_EMPTY,
UNAUTHORIZED_ENVELOPE,
UNAUTHORIZED_RESPONSE,
UnbanBulkRequest,
UnityAssetWithSourceDto,
WarningRequest,
} from './openapi'
import type { Context } from 'hono'
import type { Room, RoomBan, RoomBanRecord, RoomPermission } from '@repo/domain'
import type { MessageReceivedPayload } from '../../notify/src/notification-payloads'
import type { App, Env } from './context'
/**
* Room server. Rooms are stored in D1 as JSON blobs with generated columns for
* querying (see rooms-db.ts); the dorm (RoomId 1) is seeded by the migration.
* Responses are the stored JSON verbatim (PascalCase, client-facing shape).
*
* The `rooms` prefix maps to this worker's subdomain, so method
* routes are served bare. The 2023 client also hits several of these without the
* `/roomserver` prefix, so both forms are registered.
*/
/** Parse the first valid integer id from a comma-separated `id` query param. */
function firstId(idParam: string): number | undefined {
return idParam
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.find((n) => !Number.isNaN(n))
}
/** Parse all valid integer ids from a comma-separated `id` query param. */
/**
* How many rooms one bulk lookup may ask about. It is D1's cap on bound parameters, which
* `getRoomsByIds` binds one of per id: over it the query fails outright, so the request is
* refused with a 400 instead. Splitting the read would work, but a client asking about more
* than a hundred rooms in one call has lost track of what it is rendering — better it hears
* so than gets served.
*/
const MAX_BULK_ROOM_IDS = 100
/** The 400 an over-cap bulk lookup answers, in the bare-string style the other 400 uses. */
function tooManyIds(c: Context<App>) {
return c.json(`At most ${MAX_BULK_ROOM_IDS} room ids may be looked up at once`, 400)
}
/**
* Whether a bulk lookup asked for the public-only filter. The client sends the C# spelling
* (`True`/`False`) and absent means False, so anything but a case-insensitive `true` is off.
*/
function excludePrivateRooms(value: string | undefined): boolean {
return (value ?? '').toLowerCase() === 'true'
}
function allIds(idParam: string): number[] {
return idParam
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
}
/**
* How many rooms one account may create, when the `MAX_ROOMS_PER_ACCOUNT` var is
* unset. Cloning is the only way to make a room, so the cap is enforced there; it
* counts rooms the account created, minus their auto-provisioned dorm. Setting the
* var to 0 lifts the cap entirely, which a small private server will want. Existing
* rooms are never touched — lowering the cap just stops new ones.
*/
const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
/** Account ids granted the global (Role 0) maker pen — the reference server's
* hardcoded moderator/dev accounts. */
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
/**
* The slice of the shared presence row we read — the caller's current room instance.
* `subRoomId` is what scopes the stored permission overrides: they belong to the subroom
* the player is standing in, not to the room.
*/
interface PresenceView {
roomInstanceId?: number
roomId?: number
subRoomId?: number
}
/**
* The permissions the token table carries for every role, in the order the client has been
* seen receiving them. A room's creator may override any of them per subroom and per role
* (`PUT …/subrooms/{subRoomId}/permissions`); the table may also carry pairs the creator
* added that aren't listed here (`CAN_INVITE`, `MAX_SPAWNED_INVENTIONS`).
*/
const BUILD_PERMISSIONS = [
'CAN_USE_MAKER_PEN',
'CAN_USE_ROOM_RESET_BUTTON',
'CAN_USE_DELETE_ALL_BUTTON',
'CAN_SAVE_INVENTIONS',
'CAN_SPAWN_INVENTIONS',
'CAN_USE_PLAY_GIZMOS_TOGGLE',
] as const
/**
* Whether a role may build by default — the value every permission above takes for it when
* the creator has stored nothing. Only the room's MANAGERS (co-owner and up) may: a visitor,
* a host or a moderator in a room whose creator never opened the permissions screen must not
* be able to reset the room, delete everything in it or spawn into it. Serving `True` at
* Role 0 to everyone (as this once did) handed every visitor the delete-all button.
*/
const buildsByDefault = (role: number): boolean => role >= Role.CoOwner
/**
* The caller's role in a room: Creator for its `CreatorAccountId`, otherwise the highest
* tier `Roles` names them at, otherwise None — a visitor.
*/
function roleIn(room: Room, accountId: number): number {
if (room.CreatorAccountId === accountId) return Role.Creator
return roomRoles(room)
.filter((r) => r.AccountId === accountId)
.reduce((best, r) => Math.max(best, r.Role), Role.None as number)
}
/**
* The role tiers a manager's permission table carries a row for, lowest first — every
* member of the `Role` enum. The permissions screen draws a cell per (permission, role) and
* reads each cell from the row at exactly that role, so every tier needs its rows whether
* or not anything is stored there.
*/
const PERMISSION_ROLES = [Role.None, Role.Host, Role.Moderator, Role.CoOwner, Role.Creator]
/**
* Room permissions + Photon token the client needs to spawn into a room. `RoomInstanceId`
* is the caller's current instance from presence (null when they aren't in one).
* `PhotonAccessToken` stays empty — the reference server signs it via `ClientSecurity`,
* whose secret/algorithm we don't have; our Photon setup accepts an empty token.
*
* The table depends on who is asking, because two different screens read it:
*
* A MANAGER — the creator or a co-owner (`role` at or above {@link Role.CoOwner}, the same
* set `canManageRoom` admits) — gets the WHOLE table, one row per (permission, role) for
* every tier in {@link PERMISSION_ROLES}. The permissions screen (fed by
* `PUT …/subrooms/{subRoomId}/permissions`) draws a cell per pair and reads each cell from
* the row at exactly that role, so a stored entry is served at its own role with
* `Override: true`, and every other cell carries the value that reaches that role with
* `Override: false` — the entry stored at the highest lower role, else the default. A
* cell with no row snaps back to the default the moment it is toggled (a Host grant served
* only at Role 0 and the creator's own role, as this once did, did exactly that), and a
* default served with `Override: true` can never be un-overridden, so the flags matter as
* much as the values.
*
* Everyone else gets THEIR rows only: each permission's value for the role they hold — the
* override stored at their own role, else the one stored at the highest lower role (a grant
* to "everyone" reaches a host too), else the default — served at Role 0 AND, for a role
* holder, again at their role, every row `Override: true`. The client has been seen applying
* Role 0 rows to a visitor, and which rows it reads for a host or moderator is not known, so
* both readings land on the same values. Pairs stored under a role they don't hold are not
* theirs and are left out, so a visitor never sees — or gets — a co-owner's grants.
*
* Defaults: managers may build, nobody else may ({@link buildsByDefault}). The global maker
* pen stays with the hardcoded dev accounts, whatever the room says.
*/
function photonAccessToken(
accountId: number,
roomInstanceId: number | null,
role: number = Role.None,
overrides: RoomPermission[] = []
) {
const isBuild = (name: string): boolean =>
BUILD_PERMISSIONS.includes(name as (typeof BUILD_PERMISSIONS)[number])
const names = [...BUILD_PERMISSIONS, ...overrides.map((o) => o.Permission)].filter(
(name, i, all) => all.indexOf(name) === i
)
const storedAt = (permission: string, at: number): RoomPermission | undefined =>
overrides.find((o) => o.Permission === permission && o.Role === at)
// The override that reaches a role from below it: the highest role under `at` with one.
const reachingFromBelow = (permission: string, at: number): RoomPermission | undefined =>
overrides
.filter((o) => o.Permission === permission && o.Role < at)
.reduce<RoomPermission | undefined>(
(best, o) => (best && best.Role > o.Role ? best : o),
undefined
)
// The value a role holds for a permission with nothing stored at that role; undefined
// for a creator-added pair nothing below the role names either.
const inherited = (permission: string, at: number): string | undefined => {
const below = reachingFromBelow(permission, at)
if (below) return below.Value
return isBuild(permission) ? (buildsByDefault(at) ? 'True' : 'False') : undefined
}
const row = (
Permission: string,
Role: number,
Value: string,
Override: boolean,
Type = 0
): RoomPermission => ({ Override, Permission, Role, Type, Value })
const permissions: RoomPermission[] = []
if (buildsByDefault(role)) {
// A manager: the whole table, grouped by role.
for (const at of PERMISSION_ROLES) {
for (const name of names) {
const own = storedAt(name, at)
if (own) permissions.push(row(name, at, own.Value, true, own.Type))
else {
const value = inherited(name, at)
if (value !== undefined) permissions.push(row(name, at, value, false))
}
}
}
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
const own = permissions.find((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === role)
if (own) own.Value = 'True'
}
} else {
// Anyone else: their own values, at Role 0 and at their role.
const values = new Map<string, string>()
for (const name of names) {
const value = storedAt(name, role)?.Value ?? inherited(name, role)
if (value !== undefined) values.set(name, value)
}
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) values.set('CAN_USE_MAKER_PEN', 'True')
for (const at of role === Role.None ? [Role.None] : [Role.None, role]) {
for (const [name, value] of values) permissions.push(row(name, at, value, true))
}
}
return {
Permissions: permissions,
PhotonAccessToken: '',
RoomInstanceId: roomInstanceId,
}
}
/**
* Photon access-token handler. Auth-gated: resolves the caller, reads their current room
* instance from the shared `presence` table (see @repo/domain), works out their role in that
* room and the overrides stored on the subroom they are standing in, and returns the
* permissions + token. A player in no instance — sitting in the lobby, or an instance
* predating subroom tracking — is a visitor with the defaults.
*/
async function handlePhotonAccessToken(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
const room =
typeof instance?.roomId === 'number' ? await getRoomById(c.env.DB, instance.roomId) : null
const role = room ? roleIn(room, accountId) : Role.None
const overrides =
typeof instance?.subRoomId === 'number'
? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
: []
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, role, overrides))
}
/**
* May this caller read the room's saves? The room's creator and its co-owners always may:
* they are the ones who write and publish saves (`canManageRoom` gates both), and the
* client shows them the save history from the room's settings, wherever they are — a
* co-owner refused here sees the buttons and a list that never loads.
*
* So may anyone whose live presence puts them IN the room: they are already loading its
* scene, and the client resolves which version to load — the published one or the latest —
* from the save list, so refusing everyone but the room's managers leaves a visitor unable
* to load what the instance is actually running.
*
* Presence is the shared `presence` table the `match` heartbeat maintains, so that grant
* lasts only as long as the player is actually there (rows carry an absolute expiry and
* expired ones don't read back).
*
* And so may staff — a token carrying the `developer` / `moderator` role — in any room,
* like the other room-admin surfaces here. They are not in the room yet when this is
* read: the client asks for the save list BEFORE it matchmakes a private instance (to
* resolve which version to load), so there is no presence to grant on, and a developer
* who runs none of a room's roles was refused a private instance of it with a 403.
*
* The presence read only happens for someone who neither manages the room nor is staff,
* so the owner's and a co-owner's path stays one query, and staff's stays none.
*/
async function canReadSaves(
c: Context<App>,
room: Record<string, unknown>,
roomId: number,
accountId: number
): Promise<boolean> {
if (canManageRoom(room, accountId)) return true
if (await isStaff(c)) return true
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
return instance?.roomId === roomId
}
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
async function authedAccountId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The client build this request's token was minted for (`rn.ver`), or null when there's no
* valid token. The claim is the build the CLIENT posted at login, not this server's
* GAME_VERSION, so it identifies what the player is actually running.
*
* It is unverified — a client can claim any build — which is fine for the one thing it is
* used for here: keeping a payload away from a build it breaks. Lying about your version to
* opt IN to a broken room list only breaks your own client.
*/
async function authedGameVersion(c: Context<App>): Promise<string | null> {
return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The client builds `GET /featuredrooms/current` will serve a group to.
*
* Serving it to the 2023 client breaks the OTHER room listings — they start failing with
* NREs, apparently because the featured-room load corrupts its room cache — so the route
* was parked entirely for a while. It works on the 2025 build, so rather than stay parked
* it is gated: a build that isn't on this list gets the 404 it got when the path didn't
* exist, which is the state everything was known good in.
*
* An allow-list rather than a "this build or newer" comparison, deliberately: the thing
* being asserted is that a build was CHECKED, and a version string that sorts high (a
* debug build, say) must not opt itself in. Add a build here once it's been tried.
*/
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718.01'])
/**
* Operator-granted elevated roles — the ones the auth worker stamps from an account's
* isDeveloper/isModerator flags (see the admin CLI). Same set the `notify` / `www`
* workers gate their admin surfaces on.
*/
const STAFF_ROLES: ReadonlySet<string> = new Set(['developer', 'moderator'])
/**
* Whether the caller's token carries a staff role. Used alongside the per-room owner
* check for actions staff may take in a room they don't own.
*/
async function isStaff(c: Context<App>): Promise<boolean> {
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
return roles?.some((role) => STAFF_ROLES.has(role)) ?? false
}
/**
* Whether the `:playerId` in the path is banned from the `:roomId` in it — the read behind
* both `isBanned` routes, which differ only in the envelope they wrap the answer in.
*
* Reads the same `room_ban` rows the ban writes make and `match` refuses matchmakes on, so
* the answer is what would actually happen. A room that does not exist simply has no ban
* rows and comes back false: the question is about the ban, not about the room.
*/
async function pathBan(c: Context<App>): Promise<boolean> {
// Both routes constrain these to `[0-9]+`, so neither is ever missing — the fallback is
// only here because a helper is typed against the whole app rather than one route.
const id = (name: string) => Number.parseInt(c.req.param(name) ?? '', 10)
return isPlayerBannedFromRoom(c.env.DB, id('roomId'), id('playerId'))
}
/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */
function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401)
}
/**
* Parse an `accessibility` form field into a `RoomAccessibility` value. The client
* sends the enum NAME on the subroom route (`accessibility=Private`), not the number
* the room-level route takes, so both forms are accepted. Returns undefined when the
* field is missing or names nothing in the enum.
*/
function parseAccessibility(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined
const raw = value.trim()
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10)
const named = Object.entries(Accessibility).find(
([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase()
)
return named ? (named[1] as number) : undefined
}
/** Parse an integer from the number or numeric string a JSON body may carry. */
function parseInt10(value: unknown): number | undefined {
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
if (typeof value !== 'string') return undefined
const n = Number.parseInt(value.trim(), 10)
return Number.isNaN(n) ? undefined : n
}
/**
* The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the
* True/False picker beside the override checkbox — but a permission whose UI is something
* else carries a different value, so nothing here interprets it. A JSON boolean or number
* is rendered the way the client would have written it.
*/
function permissionValue(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'boolean') return value ? 'True' : 'False'
if (typeof value === 'number') return String(value)
return ''
}
/**
* Parse the subroom-permissions PUT body: a JSON ARRAY of
* `{ Permission, Role, Override, Type, Value }` entries.
*
* `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true
* stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the
* pair falls back to the default. It is carried through as sent.
*
* Entries without a permission name or a usable role are dropped rather than rejected —
* the client ignores the response either way, so half a table applied beats none.
*/
function parseRoomPermissions(body: unknown): RoomPermission[] {
if (!Array.isArray(body)) return []
const permissions: RoomPermission[] = []
for (const entry of body) {
if (typeof entry !== 'object' || entry === null) continue
const e = entry as Record<string, unknown>
const permission = typeof e.Permission === 'string' ? e.Permission.trim() : ''
const role = parseInt10(e.Role)
if (permission === '' || role === undefined) continue
permissions.push({
Permission: permission,
Role: role,
// Sent as a JSON boolean, unlike `Value` — accept the string form regardless.
Override: e.Override === true || String(e.Override).toLowerCase() === 'true',
Type: parseInt10(e.Type) ?? 0,
Value: permissionValue(e.Value),
})
}
return permissions
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* The room `Supports*` flags the `/restrictions` endpoint can toggle, keyed by the
* lowercased form field the client posts. Only fields present in the body are changed.
*/
const RESTRICTION_FIELDS: Record<string, string> = {
supportsscreens: 'SupportsScreens',
supportswalkvr: 'SupportsWalkVR',
supportsteleportvr: 'SupportsTeleportVR',
supportsvrlow: 'SupportsVRLow',
supportsquest2: 'SupportsQuest2',
supportsmobile: 'SupportsMobile',
supportsjuniors: 'SupportsJuniors',
}
/**
* Push a RoomUpdate notification to a player after their room changes, mirroring
* the reference server's `HubSendToPlayer(playerId, NotifFrame("RoomUpdate", room))`.
* Hub failures are logged and swallowed — the room write has already committed,
* so a hub hiccup must not fail the request.
*/
/**
* The room's `MaxPlayerCalculationMode` enum: how its player cap is meant to be read.
* `AllSubrooms` (0) and `OnlyEntrySubrooms` (1). A stored setting — nothing here derives
* `MaxPlayers` from it.
*/
const MAX_PLAYER_CALCULATION_MODES: Readonly<Record<string, number>> = {
allsubrooms: 0,
onlyentrysubrooms: 1,
}
/**
* Parse a `maxPlayerCalculationMode` field into its ordinal. The client sends the enum
* NAME (`OnlyEntrySubrooms`), matched case-insensitively; the ordinal is accepted too.
* Undefined when it names nothing in the enum, which the route refuses rather than
* defaulting — a silently-wrong mode would misreport a room's capacity.
*/
function parseMaxPlayerCalculationMode(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined
const raw = value.trim()
const named = MAX_PLAYER_CALCULATION_MODES[raw.toLowerCase()]
if (named !== undefined) return named
if (!/^\d+$/.test(raw)) return undefined
const ordinal = Number.parseInt(raw, 10)
return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined
}
async function pushRoomUpdate(
c: Context<App>,
playerId: number,
room: Record<string, unknown>
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.SubscriptionUpdateRoom,
room
)
} catch (err) {
logger.error('failed to push RoomUpdate notification', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Push a `RoomUpdate` to everyone standing in a room right now, plus anyone in `alsoNotify`.
*
* The fan-out for a change to the ROOM rather than to one player's relationship with it — a
* role changing alters what the room lets people do, and every client in it is rendering that
* room. Read from live presence across all of the room's instances, so it is whoever is
* actually there; a room nobody is in pushes to nobody.
*
* `alsoNotify` is for the player the change is ABOUT, who may not be standing in the room at
* all — a role granted to someone across the map still changes what they can do when they
* arrive. De-duplicated against the occupants, so being both doesn't earn two pushes.
*
* Each push is independent and failures are swallowed by {@link pushRoomUpdate}, so one
* unreachable player doesn't cost the rest theirs.
*/
async function pushRoomUpdateToRoom(
c: Context<App>,
roomId: number,
room: Record<string, unknown>,
alsoNotify: number[] = []
): Promise<void> {
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
for (const playerId of alsoNotify) playerIds.add(playerId)
await Promise.all([...playerIds].map((playerId) => pushRoomUpdate(c, playerId, room)))
}
/**
* Store and deliver a room-role invite — a Message of type 62 (`RoomCoOwnerInvited`), which
* is what the invited player accepts or declines from. The `Roles` entry alone is silent:
* it records the offer, this delivers it.
*
* STORED first, then pushed from the stored record, so the frame carries the row's `Id` and
* the invite survives a failed or unheard push: the invited player reads it from
* `GET /api/messages/v2/get` on their next login instead of losing it to the hub's
* best-effort queue. The frame is typed as the hub's {@link MessageReceivedPayload} so a
* renamed key fails the build rather than vanishing on the wire.
*
* `Data` is the ROLE being offered, as a string (a Message's `Data` is always a string on
* the wire). It is what the invited player's client reads the offer off, and what it must
* send back as `role` when it accepts — the accept refuses anything that isn't the role
* standing on their entry, so an invite the client can't read the tier of is one it can't
* answer. `PlayerEventId` is null; the room is in `RoomId`. The hub drops null keys when it
* builds the frame, so it reaches the client absent rather than as null, which its decoder
* reads the same way.
*
* Best-effort on the PUSH only: the role entry and the message row have both committed, so
* a hub hiccup must not fail the request.
*/
async function pushRoleInvite(
c: Context<App>,
roomId: number,
fromAccountId: number,
toAccountId: number,
role: number
): Promise<void> {
const stored = await createNotification(c.env.DB, {
FromPlayerId: fromAccountId,
ToPlayerId: toAccountId,
Type: MessageType.RoomCoOwnerInvited,
// The role being offered, as a string — a Message's `Data` is a string on the wire.
Data: String(role),
RoomId: roomId,
})
const message: MessageReceivedPayload = {
Id: stored.Id,
FromPlayerId: stored.FromPlayerId,
SentTime: stored.SentTime,
Type: stored.Type,
Data: stored.Data,
RoomId: stored.RoomId,
PlayerEventId: stored.PlayerEventId,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toAccountId,
NotificationType.MessageReceived,
{ ...message, ToPlayerId: stored.ToPlayerId }
)
} catch (err) {
logger.error('failed to push RoomCoOwnerInvited MessageReceived notification', {
messageId: stored.Id,
roomId,
toAccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
* action a person took rather than one the system inferred, which is what a room ban
* is. The rest of the enum, for reference: 2 Harassment, 3 Cheating, 5 AFK, 6 Misc,
* 7 Underage, 10 VoteKick, 100–104 CoC_*, 200 InappropriateClothing.
*/
const REPORT_CATEGORY_MODERATOR = -1
/**
* Move a freshly banned player out of the room and into their own DORM, so the ban takes
* effect on everything that reads presence right now rather than when their row next expires.
* The kick push ejects their client, but until presence moves they still count toward the
* instance's player total (keeping a full room full), still show as standing in the room to
* friends, and still pass the presence-based grants — reading the room's staged saves among
* them.
*
* Only when their presence puts them in THIS room. A player banned from a room they are not
* standing in keeps their presence: a ban here is no reason to knock them offline from wherever
* they actually are. The same rule the instant kick in `api` applies to its instance.
*
* The instance they left gets its fullness recomputed, as every other departure does, so a room
* they were filling opens back up. Best-effort like the push: the ban row is already
* committed, and a failure here must not turn a ban that happened into an error response.
*
* Returns the instance they were removed from, or null when they were not standing in the
* room — which is what decides whether they get a kick frame at all.
*/
async function evictBannedPlayer(c: Context<App>, ban: RoomBan): Promise<number | null> {
try {
const instance = (await getPresence<PresenceView>(c.env.DB, ban.BannedPlayerId))?.roomInstance
if (instance?.roomId !== ban.RoomId) return null
// Moved to their own dorm rather than deleted: the client goes there either way, and with
// no presence to read it arrives not knowing where it is and loads the dorm a second
// time. This also recomputes the fullness of the instance they left.
await movePlayerToDorm(c.env.DB, ban.BannedPlayerId)
return instance.roomInstanceId ?? null
} catch (err) {
logger.error('failed to clear a banned player’s presence', {
playerId: ban.BannedPlayerId,
roomId: ban.RoomId,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
/** An ISO timestamp as the client's ban record carries it: UTC, to the second. */
function toBanTime(iso: string): string {
return iso.replace(/\.\d+Z$/, 'Z')
}
/**
* One ban as the history route serves it. Seven keys, and the ORDER is the client's: the
* derived members before the base ones, so `AccountId` is fifth. `Reason` is a non-nullable
* string on the client, so a ban issued without one is `""`, not null.
*/
function toBanRecordDto(ban: RoomBanRecord) {
return {
Status: ban.Status,
UnbannedByAccountId: ban.UnbannedByAccountId,
BanEndTime: ban.EndedAt === null ? null : toBanTime(ban.EndedAt),
Reason: ban.Reason ?? '',
AccountId: ban.AccountId,
BannedByAccountId: ban.BannedByAccountId,
BanStartTime: toBanTime(ban.StartedAt),
}
}
/**
* Eject a room-banned player from the instance they are standing in — a `ModerationKick`
* frame (id 22), the one the client acts on to leave a room. The `room_ban` row is what keeps
* them out afterwards: `match` refuses their matchmakes into this room with `BannedFromRoom`
* (55). This frame only gets them out of the session they are in right now.
*
* It must NOT look like an account ban, and it once did. `IsBan: true` on this frame is what
* the client's moderation screen reads as a ban from the GAME — it is exactly what the staff
* account ban (`www`) sends — so a room ban sent that way showed players a game-wide ban
* screen, and with a non-zero `duration` a timed one, when nothing but one room was closed to
* them. So, like `api`'s instant kick:
*
* - `isBan` false and `duration` 0. The frame is a kick; the ban is server-side.
* - EPHEMERAL, never queued. A queued frame is delivered on the player's next connect, which
* would eject them from some unrelated session later, or greet them with it on login.
* - Sent only when they were standing in THIS room — `gameSessionId` is the instance they were
* evicted from. A player banned from a room they are not in has nothing to be ejected from
* and hears nothing; they meet the ban as a refused matchmake if they try to join.
*
* The payload is the client's moderation shape, camelCase, in wire order. `isHostKick` says the
* room's HOST ejected them rather than a room vote-kick; the only false case today is a staff
* moderator acting in a room they do not host. `playerIdReporter` is whoever caused it.
*
* Best-effort: the ban row has already committed, so a hub hiccup must not fail the request.
*/
async function pushRoomBan(
c: Context<App>,
ban: RoomBan,
gameSessionId: number,
roomName: string,
isHostKick: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
[ban.BannedPlayerId],
NotificationType.ModerationKick,
{
reportCategory: REPORT_CATEGORY_MODERATOR,
duration: 0,
gameSessionId,
isHostKick,
message: ban.Reason
? `You have been banned from ${roomName}. Reason: ${ban.Reason}`
: `You have been banned from ${roomName}.`,
playerIdReporter: ban.BannedByAccountId,
isBan: false,
isVoiceModAutoban: false,
}
)
} catch (err) {
logger.error('failed to push ModerationKick notification', {
playerId: ban.BannedPlayerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
*/
function roomResult(
c: Context<App>,
fields: { Success: boolean; Value?: unknown; ErrorId?: string; Error?: string }
) {
return c.json({
Success: fields.Success,
Value: fields.Value ?? null,
ErrorId: fields.ErrorId ?? null,
Error: fields.Error ?? null,
})
}
/**
* The room save's `value.subRoomDataSave` — a camelCase projection with a DIFFERENT
* field set from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC
* versions, no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`
* that `CurrentSave` never shows). Don't unify the two without checking the client.
*
* `unityAsset`/`unityAssetHash` name one baked main bundle when this save has a Studio
* build attached, and stay null for a maker-pen save. `target` null prefers Windows (0).
* A caller that named a target gets that platform or null, never the other one.
*/
function toSaveResponse(save: Record<string, unknown>, target: number | null = null) {
const str = (v: unknown) => (typeof v === 'string' ? v : null)
const num = (v: unknown) => (typeof v === 'number' ? v : null)
const baked = pickBakedBundle(save, target)
return {
subRoomDataSaveId: num(save.SubRoomDataSaveId),
subRoomId: num(save.SubRoomId),
unityAssetId: str(save.UnityAssetId),
unityAsset: baked?.filename ?? null,
unityAssetHash: baked?.hash ?? null,
dataBlob: str(save.DataBlob) ?? '',
dataBlobHash: str(save.DataBlobHash),
savedByAccountId: num(save.SavedByAccountId),
savedOnPlatform: num(save.SavedOnPlatform) ?? 0,
savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
description: str(save.Description),
createdAt: str(save.CreatedAt) ?? '',
}
}
/** The `unityAssetTarget` query, or null when the caller did not name an integer. */
function unityAssetTargetQuery(c: Context<App>): number | null {
const raw = c.req.query('unityAssetTarget')
if (raw === undefined || raw === '') return null
const target = Number(raw)
return Number.isInteger(target) ? target : null
}
/** One main bundle off a save: Windows when `target` is null, else that target only. */
function pickBakedBundle(
save: Record<string, unknown>,
target: number | null
): { filename: string; hash: string | null } | null {
if (!Array.isArray(save.UnitySubAssets)) return null
const rows = save.UnitySubAssets.filter(
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } =>
typeof item === 'object' && item !== null
)
const chosen =
target === null
? (rows.find((row) => row.Target === 0) ?? rows[0])
: rows.find((row) => row.Target === target)
if (!chosen || typeof chosen.Filename !== 'string' || chosen.Filename === '') return null
const hash = typeof chosen.Hash === 'string' && chosen.Hash !== '' ? chosen.Hash : null
return { filename: chosen.Filename, hash }
}
/** Drop baked bundles whose `Target` is not the one the caller asked for. */
function keepUnityAssetTarget(save: Record<string, unknown>, target: number) {
if (!Array.isArray(save.UnitySubAssets)) return
save.UnitySubAssets = save.UnitySubAssets.filter((item) => {
if (typeof item !== 'object' || item === null) return false
return (item as { Target?: unknown }).Target === target
})
}
/** Narrow every subroom's current save to `target`. No-op when the caller named none. */
function filterRoomUnityAssets(room: Record<string, unknown>, target: number | null) {
if (target === null || !Array.isArray(room.SubRooms)) return
for (const sub of room.SubRooms) {
if (typeof sub !== 'object' || sub === null) continue
const save = (sub as { CurrentSave?: unknown }).CurrentSave
if (typeof save === 'object' && save !== null) {
keepUnityAssetTarget(save as Record<string, unknown>, target)
}
}
}
/**
* A save row with its Unity-asset payloads left out — what `…/saves/no_unity_assets`
* lists. PascalCase like the rows `…/saves` serves, minus the two hydrated asset arrays
* (`UnitySubAssets`, `ReferencedUnityAssets`) and `Tags`, keeping only the asset
* IDENTIFIERS. The point of the variant is weight: those arrays are the heavy part of a
* save row, and a history list doesn't render them.
*
* `UnityAssetId` is the one field that differs rather than disappearing — always present
* and null when the save carried none, where the full row omits the key entirely.
*
* Built key by key rather than by deleting from the stored save: a save is stored as an
* opaque blob, so a future field would otherwise leak into this projection unannounced.
*/
function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
const str = (v: unknown) => (typeof v === 'string' ? v : null)
const num = (v: unknown) => (typeof v === 'number' ? v : null)
return {
SubRoomDataSaveId: num(save.SubRoomDataSaveId),
SubRoomId: num(save.SubRoomId),
UnityAssetId: str(save.UnityAssetId),
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
? save.ReferencedUnityAssetIds
: [],
DataBlob: str(save.DataBlob) ?? '',
DataBlobHash: str(save.DataBlobHash),
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
OMVersion: num(save.OMVersion) ?? 0,
SavedByAccountId: num(save.SavedByAccountId),
SavedOnPlatform: num(save.SavedOnPlatform) ?? 0,
SavedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
Description: str(save.Description) ?? '',
ModerationState: num(save.ModerationState) ?? 0,
CreatedAt: str(save.CreatedAt) ?? '',
UgcSubVersion: num(save.UgcSubVersion) ?? 0,
}
}
/**
* One save as `GET …/subrooms/:sid/saves/:saveId` answers it — the PascalCase row the
* `…/saves` list serves, NOT the camelCase `subRoomDataSave` the room save that created
* it returned (an earlier version served that, and the client rendered a save whose
* values were all missing). Two things differ from the list row, both observed against
* the reference:
*
* - Each `UnitySubAssets` entry is `{ UnityAssetId, UnityAsset, UnityAssetHash }` — the
* stored `Filename`/`Hash` under the names the client's save-detail decoder reads —
* and the save's own main bundle is lifted to top-level `UnityAsset`/`UnityAssetHash`
* (the named target's, Windows when none was named; both null for a maker-pen save).
* - No `Tags`, and `UnityAssetId` is always present (null when the save carried none).
*
* Built key by key like {@link toSaveWithoutUnityAssets}, for the same reason.
*/
function toSaveDetail(save: Record<string, unknown>, target: number | null) {
const str = (v: unknown) => (typeof v === 'string' ? v : null)
const num = (v: unknown) => (typeof v === 'number' ? v : null)
const main = pickBakedBundle(save, target)
const bundles = (Array.isArray(save.UnitySubAssets) ? save.UnitySubAssets : []).filter(
(item): item is Record<string, unknown> =>
typeof item === 'object' &&
item !== null &&
(target === null || (item as { Target?: unknown }).Target === target)
)
return {
UnitySubAssets: bundles.map((row) => ({
UnityAssetId: str(row.UnityAssetId),
UnityAsset: str(row.Filename),
UnityAssetHash: str(row.Hash),
})),
ReferencedUnityAssets: Array.isArray(save.ReferencedUnityAssets)
? save.ReferencedUnityAssets
: [],
UnityAsset: main?.filename ?? null,
UnityAssetHash: main?.hash ?? null,
SubRoomDataSaveId: num(save.SubRoomDataSaveId),
SubRoomId: num(save.SubRoomId),
UnityAssetId: str(save.UnityAssetId),
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
? save.ReferencedUnityAssetIds
: [],
DataBlob: str(save.DataBlob) ?? '',
DataBlobHash: str(save.DataBlobHash),
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
OMVersion: num(save.OMVersion) ?? 0,
SavedByAccountId: num(save.SavedByAccountId),
SavedOnPlatform: num(save.SavedOnPlatform) ?? 0,
SavedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
Description: str(save.Description) ?? '',
ModerationState: num(save.ModerationState) ?? 0,
CreatedAt: str(save.CreatedAt) ?? '',
UgcSubVersion: num(save.UgcSubVersion) ?? 0,
}
}
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
function roomEnvelope(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
}
/**
* The same envelope for the ban write, whose `value` is the BAN rather than the room —
* a ban isn't part of the room the client renders, so there is no updated room to send.
*/
const banEnvelope = roomEnvelope
/**
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
* no entity. PascalCase with a lowercase `error_id` — the same mixed casing the
* unprefixed isBanned route serves — NOT the room mutations' lowercase envelope.
*/
function leaderboardEnvelope(c: Context<App>, error: string | null = null) {
return c.json({ Success: error === null, Error: error, error_id: null })
}
/** The same bare envelope for the room progression write (`POST /rooms/:id/experience`). */
const experienceEnvelope = leaderboardEnvelope
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json(await getRoomsByCreator(c.env.DB, accountId))
}
/**
* The caller's owned rooms, excluding their dorm. The dorm is auto-provisioned,
* not a room the player made, so it doesn't belong in the "rooms you own" list.
*/
async function ownedRoomsExcludingDorm(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const rooms = await getRoomsByCreator(c.env.DB, accountId)
return c.json(rooms.filter((r) => r.IsDorm !== true))
}
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
const DEFAULT_SUGGESTION_COUNT = 10
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — its "My rooms" list is this worker's
// `GET /rooms/ownedby/me` — so the responses need CORS headers or the browser
// discards them. `origin: '*'` is deliberate and safe HERE because these endpoints
// authenticate with a bearer token in the `Authorization` header, never a cookie: a
// hostile page can't read another origin's stored token, so there is no ambient
// credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
// Studio loaders resolve asset GUIDs before downloading the published bundles.
.use('/unity_assets/baked/bulk', async (c, next) => {
// Preserve upstream's public form-based Studio resolver. JSON imports need
// the caller's presence/ownership before resolving legacy save metadata.
if (c.req.method !== 'POST' || !c.req.header('content-type')?.includes('application/json'))
return next()
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
let body: Record<string, unknown>
try {
body = await c.req.json<Record<string, unknown>>()
} catch {
return c.body(null, 400)
}
if (!body || typeof body !== 'object' || Array.isArray(body)) return c.body(null, 400)
const entries = Object.entries(body)
const ids = entries.find(([key]) => /^(unityAssetIds|assetIds|ids)$/i.test(key))?.[1]
const target = Number(
entries.find(([key]) => /^(unityAssetTarget|assetTarget|target)$/i.test(key))?.[1] ?? 0
)
const version = Number(
entries.find(([key]) => /^(unityAssetVersion|assetVersion|version)$/i.test(key))?.[1] ?? 5
)
if (
!Array.isArray(ids) ||
ids.length > 128 ||
ids.some(
(id) => typeof id !== 'string' || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)
)
) {
return c.body(null, 400)
}
if (!Number.isInteger(target) || !Number.isInteger(version)) return c.body(null, 400)
if (ids.length === 0) return c.json([])
const requested = new Set(ids.map((id: string) => id.toLowerCase()))
const studio = await getBakedUnityAssets(c.env.DB, [...requested], target, version)
if (studio.length === requested.size) return c.json(studio)
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
const { results } = await c.env.DB.prepare(
`
SELECT saves.data FROM subroom_save saves
JOIN subroom subs ON subs.current_save_id = saves.sub_room_data_save_id
JOIN room rooms ON rooms.room_id = subs.room_id
WHERE (rooms.accessibility = 1 OR rooms.creator_account_id = ?1 OR rooms.room_id = ?2)
AND (lower(json_extract(saves.data, '$.UnityAssetId')) IN (SELECT value FROM json_each(?3))
OR EXISTS (SELECT 1 FROM json_each(saves.data, '$.UnitySubAssets') asset
WHERE lower(json_extract(asset.value, '$.UnityAssetId')) IN (SELECT value FROM json_each(?3)))
OR EXISTS (SELECT 1 FROM json_each(saves.data, '$.ReferencedUnityAssets') asset
WHERE lower(json_extract(asset.value, '$.UnityAssetId')) IN (SELECT value FROM json_each(?3))))
`
)
.bind(accountId, instance?.roomId ?? -1, JSON.stringify([...requested]))
.all<{ data: string }>()
const assets = new Map<
string,
{ UnityAssetId: string; Target: number; Version: number; Filename: string; Hash: string }
>()
for (const asset of studio) assets.set(asset.UnityAssetId.toLowerCase(), asset)
for (const row of results) {
const save = JSON.parse(row.data) as Record<string, unknown>
const candidates = [
save,
...(Array.isArray(save.UnitySubAssets) ? save.UnitySubAssets : []),
...(Array.isArray(save.ReferencedUnityAssets) ? save.ReferencedUnityAssets : []),
]
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue
const asset = candidate as Record<string, unknown>
const id = typeof asset.UnityAssetId === 'string' ? asset.UnityAssetId.toLowerCase() : ''
const filename = asset.UnityAsset
if (!requested.has(id) || typeof filename !== 'string') continue
const build = /\.(\d+)\.(\d+)\.assetbundle$/.exec(filename)
if (!build || Number(build[1]) !== target || Number(build[2]) !== version) continue
if (assets.has(id)) continue
assets.set(id, {
UnityAssetId: id,
Target: target,
Version: version,
Filename: filename,
Hash: typeof asset.UnityAssetHash === 'string' ? asset.UnityAssetHash : '',
})
}
}
return c.json([...requested].flatMap((id) => (assets.has(id) ? [assets.get(id)!] : [])))
})
.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',
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 there’s 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 ?? {})
}
)
// Room search: `query` is space/`+`-separated terms — `#tag` matches room tags,
// plain terms match the name. Public, non-dorm rooms not opted out of search
// (`ExcludeFromSearch`) only — `ExcludeFromLists` is the feeds' flag and does not hide
// a room here. Paginated via skip/take. Returns `{ Results, TotalResults }`.
//
// `#community` is the one tag term that isn't a tag lookup: it is the browse chip's
// pseudo-tag reaching search, and means rooms a PLAYER made rather than the seeded
// first-party ones — the same filter `/rooms/hot?tag=community` applies.
.get(
'/rooms/search',
describeRoute({
tags: ['Discovery'],
summary: 'Search rooms',
description: [
'Full room search. `query` is space- or `+`-separated terms: a `#tag` term matches the',
'room’s tags, a plain term matches its name. Public, non-dorm rooms only, and never one',
'whose `ExcludeFromSearch` is set; `ExcludeFromLists` hides a room from the feeds, not',
'from here.',
'`#community` is a pseudo-tag no room carries — it narrows to rooms a player made',
'(anything the system Coach account didn’t create), like `/rooms/hot?tag=community`.',
].join(' '),
parameters: [
stringQuery(
'query',
'Search terms — `#tag` matches tags, plain terms match the name, `#community` matches player-made rooms'
),
...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))
}
)
// Type-ahead for the search box (`?query=r&take=4&searchSessionId=…`). A bare array of
// plain STRINGS — suggestions, not rooms and not an envelope.
//
// Every suggestion is something `/rooms/search` will actually find, so submitting one
// can't come back empty: they're drawn from room names (what a plain search term
// matches) and room tags (what a `#tag` term matches), over the same public, non-dorm,
// searchable rooms search considers — a room opted out of search is not suggested
// either. Tags come back with their `#` for that reason.
//
// `searchSessionId` is the client's own correlation id for a typing session — it ties
// the keystrokes and the eventual search together in the reference's analytics. Nothing
// here records searches, so it is accepted and ignored.
.get(
'/rooms/autocomplete_search',
describeRoute({
tags: ['Discovery'],
summary: 'Search suggestions for the search box',
description: [
'Type-ahead suggestions as a bare array of strings — not rooms, not an envelope.',
'Drawn from room names and room tags over the public, non-dorm, searchable rooms',
'`/rooms/search` searches, so every suggestion is one that finds something when',
'submitted and a room opted out of search is never suggested; a tag',
'suggestion carries its `#` so it searches by tag. A `query` starting with `#`',
'suggests tags only. Matches that START with the query come first, then ones that',
'merely contain it, alphabetically within each — the same query always suggests the',
'same things. `take` caps the list (4 is what the client asks for);',
'`searchSessionId` is the client’s analytics correlation id and is ignored.',
].join(' '),
parameters: [
stringQuery('query', 'What the player has typed so far. `#` prefix suggests tags only'),
intQuery('take', 'How many suggestions to return (default 10)'),
stringQuery('searchSessionId', 'The client’s typing-session id. Accepted and ignored'),
],
responses: { 200: json(SearchSuggestions, 'The suggestions, best match first') },
}),
async (c) => {
const take = Number.parseInt(c.req.query('take') ?? '', 10)
return c.json(
await autocompleteRoomSearch(
c.env.DB,
c.req.query('query') ?? '',
Number.isNaN(take) ? DEFAULT_SUGGESTION_COUNT : take
)
)
}
)
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
// instances' presence), then stored engagement, optionally filtered to a single
// `tag` (e.g. `rro`). `tag=new` and `tag=community` are pseudo-tags no room
// carries: `new` serves the player-made (non-RRO) rooms newest-first, `community`
// keeps the normal ordering but drops the rooms the Coach account created.
// Paginated via skip/take (take defaults to 100). Returns
// `{ Results, TotalResults }` like search.
.get(
'/rooms/hot',
describeRoute({
tags: ['Discovery'],
summary: 'The “hot” rooms feed',
description: [
'Public, non-dorm rooms ordered by how many players are in them right now — live',
'presence summed across each room’s instances — falling back to stored engagement',
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
'filter chips post one, e.g. `rro`). The `new` and `community` chips are pseudo-tags —',
'no room carries either. `new` instead serves the player-made (non-RRO) rooms, newest',
'first; `community` keeps the ordering above but serves only rooms the Coach account',
'(the system account owning the seeded first-party rooms) did not create.',
].join(' '),
parameters: [
stringQuery(
'tag',
'Restrict to rooms carrying this tag (or `new`/`community`, pseudo-tags)'
),
...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))
}
)
// Curated room playlists — the editorially grouped room lists the discovery pages'
// `PlaylistById` sections draw from. Nothing curates one yet, so this is an empty array:
// the client reads that as "no playlists" and simply draws no playlist rows, where a 404
// leaves it retrying a feed that isn't coming.
.get(
'/rooms/curated_playlists',
describeRoute({
tags: ['Discovery'],
summary: 'Curated room playlists',
description: [
'The curated room playlists the discovery pages’ playlist sections draw from. There',
'is no editorial curation on this server yet, so this is always an empty array —',
'which the client reads as “no playlists” and draws nothing, rather than the 404 it',
'would keep retrying.',
].join(' '),
responses: { 200: json(CuratedPlaylists, 'Always an empty list') },
}),
(c) => c.json([])
)
// The `rising` carousel — the discovery pages fill a `CarouselEndpoint` section by
// slug, and this is the one the client asks for by name. Trending means someone is IN
// the room right now: unlike the hot feed, which ranks by head-count but still lists
// the empty rooms underneath, this one FILTERS on live presence, so a quiet server
// serves an empty carousel rather than a stale one.
//
// Paged like the hot feed (`skip`/`take`, take defaults to 100) and answers the same
// `{ Results, TotalResults }` envelope its sibling feeds do. Only `rising` is served —
// the other slugs in the discovery catalogue (`foryou`, `staffpicks`, the
// `*_algoendpoint` rows) keep 404ing until each is given a feed of its own.
.get(
'/rooms/carousel/rising',
describeRoute({
tags: ['Discovery'],
summary: 'The “rising” rooms carousel',
description: [
'The rooms players are in RIGHT NOW, busiest first — the trending carousel. Live',
'presence is a filter here, not just a sort: a room nobody is standing in is absent',
'entirely, so this is empty when the server is quiet rather than falling back to',
'stored engagement the way `/rooms/hot` does. Ties break on engagement and then',
'RoomId, so equally busy rooms page stably. Public, non-dorm, listable rooms only.',
].join(' '),
parameters: pageParams(100),
responses: { 200: json(PagedRooms, 'The carousel page') },
}),
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 getTrendingRooms(c.env.DB, 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',
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',
describeRoute({
tags: ['Discovery'],
summary: 'Recommended rooms',
description: [
'Public, non-dorm rooms ranked by engagement. Unlike search and hot this is a BARE',
'array — the client’s 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, at most ten of them. No real curation yet, so
// `current` just returns a shuffled sample of eligible rooms in the featured-group shape.
//
// Gated on the caller's BUILD, not just their token: this payload breaks the 2023
// client — its other room listings start failing with NREs, apparently because the
// featured-room load corrupts the client's room cache — while the 2025 build renders it
// fine. So a build on FEATURED_ROOMS_VERSIONS gets the group and every other build gets
// a 404, which is exactly what it got while this path was parked and everything worked.
//
// Auth-gated for the version, really: the build comes off the token's `rn.ver` claim, so
// there is nowhere to read it from without one. Nothing in the answer is per-player.
.get(
'/featuredrooms/current',
describeRoute({
tags: ['Discovery'],
summary: 'Featured rooms',
description: [
'A single always-active group of featured rooms: a random shuffle of eligible public',
'rooms, since there is no editorial curation yet. Capped at 10 rooms — a featured',
'group is a short selection, not the whole room list — and the cap is applied after',
'the shuffle, so each request serves a different sample.',
'',
'Restricted by CLIENT BUILD. Serving this to the 2023 client breaks its other room',
'listings (NREs, apparently from the featured-room load corrupting its room cache), so',
'only the builds known to render it — `20250718.01` today — get the group; anything',
'else gets a 404, the same answer it got while the route was parked. The build is read',
'from the token’s `rn.ver` claim, which is why this needs a token at all: nothing in',
'the group itself is per-player.',
].join('\n'),
security: AUTHED,
responses: {
200: json(FeaturedRoomGroupDto, 'The featured-room group'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'The caller’s client build is not one this is served to' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const version = await authedGameVersion(c)
if (version === null || !FEATURED_ROOMS_VERSIONS.has(version)) {
logger.info('featured rooms withheld: unsupported client build', { accountId, version })
return c.notFound()
}
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',
describeRoute({
tags: ['Rooms'],
summary: 'Look up several rooms at once',
description: [
'Rooms by `id` — repeated `id` params, a comma-separated list, or both — or a single',
'`name`. Ids that aren’t in D1 are simply absent from the result rather than an error —',
'the client reads an empty result as NoSuchRoom. `excludePrivateRooms=True` drops rooms',
'that are not publicly visible, as on the POST.',
].join(' '),
parameters: [
stringQuery('id', 'Room ids — repeated `id` fields, comma-separated, or both'),
stringQuery('name', 'A single room name. Ignored when `id` is given'),
stringQuery('excludePrivateRooms', 'True drops rooms that are not publicly visible'),
],
responses: {
200: json(RoomDto.array(), 'The rooms that matched (missing ids are omitted)'),
400: json(
MissingLookupParam,
'Neither `id` nor `name` was supplied, or more than 100 ids were'
),
},
}),
async (c) => {
// `id` repeats (`?id=641&id=657`) as often as it is comma-separated, and the client
// spells it both ways — read every occurrence, then split each on commas.
const idParams = c.req.queries('id') ?? []
const nameParam = c.req.query('name')
if (idParams.length === 0 && !nameParam) {
return c.json("Either 'id' or 'name' query parameter is required", 400)
}
if (idParams.length > 0) {
const ids = idParams.flatMap(allIds)
if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
const rooms = await getRoomsByIds(c.env.DB, ids)
return c.json(
excludePrivateRooms(c.req.query('excludePrivateRooms'))
? rooms.filter((r) => r.Accessibility === 1)
: rooms
)
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ? [room] : [])
}
)
// The same bulk lookup as a POST, which is the form the client sends: the ids ride in a
// form-urlencoded body of repeated `id` fields (`id=888&id=532&…`) rather than a query
// string, because it asks for a whole room list at once — 70-odd ids in the wild.
//
// `excludePrivateRooms=True` drops rooms that are not publicly visible; the client sends
// `False`, and absent means False, which is also what the GET does. Note this is a filter
// the CALLER asks for, not an access check: a room id is not a secret (the client only
// has ids it was already given), and the GET has always answered by id regardless of
// accessibility — a player's own unpublished room has to resolve here or it vanishes from
// their lists.
.post(
'/rooms/bulk',
describeRoute({
tags: ['Rooms'],
summary: 'Look up several rooms at once (bulk POST)',
description: [
'Rooms by id, as a form body of repeated `id` fields — the form the client sends, since',
'it asks about a whole room list at once. Ids that aren’t in D1 are simply absent from',
'the result rather than an error, so the array can be shorter than the request. At most',
'100 ids per call (D1 binds one parameter each); more is a 400.',
'',
'`excludePrivateRooms=True` drops rooms that are not publicly visible. It is a filter',
'the caller asks for, not an access check — like the GET, this answers by id whatever',
'the room’s accessibility, which is what makes a player’s own unpublished room resolve.',
].join('\n'),
requestBody: form(BulkRoomsRequest, 'The room ids, plus the optional filter'),
responses: {
200: json(RoomDto.array(), 'The rooms that matched (missing ids are omitted)'),
400: json(TooManyLookupIds, 'More than 100 ids were asked for'),
},
}),
async (c) => {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const field = (name: string): string[] => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const value = key === undefined ? [] : body[key]
return (Array.isArray(value) ? value : [value]).filter(
(v): v is string => typeof v === 'string'
)
}
// Repeated fields, and each value may itself be comma-separated — the GET's spelling,
// accepted here too so one body shape doesn't have to be guessed at.
const ids = field('id').flatMap(allIds)
if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
const rooms = await getRoomsByIds(c.env.DB, ids)
const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0])
return c.json(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms)
}
)
// 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. None of them
// filter on Accessibility: these are the owner's own "My Rooms" lists, so a room
// they haven't published yet (a fresh clone is Private) has to show up here.
// Only the public `ownedby/:accountId` profile list is accessibility-filtered.
.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 caller’s rooms'), 401: UNAUTHORIZED_RESPONSE },
}),
ownedRooms
)
.get(
'/rooms/ownedby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller owns (excluding their dorm)',
description: [
'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
'`createdby/me` for everything the account created. Accessibility is deliberately NOT',
'filtered — this is the owner’s own list, so unpublished (Private) rooms appear, unlike',
'the public `ownedby/{accountId}` profile list.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomDto.array(), 'The caller’s 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 caller’s rooms'), 401: UNAUTHORIZED_RESPONSE },
}),
ownedRooms
)
// Rooms the caller works on — the ones they CREATED plus anyone else's that name them in
// `Roles` (Host, Moderator or CoOwner). Auth-scoped: `me` resolves from the bearer token,
// and there is no query string or body to read.
//
// Created rooms used to be excluded, on the grounds that a room's `Roles` names its
// creator too and the client shows "owned" and "contributed" separately. That left the
// list empty for every account that had only built its own rooms — most of them — so it
// now overlaps `createdby/me` rather than coming back empty. The dorm stays out, as it
// does on `ownedby/me`. Like the other `*by/me` lists it answers a bare array of the
// canonical room DTO — no envelope, no paging wrapper — and doesn't filter on
// accessibility, since a contributor is working on the room whether or not it's
// published.
.get(
'/rooms/contributedby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller owns or contributes to',
description: [
'Every room the caller works on, as a bare array: the ones they CREATED plus the ones',
'that name them in their `Roles` — Host, Moderator or CoOwner. Overlaps',
'`createdby/me` deliberately, so a client rendering one list sees everything; the',
'dorm is excluded as it is on `ownedby/me`. Every role tier counts, not only the',
'owner-level ones, and accessibility is not filtered: a contributor works on the room',
'whether or not it is published.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomDto.array(), 'The rooms the caller owns or contributes to (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json(await getContributedRooms(c.env.DB, accountId))
}
)
// The caller's own dorm, as its ID ALONE — a bare JSON number, not the room. The
// caller follows up with `GET /rooms/{roomId}` when it wants the room itself.
//
// Gets-or-creates, exactly as entering a dorm does (`match`): the provisioning is the
// point of the call as much as the answer is, so a player who has never been to their
// dorm gets one minted here rather than a 404, and the id is stable from then on.
.get(
'/dormroom/me',
describeRoute({
tags: ['My rooms'],
summary: 'The caller’s dorm id',
description: [
'The `RoomId` of the caller’s personal dorm, as a bare JSON number — NOT the room:',
'fetch that from `GET /rooms/{roomId}` with the id this returns.',
'',
'The dorm is provisioned on first access (cloned from the seeded template dorm), so',
'this answers for any authed caller and never 404s, and calling it again returns the',
'same id.',
].join(' '),
security: AUTHED,
responses: { 200: json(DormRoomId, 'The caller’s dorm id'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const dorm = await getOrCreateDormRoom(c.env.DB, accountId)
return c.json(Number(dorm.RoomId))
}
)
// 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]+}',
describeRoute({
tags: ['Rooms'],
summary: 'Another player’s public rooms',
description: [
'The rooms an account owns that are publicly viewable — what the client shows on a',
'player’s 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 account’s public rooms') },
}),
async (c) =>
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
)
// A player's showcased rooms — the hand-picked rail the client draws on a profile,
// separate from `ownedby/{accountId}` (which is everything public they own). Stub →
// empty list: nothing stores a showcase yet, and an empty rail is what a player who
// has picked nothing looks like, where a 404 leaves the profile half-drawn. No auth,
// matching the profile list it sits beside — a showcase is public by definition.
.get(
'/showcase/:playerId{[0-9]+}',
describeRoute({
tags: ['Rooms'],
summary: 'A player’s showcased rooms',
description: [
'The rooms a player has showcased on their profile, as a bare array. Nothing stores a',
'showcase yet, so this is a stub serving an empty list — which the client reads as',
'“nothing showcased”, the same as a player who has picked none. Unlike',
'`ownedby/{accountId}`, which lists everything public the account owns, a showcase is',
'a chosen subset. No auth: a profile is public.',
].join(' '),
parameters: [playerIdParam],
responses: { 200: json(ShowcasedRooms, 'An empty list') },
}),
(c) => c.json([])
)
// 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',
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',
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))
}
)
// Another player's visited rooms — what the client shows on their profile. Auth-gated
// (401) but otherwise open: anyone can look at anyone's history, as they can at the
// `ownedby/{accountId}` list beside it. Registered after `visitedby/me` so the
// literal path wins. Paginated via skip/take (take defaults to 100) and, like
// `visitedby/me`, a bare array — the client's room-source loaders expect a plain
// list, not a page.
//
// What is hidden is the ROOM, not the history: everyone else sees only the LISTABLE
// rooms of it (public, not a dorm, not opted out of lists — the same cut as
// `ownedby/{accountId}`). A visit is stamped wherever the player goes, so the raw
// history names their unpublished builds and the private rooms they were invited
// into, and served whole it leaked rooms nobody has released. Reading your OWN
// history by id is the full list, exactly as `visitedby/me` serves it.
.get(
'/rooms/visitedby/:playerId{[0-9]+}',
describeRoute({
tags: ['Rooms'],
summary: 'Another player’s visited rooms',
description: [
'The rooms another player has visited, as a bare array. Any authed caller may read',
'anyone’s history, but only its listable rooms (public, not a dorm, not opted out of',
'lists): the player’s unpublished builds and the private rooms they were invited',
'into stay off the list. Asking for your own id serves the full history, as',
'`visitedby/me` does.',
].join(' '),
security: AUTHED,
parameters: [playerIdParam, ...pageParams(100)],
responses: {
200: json(RoomDto.array(), 'That player’s visited rooms'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const playerId = Number.parseInt(c.req.param('playerId'), 10)
// Your own history is the full one (the client sometimes sends the id rather
// than `me`); anyone else's is cut to its listable rooms.
const own = playerId === accountId
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(
own
? await getVisitedRooms(c.env.DB, playerId, skip, take)
: await getPublicVisitedRooms(c.env.DB, playerId, 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',
describeRoute({
tags: ['Interaction'],
summary: 'The caller’s 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',
describeRoute({
tags: ['Interaction'],
summary: 'Toggle the caller’s 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',
describeRoute({
tags: ['Interaction'],
summary: 'Un-cheer a room',
description: [
'Clears the cheer outright, where the PUT toggles it. Idempotent — un-cheering a room',
'that isn’t 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 caller’s 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',
describeRoute({
tags: ['Interaction'],
summary: 'Un-favorite a room',
description: [
'Clears the favorite outright, where the PUT toggles it. Idempotent — un-favoriting a',
'room that isn’t 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',
describeRoute({
tags: ['Room settings'],
summary: 'Clone a room',
description: [
'Copies a room’s 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 keeps only the source’s',
'system tags (`Type` 1: `beta`, `limitsv2` — what the scene needs to run) and drops',
'every other tag, and starts with zeroed `Stats`, `IsRRO`/`IsDeveloperOwned`/`CloningAllowed` cleared, and PRIVATE accessibility — a new room is unpublished until its',
'owner sets its accessibility, so it never lands in the public feeds on creation.',
'',
'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 room’s 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() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
// Shape before availability, so a rejected name costs no D1 read.
const badName = roomNameRejection(name, 'room name')
if (badName !== null) return roomEnvelope(c, null, badName)
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)
}
)
// 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',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s description',
description: [
'Owner-only (the room’s `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. Pushes a',
'`RoomUpdate` to the owner — this envelope carries no room, so the push is the only',
'thing that tells their client to redraw.',
].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 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)
// Same reason as the rename below: the envelope carries no room, so without this
// the client redraws the room from what it already had — the old description.
await pushRoomUpdate(c, accountId, { ...room, Description: 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',
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 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!',
})
}
// Same ErrorId as the empty case — the client keys off it to mark the field, and
// both are the name being unusable. The sentence is what tells them which.
const badName = roomNameRejection(name, 'room name')
if (badName !== null) {
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
}
// 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)
// The rename answers a bare `{ Success }` with no room in it, so the client has
// nothing to re-render from and kept showing the old name until the push arrived.
// Built from the room already in hand rather than re-read, like the image route's.
await pushRoomUpdate(c, accountId, { ...room, Name: name })
return roomResult(c, { Success: true })
}
)
// Change a room's tags. Auth-gated (401) and owner/co-owner-only (403). Returns the
// `{ success, error, value }` envelope with the updated room as `value`; business
// failures are 200 with success:false.
//
// TWO BODIES reach this one path — Rec Room reshaped the request rather than minting a
// second route, so the fields, not the URL, say which one this is:
//
// - `tag=<name>` ALONE is the 2023 toggle. There is no delete/patch counterpart, so
// the same call adds the tag (Type 0) when absent and removes it when present, and
// the five "main" tags (pvp/quest/game/hangout/art) act as radio buttons.
// - Anything else is the whole-state save both clients send from room settings:
// `autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.
// `tag` repeats and is the complete set of USER tags, `autoTag` adds a derived one
// (Type 1), and `primaryGenreTag` flags the genre. All three compose into one write.
//
// The discriminator is deliberately "is there more than a lone `tag`": a save that
// happens to carry one selected tag must not TOGGLE it back off, which is what made
// this worth spelling out rather than counting `tag` alone.
.put(
'/rooms/:roomId{[0-9]+}/tags',
describeRoute({
tags: ['Room settings'],
summary: 'Toggle a tag on a room',
description: [
'Owner or co-owner only (403 otherwise). Two bodies reach this one path, and the',
'FIELDS say which — not the URL.',
'',
'**A lone `tag=<name>` TOGGLES** (the 2023 form): there is no delete/patch',
'counterpart, so the same call adds the tag (Type 0) when absent and removes it when',
'present, and the “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
'buttons among themselves.',
'',
'**Anything else is a whole-state save** — the form room settings posts, e.g.',
'`autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.',
'Nothing toggles here; the three fields compose into one write:',
'',
'- `tag` repeats and is the COMPLETE set of user (Type 0) tags — one the body omits',
'is removed. Derived tags are not the client’s to send and are left alone.',
'- `autoTag` repeats and adds a derived tag at **Type 1** (`limitsv2`, `beta`). It is',
'additive: it never removes one, since the client posts what it wants rather than the',
'full set. A tag already on the room is re-categorised rather than duplicated.',
'- `primaryGenreTag` flags the room’s genre. The tag is added as a Type 0 tag when',
'the room lacks it and left as it stands when it has it; `IsPrimaryGenre: true` moves',
'onto it, and every OTHER tag loses the flag but KEEPS its place.',
'',
'Answers the lowercase envelope with the updated room, which the client re-renders',
'from, and pushes a `RoomUpdate` to the owner for their other sessions.',
].join('\n'),
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,
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)
// `all: true` because `tag` REPEATS on the whole-state save; without it Hono keeps
// only the last value and a three-tag save would land as one tag.
const body: Record<string, unknown> = await c.req.parseBody({ all: true }).catch(() => ({}))
// An empty value is the same nothing as an absent field. There is no "clear the
// genre" request, so a blank `primaryGenreTag` is a malformed post rather than an
// instruction to unset — and a blank `tag` can't name what to toggle.
const values = (name: string): string[] =>
(Array.isArray(body[name]) ? body[name] : [body[name]])
.filter((v): v is string => typeof v === 'string')
.map((v) => v.trim())
.filter((v) => v !== '')
const tags = values('tag')
const autoTags = values('autoTag')
const primaryGenre = values('primaryGenreTag')[0]
if (tags.length === 0 && autoTags.length === 0 && primaryGenre === undefined) {
return roomEnvelope(c, null, 'You must provide a tag!')
}
// A LONE tag is the 2023 toggle; a tag alongside anything else — another tag, an
// auto tag, a genre — is part of a whole-state save, where nothing toggles.
const isToggle = tags.length === 1 && autoTags.length === 0 && primaryGenre === undefined
const updated = await applyRoomTagEdit(c.env.DB, roomId, room, {
toggle: isToggle ? tags[0] : undefined,
tags: isToggle ? undefined : tags.length > 0 ? tags : undefined,
autoTags,
primaryGenre,
})
// This one DOES answer the updated room, so the caller's own client redraws from
// the response; the push is for their other sessions, as on every mutation below.
await pushRoomUpdate(c, accountId, updated)
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',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s 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 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 })
}
)
// Delete a room. Auth-gated (401); owners and staff (developers/moderators) may delete.
// 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]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Delete a room',
description: [
'Owner or staff. Removes the room record, the per-player interactions with it, and the',
'room’s 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 isOwner = room.CreatorAccountId === accountId
if (!isOwner && !(await isStaff(c))) {
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}`)
}
// A takedown — staff deleting a room that isn't theirs — goes in the audit log; an
// owner deleting their own room is their business and doesn't. The row keeps the
// room's identity, since the room itself is gone and nothing else will answer "which
// room was that". Written after the fact and caught: a failed insert must not report
// a deletion that has already happened as refused. It leaves a loud line instead.
if (!isOwner) {
try {
await writeAuditLog(c.env.DB, {
playerId: accountId,
action: 'room_delete',
data: {
roomId,
name: room.Name,
creatorAccountId: room.CreatorAccountId,
accessibility: room.Accessibility,
},
})
} catch (err) {
logger.error('could not write an audit log row', {
action: 'room_delete',
playerId: accountId,
roomId,
error: err instanceof Error ? err.message : String(err),
})
}
}
return roomResult(c, { Success: true })
}
)
// A room's role list — the same `Roles` the room DTO already carries, served on its own
// so the client can refresh the member list without re-reading the whole room. Public,
// like the room itself, and a BARE array rather than the room-write envelope. An unknown
// room is an empty list.
.get(
'/rooms/:roomId{[0-9]+}/roles',
describeRoute({
tags: ['Room settings'],
summary: 'A room’s roles',
description: [
'Everyone with a role in the room — its creator plus whoever has been granted Host,',
'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the',
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the',
'room is: a role list says who runs a room, which the room page already shows.',
'',
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An',
'unknown room is an empty list rather than an error — it reads the same as a room',
'nobody holds a role in.',
].join('\n'),
parameters: [roomIdParam],
responses: {
200: json(RoomRoleDto.array(), 'The room’s role assignments'),
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
return c.json(room ? roomRoles(room) : [])
}
)
// Set a player's role in a room (`Roles[].Role`). ONE path, two callers:
//
// - the room's owner/co-owner GRANTING a role to somebody else, which takes effect
// immediately — the helper tiers are theirs to hand out;
// - the invited player ANSWERING their own standing co-owner invite, accepting it by
// naming the offered tier or declining with `role=0`.
//
// Which one it is comes from whether the path account IS the caller. CoOwner is the one
// tier a grant cannot reach: it has to be offered (`…/invite`) and accepted, so the
// second path exists at all. Creator is refused for the same reason — it is strictly
// more than the tier that needs an invite.
//
// Auth-gated (401), and a 403 for a valid token with no standing to do either. Everyone
// in the room gets a `RoomUpdate` push, plus the affected player wherever they are.
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Set a player’s role in a room',
description: [
'Two callers share this path, told apart by whether the `accountId` in it is the',
'caller’s own.',
'',
'**The room’s owner or a co-owner, granting a role to someone else.** Takes effect',
'immediately, updating the target’s entry in `Roles` or adding one. `Role` 30',
'(CoOwner) is refused: co-ownership is offered with `PUT …/roles/{accountId}/invite`',
'and accepted by the invited player, which is what the other half of this endpoint',
'is for. `Role` 255 (Creator) is refused too — it is strictly more than the tier',
'that needs an invite, so it cannot be the one grant that skips the ceremony.',
'Any pending invite on the entry is left standing.',
'',
'**The same caller, REVOKING a role** — `role=0`, or a body naming no role at all.',
'There is no "holds no role" tier, so the target’s entry is removed outright, taking',
'any pending invite on it with it. Two entries are protected and refused: the room’s',
'owner (its creator, or a Creator entry), whose ownership moves only by transfer; and',
'a co-owner, unless the caller is the room’s owner — co-owners are peers and cannot',
'revoke each other. Removing a role the player does not hold succeeds and changes',
'nothing. A role that is present but not a number is still a rejection, never read as',
'a removal.',
'',
'**The invited player, answering their own co-owner invite.** Accepting promotes',
'their entry from its pending `InvitedRole` to the real `Role` and clears the',
'pending one — an accepted entry reads `Role: 30, InvitedRole: 0`, so an invite',
'cannot be answered twice. The `role` body field must equal the `InvitedRole` the',
'owner offered: without that check the invited player could answer an invite to',
'Host by asking for Creator and award themselves the room. Declining is `role=0`',
'and drops the entry outright, leaving `Roles` as it was before the offer — note',
'that this takes any role they already held with it. No entry, no invite standing,',
'and the wrong role are all the same rejection, so probing tells the caller nothing.',
'',
'Everyone standing in the room right now gets the `RoomUpdate` push, and so does the',
'affected player wherever they are — a role change alters what the room lets people',
'do, and every client showing it re-renders from the room they are pushed. The',
'caller reads the same room out of the envelope.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'accountId',
in: 'path',
required: true,
description: 'The player whose role changes — the caller’s own id to answer an invite',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(
RoleRequest,
'The role tier; 0 or absent to revoke, or 0 to decline your own invite'
),
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!')
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
// A body naming NO role means "no role" — the owner-side removal below. A role that
// is present but not a number is still malformed, so the two are kept apart: the
// absent case must not swallow a typo'd tier and silently strip someone.
const rawRole = typeof body.role === 'string' ? body.role.trim() : ''
const namesRole = rawRole !== ''
const role = namesRole ? Number.parseInt(rawRole, 10) : Role.None
if (namesRole && Number.isNaN(role)) {
return roomEnvelope(c, null, 'You must provide a valid role!')
}
// Acting on your OWN entry is answering an invite — the only thing a player with
// no standing in the room may do here, and the only way a co-owner role is ever
// taken. An owner targeting themselves lands here too, and has no invite to answer.
if (targetAccountId === accountId) {
// Answering an invite still has to NAME the answer: accepting names the tier offered
// and declining is an explicit `role=0`. An empty body is a removal, and removing
// your own entry is not what this branch does.
if (!namesRole) return roomEnvelope(c, null, 'You must provide a valid role!')
const answered = await answerRoomRoleInvite(c.env.DB, roomId, accountId, role, room)
if (!answered) return roomEnvelope(c, null, 'You have no such invite to this room!')
await pushRoomUpdateToRoom(c, roomId, answered)
return roomEnvelope(c, answered)
}
// Otherwise it is a grant or a REVOKE, and the room's owner/co-owner gate applies.
if (!canManageRoom(room, accountId)) return c.body(null, 403)
// `role=0` on SOMEONE ELSE'S entry is the owner-side revoke: there is no "holds no
// role" tier, so removing the role means removing the record. (The same value on your
// own entry is a decline, handled above — whose entry it is, is what tells them
// apart.) `removeRoomRole` owns the two protections: ownership is never revoked this
// way, and only the room's owner may revoke a co-owner, so co-owners cannot strip
// each other.
if (role === Role.None) {
const removed = await removeRoomRole(c.env.DB, roomId, targetAccountId, accountId, room)
if (!removed) return roomEnvelope(c, null, 'You cannot remove that role!')
await pushRoomUpdateToRoom(c, roomId, removed, [targetAccountId])
return roomEnvelope(c, removed)
}
// Null means the tier has to be ACCEPTED rather than handed over: nobody force-adds
// a co-owner. `setRoomRole` refuses it itself — the rule belongs to the room's
// `Roles`, not to this route — so there is no check here to forget to copy.
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
if (!updated) return roomEnvelope(c, null, 'Co-ownership must be invited, not granted!')
await pushRoomUpdateToRoom(c, roomId, updated, [targetAccountId])
return roomEnvelope(c, updated)
}
)
// Hand the room to a new owner (form body `accountId`). Auth-gated (401) and gated to
// the room's OWNER alone (403) — giving a room away is the one thing only its owner may
// do. The recipient becomes Creator and the outgoing owner is left CoOwner, and
// `CreatorAccountId` moves with them.
.put(
'/rooms/:roomId{[0-9]+}/creator',
describeRoute({
tags: ['Room settings'],
summary: 'Transfer a room to a new owner',
description: [
'Makes `accountId` the room’s owner: their `Roles` entry becomes `Role` 255 (Creator)',
'and the outgoing owner’s becomes 30 (CoOwner), so whoever built the room keeps their',
'access to it without keeping the room. `CreatorAccountId` moves too — it is the',
'room’s real owner field, which every gate short-circuits on and the "rooms I made"',
'lists select on, so leaving it behind would give the room two owners.',
'',
'Gated to the room’s OWNER — its creator, or the holder of the Creator role. A valid',
'token from anyone else, a co-owner included, is a 403. This is the one place',
'`Role` 255 is handed over without the recipient accepting it: unlike a co-owner',
'invite, it is not a grant made about someone by a third party but the only person',
'who could already do anything to this room giving it away.',
'',
'A DORM cannot be transferred. A dorm is found by its owner, so handing one over',
'would give away somebody’s personal room and mint them a fresh empty one on next',
'access, their build gone.',
'',
'Everyone standing in the room gets the `RoomUpdate` push, and so do both parties',
'wherever they are — the room they are looking at just changed hands.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(CreatorRequest, 'The account taking the room over'),
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!')
if (!isRoomOwner(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const toAccountId =
typeof body.accountId === 'string' ? Number.parseInt(body.accountId, 10) : Number.NaN
if (Number.isNaN(toAccountId)) return roomEnvelope(c, null, 'You must provide an account!')
if (toAccountId === accountId) return roomEnvelope(c, null, 'You already own this room!')
// A room handed to an account that doesn't exist is orphaned for good — nobody is
// left who can transfer it back.
if (!(await getAccount(c.env.DB, toAccountId))) {
return roomEnvelope(c, null, 'That player does not exist!')
}
const updated = await transferRoomOwnership(c.env.DB, roomId, accountId, toAccountId, room)
if (!updated) return roomEnvelope(c, null, 'A dorm cannot be given away!')
// Both parties hear about it wherever they are — the outgoing owner's client has to
// stop offering them the owner's tools, and the new owner's to start.
await pushRoomUpdateToRoom(c, roomId, updated, [accountId, toAccountId])
return roomEnvelope(c, updated)
}
)
// Invite a player to a room role (`Roles[].InvitedRole`). Auth-gated (401) and gated to
// the room's OWNER alone (403 otherwise) — narrower than the role WRITE above, which a
// co-owner may also use. Body is the `role` form field. Records the pending offer on the
// target's role entry (adding one at `Role` 0 when they have none), pushes the
// `RoomCoOwnerInvited` message that raises the invite on their client, and answers a
// bare `{ success: true }` — the inviter's client re-renders nothing from it.
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}/invite',
describeRoute({
tags: ['Room settings'],
summary: 'Invite a player to a room role',
description: [
'Sets `InvitedRole` on the target’s entry in the room’s `Roles` (adding an entry at',
'`Role` 0 when they have none) — the PENDING half of a role entry, held until they',
'accept, at which point the role proper is granted. Someone who already holds a role',
'keeps it while a higher one is pending.',
'',
'Gated to the room’s OWNER — its creator, or the holder of the Creator role. Narrower',
'than setting a role outright, which a co-owner may also do: otherwise a co-owner',
'could quietly grow the set of people who can change the room. A valid token from',
'anyone else is a 403.',
'',
'The INVITED player gets a durable `MessageReceived` frame carrying a Message of type',
'62 (`RoomCoOwnerInvited`), naming the inviter, the room, and the offered role as its',
'`Data` — that message, not the `Roles` entry, is what their client raises the invite',
'from, and the role in it is what they send back to accept.',
'',
'Answers a bare `{ success: true }`, NOT the `{ success, error, value }` room',
'envelope: nothing on the inviter’s screen re-renders from an invite.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'accountId',
in: 'path',
required: true,
description: 'The player being invited',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(InviteRoleRequest, 'The role tier being offered'),
responses: {
200: json(SuccessEnvelope, 'The invite was recorded and pushed'),
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)
// An unknown room and a room somebody else owns answer the same 403: the gate is
// the owner check, and there is no envelope here to carry a message in.
if (!room || !isRoomOwner(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 c.body(null, 400)
await inviteRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
await pushRoleInvite(c, roomId, accountId, targetAccountId, role)
return c.json({ success: true })
}
)
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
// ban: a ban list says who a room's owner has had trouble with, so it isn't public.
// Answers a BARE array (not the room-write envelope), newest ban first.
.get(
'/rooms/:roomId{[0-9]+}/bans',
describeRoute({
tags: ['Room settings'],
summary: 'A room’s ban list',
description: [
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
'exactly like issuing a ban: the room’s creator or a co-owner, or an account whose',
'token carries the `developer` / `moderator` role. A ban list says who a room’s',
'owner has had trouble with, so it is not public.',
'',
'A bare array, NOT the `{ success, error, value }` envelope the ban write answers,',
'and the entries are camelCase with a different field set: no room id (the path',
'already says which room) and no ban mask. An unknown room is an empty list rather',
'than an error — it reads the same as a room nobody is banned from.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(RoomBanEntryDto.array(), 'The room’s bans, newest first'),
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)
// No room → nothing banned. Same answer as a room with an empty ban list, so
// this doesn't become a way to probe which room ids exist.
if (!room) return c.json([])
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
const bans = await getRoomBans(c.env.DB, roomId)
return c.json(
bans.map((ban) => ({
accountId: ban.BannedPlayerId,
bannedByAccountId: ban.BannedByAccountId,
banStartTime: ban.CreatedAt,
}))
)
}
)
// One player's ban history in one room (`?id=` is the player). The owner's view of
// whether someone is banned now and how often they have been before — so gated like the
// ban list: auth (401), then the room's owner/co-owner or a staff token (403).
//
// Answers the PascalCase `{ Value, Success, Error, error_id }` envelope the unprefixed
// isBanned uses — `error_id` lowercase among its PascalCase siblings — with `Value` exactly
// `{ ActiveBan, PreviousBans }`. The record's seven keys are in the client's order: the
// derived members (`Status`, `UnbannedByAccountId`, `BanEndTime`, `Reason`) come BEFORE the
// base ones (`AccountId`, `BannedByAccountId`, `BanStartTime`), which is why `AccountId`
// is fifth. Built key by key in that order rather than by spreading a domain object.
.get(
'/rooms/:roomId{[0-9]+}/bans/history',
describeRoute({
tags: ['Room settings'],
summary: 'A player’s ban history in a room',
description: [
'Whether `id` is banned from the room right now, and every ban of theirs there that has',
'ended. `Value` has exactly two keys: `ActiveBan` (the ban in force, or null) and',
'`PreviousBans` (newest first).',
'',
'Each record is seven keys in this order: `Status` (0 Active, 1 Elapsed, 2 Lifted),',
'`UnbannedByAccountId` (who lifted it; null unless Lifted), `BanEndTime`, `Reason`',
'(`""` when none was given — never null), `AccountId`, `BannedByAccountId`,',
'`BanStartTime`. `BanEndTime` is the scheduled expiry of an Active ban (null for a',
'permanent one), the expiry of an Elapsed ban, and the moment a Lifted ban was lifted.',
'Timestamps are ISO 8601 UTC to the second.',
'',
'A timed ban that has run out is not active: it is reported as an Elapsed previous ban.',
'A ban re-issued while still in force is AMENDED, not ended, so it appears once, with',
'its latest reason and times. History starts with this server’s ban-history migration;',
'bans lifted before it left no record.',
'',
'Gated like the ban list: the room’s creator or a co-owner, or an account whose token',
'carries the `developer` / `moderator` role. An unknown room answers an empty history.',
'A missing or non-numeric `id` answers `Success: false` with a null `Value`.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'id',
in: 'query',
required: true,
description: 'The account whose ban history to read',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(RoomBanHistoryEnvelope, 'The player’s active ban and previous bans'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const playerId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(playerId)) {
return c.json({
Value: null,
Success: false,
Error: 'You must provide a valid player!',
error_id: null,
})
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
// No room → no bans, the same answer the ban list gives, so this is not a way to
// probe which room ids exist.
if (room && !canManageRoom(room, accountId) && !(await isStaff(c))) {
return c.body(null, 403)
}
const history = room
? await getRoomBanHistory(c.env.DB, roomId, playerId)
: { active: null, previous: [] }
return c.json({
Value: {
ActiveBan: history.active ? toBanRecordDto(history.active) : null,
PreviousBans: history.previous.map(toBanRecordDto),
},
Success: true,
Error: null,
error_id: null,
})
}
)
// Ban a player from a room (form body `id` + `banMask`, optionally `reason` and
// `durationMinutes`). Auth-gated (401), then
// gated to the room's owner/co-owner OR a staff token (403). One row per
// (room, player) — re-banning rewrites it, so the call is idempotent.
.post(
'/rooms/:roomId{[0-9]+}/bans',
describeRoute({
tags: ['Room settings'],
summary: 'Ban a player from a room',
description: [
'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
'someone already banned rewrites their row rather than adding a second. The row is',
'what the `match` worker checks: a banned player’s matchmake into this room is',
'refused with errorCode 55 and never gets a Photon room id.',
'',
'Gated to the room’s creator or a co-owner, OR to any account whose token carries the',
'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
'yourself, or banning someone who can manage the room, is refused: otherwise a',
'co-owner could ban the owner out of their own room.',
'',
'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
'what it selects is not known yet. It defaults to 0 when absent.',
'',
'`reason` is optional free text, stored on the ban and appended to the kick message.',
'`durationMinutes` makes the ban lapse on its own; absent, empty or `0` is permanent.',
'A duration that is present but not a non-negative whole number is a rejection, never',
'read as permanent. A lapsed ban stops counting everywhere — matchmaking, `isBanned`,',
'the ban list — without anyone lifting it. Re-banning REPLACES the reason and',
'duration and restarts the clock; it does not extend the remaining time.',
'',
'If the banned player’s live presence puts them in THIS room, they are moved into their',
'own DORM — presence rewritten rather than deleted, so their client does not load the',
'dorm twice — and the instance they left frees a slot. Presence in some other room is',
'left alone.',
'',
'A banned player standing in the room also gets a `ModerationKick` frame (id 22),',
'the frame the client acts on to leave, with `gameSessionId` the instance they were',
'removed from. It is a KICK, not a ban, as far as the client is concerned: `isBan` is',
'false and `duration` 0, because `isBan: true` is what the client shows as a ban from',
'the whole game. The ban itself is the row, enforced when `match` refuses them the room',
'(`BannedFromRoom`, 55). The frame is ephemeral, never queued, and a player not in the',
'room gets none. `isHostKick` is false only for a staff moderator acting in a room they',
'do not host; `playerIdReporter` is whoever issued the ban.',
'',
'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
'but `value` is the BAN, not the room — a ban is not part of the room the client',
'renders. This shape is unverified against the real service.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(BanRequest, 'The player to ban'),
responses: {
200: json(RoomBanEnvelope, 'The stored ban, 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 banEnvelope(c, null, 'This room does not exist!')
// The room's own owners, or a staffer acting across rooms. Roles are only
// looked up when the cheaper room check fails. The room's own owner IS the
// host, which is what the kick frame's `isHostKick` reports.
const isHostKick = canManageRoom(room, accountId)
if (!isHostKick && !(await isStaff(c))) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const bannedPlayerId = Number.parseInt(str(body.id), 10)
if (Number.isNaN(bannedPlayerId)) {
return banEnvelope(c, null, 'You must provide a valid player to ban!')
}
if (bannedPlayerId === accountId) return banEnvelope(c, null, 'You cannot ban yourself!')
// Without this a co-owner could ban the room's creator out of their own room.
if (canManageRoom(room, bannedPlayerId)) {
return banEnvelope(c, null, 'You cannot ban an owner of this room!')
}
// Absent or unparseable → 0, the value the client sends.
const banMask = Number.parseInt(str(body.banMask), 10) || 0
// Absent or empty is a PERMANENT ban. A duration that is present but is not a
// non-negative whole number is refused rather than read as permanent: a typo turning
// "30 minutes" into "forever" is the one mistake here that is worse than failing.
const rawDuration = str(body.durationMinutes).trim()
const durationMinutes = rawDuration === '' ? 0 : Number(rawDuration)
if (!Number.isInteger(durationMinutes) || durationMinutes < 0) {
return banEnvelope(c, null, 'You must provide a valid ban duration!')
}
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId, {
reason: str(body.reason),
durationMinutes,
})
// Out of the room's presence first, so by the time their client acts on the kick
// nothing server-side still counts them as standing in it. Only a player who WAS
// standing in the room is kicked — anyone else has nothing to be ejected from.
const evictedFrom = await evictBannedPlayer(c, ban)
if (evictedFrom !== null) {
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushRoomBan(c, ban, evictedFrom, roomName, isHostKick)
}
return banEnvelope(c, ban)
}
)
// Is this player banned from this room? The client asks before offering someone a room
// action, so it can grey it out rather than let the attempt fail.
//
// The path is the client's, verbatim: `/Room_server/…`, capitalised and underscored,
// unlike the `/roomserver/rooms/createdby/me` alias elsewhere. Hono matches
// case-sensitively, so it is registered exactly as the client spells it.
//
// Answers the `{ success, error, error_id, value }` envelope — NOT the room mutations'
// `{ success, error, value }`: this one carries `error_id`, and its `error` is null where
// theirs is an empty string. `success` is whether the CHECK ran, not the answer; the
// answer is `value`.
//
// Auth-gated but not owner-gated: a player about to interact with someone needs this, and
// a ban is not a secret from the person it would stop.
.get(
'/Room_server/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}/isBanned',
describeRoute({
tags: ['Room settings'],
summary: 'Whether a player is banned from a room',
description: [
'Whether `playerId` is banned from `roomId`, read from the same `room_ban` rows the',
'ban routes write and `match` refuses matchmakes on — so it answers what would',
'actually happen, not a stub.',
'',
'The envelope is `{ success, error, error_id, value }`: `success` says the check ran,',
'`value` is the answer. It is NOT the room mutations’ envelope — that one has no',
'`error_id` and uses `""` where this uses null.',
'',
'Auth-gated, but any authenticated caller may ask: a ban is not a secret from the',
'player it stops. The path is the client’s own capitalised `/Room_server/` spelling.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'playerId',
in: 'path',
required: true,
description: 'The account being asked about',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(IsBannedEnvelope, 'Whether that player is banned from that room'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json({ success: true, error: null, error_id: null, value: await pathBan(c) })
}
)
// The SAME check on the unprefixed path (`GET /rooms/112/bans/1/isBanned`), which is the
// spelling the client uses on the rooms host itself. Registered as its own route rather
// than as an alias because the answer is not the same bytes: this one is PascalCase
// (`Value`/`Success`/`Error`, `error_id` still lowercase), and the client's decoder drops
// members it doesn't know silently — a lowercase `value` here would read as `false` and
// show a banned player as unbanned.
//
// Same gate as its sibling: auth-gated, not owner-gated. A ban is not a secret from the
// player it stops, and the client asks this before offering a room action so it can grey
// it out rather than let the attempt fail.
.get(
'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}/isBanned',
describeRoute({
tags: ['Room settings'],
summary: 'Whether a player is banned from a room (unprefixed path)',
description: [
'The same `room_ban` check as the `/Room_server/` route, on the path the client uses',
'against the rooms host directly — and in the PascalCase envelope it reads there:',
'`{ Value, Success, Error, error_id }`, with `error_id` lowercase.',
'',
'The two envelopes are NOT unified. The client’s decoder drops members it does not',
'recognise, so serving the other route’s lowercase `value` here would decode as',
'`false` — a banned player shown as unbanned — rather than fail.',
'',
'Auth-gated, but any authenticated caller may ask: a ban is not a secret from the',
'player it stops. A room that does not exist has no bans, so it answers',
'`Value: false` rather than 404ing — the check is about the ban row, not the room.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, bannedPlayerIdParam],
responses: {
200: json(IsBannedPascalEnvelope, 'Whether that player is banned from that room'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json({ Value: await pathBan(c), Success: true, Error: null, error_id: null })
}
)
// Lift bans in BULK (form body `id`, repeated or comma-separated, plus the `banMask` the
// client sends and nothing reads). Same gate as issuing one: auth-gated (401), then the
// room's owner/co-owner OR a staff token (403). Declared before the `/:playerId` route
// only for reading order — `bulk` is not a number, so the two never compete for a path.
.delete(
'/rooms/:roomId{[0-9]+}/bans/bulk',
describeRoute({
tags: ['Room settings'],
summary: 'Unban players from a room in bulk',
description: [
'Removes the `room_ban` row of every player named by `id`, so they can matchmake into',
'the room again. `id` may repeat (`id=205&id=206`) or be comma-separated; `banMask`',
'is accepted because the client sends it, and ignored — it described the ban, not the',
'unban. The body is form-encoded, on a DELETE.',
'',
'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account',
'whose token carries the `developer` / `moderator` role.',
'',
'Unlike the single-player DELETE, a player who is not banned is SKIPPED rather than',
'failing the call — one stale id in a list must not sink the rest. Only a body naming',
'no valid id at all is a rejection. A ban that has already lapsed counts as not banned.',
'',
'Answers the ban envelope with `value` the ARRAY of bans actually removed, in the order',
'asked; empty when none of the players named were banned. No notification is pushed.',
'This shape is unverified against the real service.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(UnbanBulkRequest, 'The players to unban'),
responses: {
200: json(
RoomBansRemovedEnvelope,
'The removed bans, 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 banEnvelope(c, null, 'This room does not exist!')
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
// `all: true` keeps a repeated `id` from collapsing to its last value, which would
// unban one player out of the list and report success.
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
const ids = [
...new Set(
[body.id]
.flat()
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => !Number.isNaN(n))
),
]
if (ids.length === 0) return banEnvelope(c, null, 'You must provide a valid player to unban!')
const removed: RoomBan[] = []
for (const playerId of ids) {
const ban = await unbanPlayerFromRoom(c.env.DB, roomId, playerId, accountId)
if (ban) removed.push(ban)
}
return c.json({ success: true, error: '', value: removed })
}
)
// Lift a player's ban on a room. Same gate as issuing one: auth-gated (401), then the
// room's owner/co-owner OR a staff token (403).
.delete(
'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Unban a player from a room',
description: [
'Removes the player’s `room_ban` row, so they can matchmake into the room again.',
'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account',
'whose token carries the `developer` / `moderator` role.',
'',
'Unbanning someone who is not banned is a rejection (`success: false`), not a silent',
'success — the caller asked to undo something that was not there.',
'',
'Answers the same envelope as the ban write, with the REMOVED ban as `value`. No',
'notification is pushed: nothing tells a player their ban was lifted.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, bannedPlayerIdParam],
responses: {
200: json(RoomBanEnvelope, 'The removed ban, 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 banEnvelope(c, null, 'This room does not exist!')
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId, accountId)
if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!')
return banEnvelope(c, removed)
}
)
// Configure one of a room's leaderboard slots (form body `leaderboardTitle` +
// `statFormat` + `sortAscending`). Auth-gated (401) and owner/co-owner-only (403).
// One row per (room, slot) — re-posting a slot reconfigures it, so the call is
// idempotent.
.post(
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Configure a room leaderboard',
description: [
'Creates or reconfigures one leaderboard slot in the `room_leaderboard` table — one',
'row per (room, slot), so re-posting a slot rewrites its title, format and direction',
'rather than adding a second. The slot number in the path is the client’s small',
'ordinal (1, 2, 3…), unique only within the room. Owner or co-owner only (403',
'otherwise).',
'',
'`statFormat` is stored verbatim (default 0); `sortAscending` is the client’s',
'`True`/`False` string (default `False`).',
'',
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
'mutations’ lowercase `{ success, error, value }`.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam],
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
responses: {
200: json(LeaderboardResultEnvelope, 'Stored, 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 leaderboardEnvelope(c, '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 leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const title = typeof body.leaderboardTitle === 'string' ? body.leaderboardTitle : ''
const statFormat =
typeof body.statFormat === 'string' ? Number.parseInt(body.statFormat, 10) : Number.NaN
// The client sends .NET-style `True`/`False`; anything but a `true` reads false.
const sortAscending =
typeof body.sortAscending === 'string' && body.sortAscending.toLowerCase() === 'true'
await setRoomLeaderboard(
c.env.DB,
roomId,
leaderboardId,
title,
Number.isNaN(statFormat) ? 0 : statFormat,
sortAscending
)
return leaderboardEnvelope(c)
}
)
// Remove one of a room's leaderboard slots. Auth-gated (401) and owner/co-owner-only
// (403). The client fires these blindly for every slot when tearing boards down, so a
// slot that isn't configured is a rejection envelope, not an HTTP error.
.delete(
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Remove a room leaderboard',
description: [
'Removes the slot’s `room_leaderboard` row. Owner or co-owner only (403 otherwise).',
'',
'Removing a slot that isn’t configured is a rejection (`Success: false`), not a',
'silent success — the caller asked to undo something that was not there. The client',
'deletes slots blindly when tearing boards down and tolerates the refusal.',
'',
'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam],
responses: {
200: json(LeaderboardResultEnvelope, 'Removed, 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 leaderboardEnvelope(c, 'This room does not exist!')
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId)
if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!')
return leaderboardEnvelope(c)
}
)
// 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',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s 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 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 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',
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 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)
}
)
// Set a room's MaxPlayerCalculationMode (form body `maxPlayerCalculationMode`, the enum
// NAME). Auth-gated (401) and owner/co-owner-only. A stored setting only: it does not
// touch `MaxPlayers`. Answers the room in the PascalCase `{ Value, Success, Error,
// error_id }` envelope, like the subroom move — not the lowercase one its neighbours use.
.put(
'/rooms/:roomId{[0-9]+}/max_player_calculation_mode',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s max-player calculation mode',
description: [
'Sets `MaxPlayerCalculationMode`: `AllSubrooms` (0) or `OnlyEntrySubrooms` (1). The',
'client sends the enum name (`maxPlayerCalculationMode=OnlyEntrySubrooms`); the',
'ordinal is accepted too, and anything else is rejected. It is a stored setting:',
'`MaxPlayers` is not recomputed from the subrooms here.',
'',
'Owner or co-owner only. Answers the updated room under `Value` in the PascalCase',
'`{ Value, Success, Error, error_id }` envelope — NOT the lowercase',
'`{ success, error, value }` the other room-settings routes answer. A rejection is',
'HTTP 200 with `Success: false`, `Value: null` and the message in `Error`; only a',
'missing token is a 401.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(MaxPlayerCalculationModeRequest, 'The mode'),
responses: {
200: json(RoomPascalEnvelope, 'The updated room, or a rejection with `Success: false`'),
401: { description: 'No bearer token (empty body)' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return c.body(null, 401)
const refuse = (error: string) =>
c.json({ Value: null, Success: false, Error: error, error_id: null })
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return refuse('This room does not exist!')
if (!canManageRoom(room, accountId)) return refuse('You are not the owner of this room!')
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const mode = parseMaxPlayerCalculationMode(body.maxPlayerCalculationMode)
if (mode === undefined) return refuse('You must provide a valid maxPlayerCalculationMode!')
const updated = await updateRoomFields(c.env.DB, roomId, room, {
MaxPlayerCalculationMode: mode,
})
await pushRoomUpdate(c, accountId, updated)
return c.json({ Value: updated, Success: true, Error: null, error_id: null })
}
)
// 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',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s platform/movement support flags',
description: [
'The room’s `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 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)
}
)
// Set a room's load screen (`LoadScreens[]` — the image shown while the room loads).
// Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` form field
// plus optional `title`/`subtitle`. REPLACES the list with the single posted
// `{ ImageName, Title, Subtitle }` and returns the updated room in the
// `{ success, error, value }` envelope.
//
// The field is an array because the client's parser wants one, but the client only
// ever renders (and only ever posts) a single screen — appending left the old screen
// in slot 0 and the new one unreachable behind it, so setting a load screen appeared
// to do nothing. Kept as an array so multi-screen support can land without a
// migration.
.put(
'/rooms/:roomId{[0-9]+}/loadscreen',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s load screen',
description: [
'REPLACES the room’s `LoadScreens` with the single posted `{ ImageName, Title,',
'Subtitle }` — the image shown while the room loads. The field is an array (the',
'client’s parser expects one) but the client only supports a single screen, so this',
'never appends. Owner or co-owner only (403 otherwise).',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(LoadScreenRequest, 'The load screen to set'),
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 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 : ''
// The posted screen becomes the whole list — the client shows one load screen.
const loadScreens = [{ 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',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s accessibility',
description: [
'The room’s 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 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)
}
)
// Hide a room from the feeds and from search, or list it again — the staff listing
// toggle on the website's room page. NOT a game endpoint: the real service set
// `ExcludeFromLists` / `ExcludeFromSearch` from an admin tool, and the client has no
// screen for either, so this is recflare's own and is staff-only — the owner has
// `Accessibility` for hiding their own room. The two flags are independent in the reads
// (see LISTABLE_WHERE / SEARCHABLE_WHERE) but are set TOGETHER here: a room moderation
// pulls from the listings is meant to be unfindable, not merely un-browsable.
.put(
'/rooms/:roomId{[0-9]+}/exclude',
describeRoute({
tags: ['Custom', 'Room settings'],
summary: 'Hide a room from the listings and search (staff)',
description: [
'Sets `ExcludeFromLists` AND `ExcludeFromSearch` together to the posted `excluded`.',
'Staff only — a token carrying the `developer` / `moderator` role; the room’s owner',
'is refused (403) like anyone else, since this is a moderation action rather than a',
'room setting. The room stays reachable by link and by matchmake; it simply stops',
'appearing in the browse feeds, search and autocomplete. Not an endpoint the game',
'client calls — the website’s room page uses it. Written to the audit log.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(ExcludeRequest, 'Whether the room is hidden'),
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)
if (!(await isStaff(c))) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const excluded =
body.excluded === 'true' || body.excluded === '1'
? true
: body.excluded === 'false' || body.excluded === '0'
? false
: null
if (excluded === null) {
return roomEnvelope(c, null, 'You must provide a valid excluded!')
}
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!')
const updated = await updateRoomFields(c.env.DB, roomId, room, {
ExcludeFromLists: excluded,
ExcludeFromSearch: excluded,
})
// Always a staff action on somebody's room, so always audited — the owner can't
// get here. Written after the fact and caught, like the takedown's row: a failed
// insert must not report a change that has already happened as refused.
try {
await writeAuditLog(c.env.DB, {
playerId: accountId,
action: 'room_exclude',
data: {
roomId,
name: room.Name,
creatorAccountId: room.CreatorAccountId,
excluded,
},
})
} catch (err) {
logger.error('could not write an audit log row', {
action: 'room_exclude',
playerId: accountId,
roomId,
error: err instanceof Error ? err.message : String(err),
})
}
// The owner's client, if it's open, re-renders the room from this.
if (typeof room.CreatorAccountId === 'number') {
await pushRoomUpdate(c, room.CreatorAccountId, updated)
}
return roomEnvelope(c, updated)
}
)
// A subroom's saved-data versions — the room-history / "restore a save" list. Every
// save is its own `subroom_save` row (nothing is overwritten), so this is real
// history, newest first, paged by skip/take. Auth-gated (401), and readable by the
// room's creator, a co-owner, anyone whose presence puts them in the room, or staff (see
// `canReadSaves`).
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
describeRoute({
tags: ['Subrooms'],
summary: 'A subroom’s saved-data versions',
description: [
'The room-history / “restore a save” list, newest first. Every room save appends a',
'row rather than overwriting, so this is the subroom’s full history; it is empty',
'only when the subroom has never been saved.',
'An integer `unityAssetTarget` keeps only `UnitySubAssets` for that target',
'(0 Windows, 2 Android/Quest). `unityAssetVersion` is accepted and ignored.',
'',
'The list includes STAGED saves that were never published, so it is not public:',
'the room’s creator or a co-owner may read it, and so may anyone standing IN the room',
'(their live presence says so) and anyone whose token carries the `developer` /',
'`moderator` role. Anyone else is a 403. It is what the client reads to resolve',
'“load the latest or the published version?” on entering a private instance — a',
'visitor who cannot read it cannot load what the instance is running.',
'',
'`TotalResults` and `TotalCount` carry the same number: the client’s paged DTO and',
'the reference disagree on the name, so both are emitted.',
].join(' '),
security: AUTHED,
parameters: [
roomIdParam,
subRoomIdParam,
stringQuery(
'unityAssetTarget',
'When an integer, `UnitySubAssets` on each row keeps only that target (0 Windows, 2 Android/Quest)'
),
stringQuery('unityAssetVersion', 'Accepted and ignored'),
stringQuery('skip', 'How many saves to skip (default 0)'),
stringQuery('take', 'How many saves to return (default all)'),
],
responses: {
200: json(SubRoomSavesPage, 'The subroom’s saves, newest first'),
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 subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't read its saves.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) {
return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
}
if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const from = Number.isNaN(skip) || skip < 0 ? 0 : skip
const page = saves.slice(from, Number.isNaN(take) || take < 0 ? undefined : from + take)
const target = unityAssetTargetQuery(c)
if (target !== null) {
for (const save of page) keepUnityAssetTarget(save, target)
}
return c.json({ Results: page, TotalResults: saves.length, TotalCount: saves.length })
}
)
// The same history list, with the Unity-asset payloads left out. The rows are the
// `…/saves` rows minus `UnitySubAssets`/`ReferencedUnityAssets`/`Tags` — the heavy part
// of a save row, which a history list never renders — keeping the asset IDENTIFIERS.
//
// It answers a BARE paged wrapper: no `{ success, error, value }` envelope, unlike the
// room mutations next door. Same gate, same paging and the same empty page for an
// unknown room/subroom as `…/saves`, so the two can be swapped for one another.
//
// The `:saveId` detail route below is digit-constrained, so `no_unity_assets` can never
// be read as a save id whichever order these are declared in.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/no_unity_assets',
describeRoute({
tags: ['Subrooms'],
summary: 'A subroom’s saves, without their Unity-asset payloads',
description: [
'The same page as `…/saves`, newest first, carrying the lighter rows: no',
'`UnitySubAssets`, no `ReferencedUnityAssets`, no `Tags` — only the asset ids. A BARE',
'paged wrapper, with no `{ success, error, value }` envelope around it.',
'',
'Gated exactly like `…/saves`, and for the same reason: the list includes STAGED',
'saves that were never published, so it is the room’s creator, a co-owner, anyone',
'whose live presence puts them in the room, or staff (a `developer` / `moderator`',
'token), and anyone else is a 403.',
'',
'`TotalResults` and `TotalCount` carry the same number — the client’s paged DTO and',
'the reference disagree on the name, so both are emitted.',
].join(' '),
security: AUTHED,
parameters: [
roomIdParam,
subRoomIdParam,
stringQuery('skip', 'How many saves to skip (default 0)'),
stringQuery('take', 'How many saves to return (default all)'),
],
responses: {
200: json(SubRoomSavesNoUnityAssetsPage, 'The subroom’s saves, newest first'),
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 subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't read its saves.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) {
return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
}
if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const from = Number.isNaN(skip) || skip < 0 ? 0 : skip
const page = saves.slice(from, Number.isNaN(take) || take < 0 ? undefined : from + take)
// `TotalResults` counts the whole history, not the page — that is what the client
// pages against.
return c.json({
Results: page.map(toSaveWithoutUnityAssets),
TotalResults: saves.length,
TotalCount: saves.length,
})
}
)
// One of a subroom's saves by id — the detail behind a row of the `…/saves` list.
// Same gate as that list: a save id resolves whether or not it was ever published, so
// this exposes the same unpublished work, to the same readers.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/:saveId{[0-9]+}',
describeRoute({
tags: ['Subrooms'],
summary: 'One of a subroom’s saves by id',
description: [
'A single save as a PascalCase row like the ones `…/saves` lists — NOT the camelCase',
'`subRoomDataSave` the room save that created it returned (served that way, the',
'client showed a save with every value missing). It differs from the list row in',
'how it names bundles: each `UnitySubAssets` entry is `{ UnityAssetId, UnityAsset,',
'UnityAssetHash }`, the save’s main bundle is lifted to top-level `UnityAsset`/',
'`UnityAssetHash` (null for a maker-pen save), and there is no `Tags`. An integer',
'`unityAssetTarget` picks which target’s bundles those are (0 Windows, 2',
'Android/Quest); none named means every bundle in the list and Windows on top.',
'Save ids are globally unique but resolved scoped to the subroom, so one subroom',
'cannot read another’s save by guessing an id: a save that belongs elsewhere is a',
'404, same as an unknown one.',
'',
'Gated like the list it details — the room’s creator, a co-owner, anyone whose',
'presence puts them in the room, or staff (a `developer` / `moderator` token). A save id',
'resolves whether or not it was ever published, so this reads unpublished work.',
].join(' '),
security: AUTHED,
parameters: [
roomIdParam,
subRoomIdParam,
saveIdParam,
stringQuery(
'unityAssetTarget',
'When an integer, the bundles are that target’s only (0 Windows, 2 Android/Quest)'
),
],
responses: {
200: json(SubRoomDataSaveDetailDto, 'The save'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: { description: 'No such room, subroom, or save on that subroom' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const saveId = Number.parseInt(c.req.param('saveId'), 10)
// Scoped through the room, like the list, so a subroom id from another room can't
// be used to read its saves.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
if (!save) return c.notFound()
const target = unityAssetTargetQuery(c)
// Imported pre-Studio saves name their main/dependency bundles directly.
// Keep upstream's target-aware Studio projection for its built assets.
if (
typeof save.UnityAsset === 'string' &&
!pickBakedBundle(save, target) &&
(target === null || target === 0)
)
return c.json(save)
return c.json(toSaveDetail(save, target))
}
)
// Metadata for the unity asset on one of this subroom's saves. The editor calls
// this after a build; the game reads the same bundles from `CurrentSave` and then
// downloads `filename` from the CDN. Bare object, no envelope, no auth — the room
// document already names the files.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/unityasset',
describeRoute({
tags: ['Subrooms'],
summary: 'The unity asset attached to a subroom save',
description: [
'The baked Windows and Android bundles stored for `unityAssetId`, when that id is',
'on a save of this subroom. A bare object: `unityAssetId`, `createdByAccountId`,',
'`bakedUnityAssets` (one entry per main bundle), plus `filename` and `hash` for the',
'Windows bundle. Bytes are `GET /unityasset/{filename}` on the CDN.',
'',
'`target` is 0 for Windows and 2 for Android/Quest. Stripped bundles are stored and',
'not listed. An unknown room, subroom, or asset is a 404. No auth: `GET /rooms/{id}`',
'already includes the same filenames on `CurrentSave`.',
].join(' '),
parameters: [
roomIdParam,
subRoomIdParam,
stringQuery('unityAssetId', 'The save’s `UnityAssetId`'),
],
responses: {
200: json(UnityAssetWithSourceDto, 'The unity asset and its baked bundles'),
404: { description: 'No such room, subroom, or asset on that subroom' },
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const unityAssetId = c.req.query('unityAssetId') ?? ''
if (unityAssetId === '') return c.notFound()
const sub = await getSubRoom(c.env.DB, roomId, subRoomId)
if (!sub) return c.notFound()
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
if (!save) return c.notFound()
const asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
if (!asset) return c.notFound()
// Main bundles, Windows first; the Windows one (or the first there is) supplies
// the top-level `filename`/`hash`.
const mains = bakedUnityAssets(asset.builds)
const source = mains.find((build) => build.Target === 0) ?? mains[0]
if (!source) return c.notFound()
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
return c.json({
unityAssetId: asset.unityAssetId,
createdByAccountId: asset.createdByAccountId || savedBy,
bakedUnityAssets: mains.map((build) => ({
unityAssetId: build.UnityAssetId,
target: build.Target,
version: build.Version,
filename: build.Filename,
})),
filename: source.Filename,
hash: source.Hash,
})
}
)
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
// by the room creator or a Creator/CoOwner role holder. Points the subroom at
// the uploaded data blobs and records the revision's fields against that SUBROOM,
// 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',
describeRoute({
tags: ['Subrooms'],
summary: 'Save a subroom’s data (room save)',
description: [
'Records a save against the subroom from the blobs the client has already uploaded',
'through the `storage` worker. Everything the body carries describes THAT revision',
'and lands on the save and its subroom — `Description` is the save comment, NOT the',
'room’s description (only `PUT /rooms/{roomId}/description` sets that). Nothing here',
'writes to the room. Editable by the room’s creator or a co-owner (403 otherwise); a',
'missing token is an EMPTY-body 401, unlike the other room writes.',
'',
'`AutoPublish: true` makes the save live immediately. Otherwise it is STAGED: it',
'lands on `StagedSubRoomDataSaveId` with the live `CurrentSave` untouched, so',
'players keep loading the last published version until the owner calls',
'`POST …/subrooms/{subRoomId}/publish_save`. DORMS always publish — they have no',
'publish step in the client, so staging one would hide the player’s own edits.',
'',
'`value` carries BOTH the updated `room` and the `subRoomDataSave` just created,',
'and `error` is NULL here rather than the empty string the other room envelopes',
'use. The save is projected in camelCase with a different field set from the',
'PascalCase `CurrentSave` embedded in the room — the two are not the same shape.',
'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the',
'saver’s 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(RoomSaveEnvelope, 'The updated room + the new save, or a rejection'),
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 room = await getRoomById(c.env.DB, roomId)
if (!room) {
return c.json({ success: false, error: 'This room does not exist!', value: null })
}
// 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)
// The client uploads BOTH blobs to `storage` first and sends their keys here:
// `SubRoomData` is the scene blob (what the loader downloads, onto the subroom's
// save), `RoomData` the room-level metadata blob (onto the ROOM, as `DataBlob`).
// `OwnershipProof` is accepted and ignored.
const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string; Hash?: string | null }
SubRoomData?: { Filename?: string; Hash?: string | null }
UnityAssetId?: string | null
Description?: string
PersistenceVersion?: number
InventionUsage?: string
AutoPublish?: boolean
}
const result = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
subRoomDataFilename: body.SubRoomData?.Filename,
subRoomDataHash:
typeof body.SubRoomData?.Hash === 'string' ? body.SubRoomData.Hash : undefined,
roomDataFilename: body.RoomData?.Filename,
roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined,
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
autoPublish: body.AutoPublish === true,
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 (!result) {
return c.json({ success: false, error: 'This subroom does not exist!', value: null })
}
// `value` carries BOTH the updated room and the save just created — and `error`
// is null here, not the empty string the other room envelopes use.
await pushRoomUpdate(c, accountId, result.room)
await attachStudioUnityAssets(c.env.DB, [result.save])
return c.json({
success: true,
error: null,
value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) },
})
}
)
// 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',
describeRoute({
tags: ['Subrooms'],
summary: 'Modify a subroom’s settings',
description: [
'Sets a subroom’s `Name`, `Accessibility` and `MaxPlayers`. Owner-only — only the',
'room’s 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 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 badName = roomNameRejection(name, 'subroom name')
if (badName !== null) {
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
}
const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
name,
// Accepts the enum name as well as the ordinal — the dedicated
// `/accessibility` route below is sent names, so this may be too.
accessibility: parseAccessibility(body.accessibility),
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
})
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 })
}
)
// Publish a subroom's staged save — promote it to the live one players load. Every
// non-dorm room save only STAGES (see the save route), so this is the manual step that
// makes edits visible. Auth-gated (401) and creator-only: co-owners may save, but
// only the room's owner decides what goes live. Answers the updated ROOM in the
// `{ success, error, value }` envelope, like the other subroom mutations.
.post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/publish_save',
describeRoute({
tags: ['Subrooms'],
summary: 'Publish one of a subroom’s saves',
description: [
'Makes the save named by the `subRoomDataSaveId` form field the one players load —',
'it becomes the subroom’s `CurrentSave`. A room save only STAGES (dorms excepted),',
'so nothing a creator saves reaches players until this is called.',
'',
'The id may be any save in the subroom’s history, so this doubles as restore-a-save.',
'`StagedSubRoomDataSaveId` is cleared only when the published save IS the staged',
'one — restoring an older version keeps newer unpublished work staged.',
'',
'Owner-only: co-owners may save but not decide what goes live. A save id belonging',
'to another subroom is rejected.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: form(PublishSaveRequest, 'The save to publish'),
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 body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const saveId =
typeof body.subRoomDataSaveId === 'string'
? Number.parseInt(body.subRoomDataSaveId, 10)
: Number.NaN
if (Number.isNaN(saveId)) {
return roomEnvelope(c, null, 'You must provide a valid save!')
}
const result = await publishSubRoomSave(c.env.DB, roomId, subRoomId, saveId)
if (!result.ok) {
return roomEnvelope(
c,
null,
result.reason === 'unknown_save'
? 'That save does not exist!'
: 'This subroom does not exist!'
)
}
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.room)
}
)
// Set a single subroom's `Accessibility`. Same effect as the `accessibility` field of
// the subroom `modify` call, but this is what the client actually calls when the
// player flips one subroom's visibility, and the body carries the enum NAME
// (`accessibility=Private`), not the number the room-level `/accessibility` takes.
// Auth-gated (401) and owner-only, like the other subroom mutations. Answers the
// updated ROOM in the `{ success, error, value }` envelope — the client re-renders
// the room's subroom list from `value`, the same as subroom create/delete.
.put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/accessibility',
describeRoute({
tags: ['Subrooms'],
summary: 'Set a subroom’s accessibility',
description: [
'A subroom’s own visibility, independent of the room’s top-level `Accessibility`.',
'The client sends the `RoomAccessibility` NAME here (`accessibility=Private`) rather',
'than the ordinal the room-level route takes, so both forms are accepted; an',
'unrecognised value is rejected. Owner-only — only the room’s creator may change',
'its subrooms, not co-owners.',
'',
'Answers the updated ROOM, not the bare subroom, so the client can re-render the',
'room’s subroom list from `value`.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: form(SubRoomAccessibilityRequest, 'The new accessibility'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_ENVELOPE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
if (room.CreatorAccountId !== accountId) {
return roomEnvelope(c, null, 'You are not the owner of this room!')
}
if (!findSubRoom(room, subRoomId)) {
return roomEnvelope(c, null, 'This subroom does not exist!')
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const accessibility = parseAccessibility(body.accessibility)
if (accessibility === undefined) {
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
}
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { accessibility })
if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
)
// Set a subroom's permission overrides — what each role may do in that subroom. The
// body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override`
// is the client's checkbox, so true stores the entry for that pair and false clears it
// back to the default. The stored table then overwrites the matching defaults in
// `GET /photon_access_token`. Auth-gated (401) and gated to the room's MANAGERS — its
// creator or a co-owner (403), the same `canManageRoom` the other room-admin writes
// take; a creator-only check here locked out co-owners the client shows the screen to,
// and an owner whose room names them in `Roles` rather than `CreatorAccountId`. Answers
// the whole ROOM under `Value` in the PascalCase `{ Value, Success, Error, error_id }`
// envelope — the same reader the subroom move and max-player mode routes feed, and the
// same Room DTO `GET /rooms/{id}` serves (the permissions themselves aren't on it). A
// rejection is HTTP 200 with `Success: false` and the message in `Error`; only a
// missing token is a 401. This once answered an empty 200, which the client's reader
// couldn't decode.
.put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions',
describeRoute({
tags: ['Subrooms'],
summary: 'Set a subroom’s permissions',
description: [
'Stores the permission entries a room’s creator changed for one subroom — who may',
'save inventions, invite players, use the delete-all button, and so on. The body is a',
'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending',
'a pair overwrites the stored entry rather than adding a second, and pairs that were',
'never sent are left alone.',
'',
'`Override` is the checkbox the client draws beside each permission, not data:',
'`true` stores `Value` for that pair, and `false` means “fall back to the default”, so',
'it DELETES any stored entry. Nothing is stored with `Override: false`; a stored entry',
'reads back with `true` at its own role. `Value` is a string — usually `True`/`False`,',
'but it is kept verbatim, since not every permission’s UI is a True/False picker.',
'',
'What this feeds is `GET /photon_access_token`. For a manager it serves the WHOLE',
'table — a row per (permission, role) for every role tier, stored entries with',
'`Override: true` and the rest `false` — which is what this screen reads back: a',
'cell with no row at its exact role snaps back to the default as soon as it is',
'toggled. For everyone else it serves only the entries that reach THEIR role: one at',
'their role, else one at a lower role (a Role 0 grant reaches everyone), over the',
'role’s defaults. One naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is',
'served too. The overrides apply to the subroom the caller is standing in, resolved',
'from presence.',
'',
'Gated to the room’s managers — its creator or a co-owner (`canManageRoom`), as the',
'other room-admin writes are. Answers the whole ROOM under `Value` in the PascalCase',
'`{ Value, Success, Error, error_id }` envelope — the same Room DTO `GET /rooms/{id}`',
'serves (the permission table itself is not part of it), and the same envelope the',
'subroom move and `max_player_calculation_mode` answer, NOT the lowercase',
'`{ success, error, value }` the other subroom mutations use. A rejection is HTTP 200',
'with `Success: false`, `Value: null` and the message in `Error`; only a missing',
'token is a 401.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
responses: {
200: json(RoomPascalEnvelope, 'The room, or a rejection with `Success: false`'),
401: { description: 'No bearer token (empty body)' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return c.body(null, 401)
const refuse = (error: string) =>
c.json({ Value: null, Success: false, Error: error, error_id: null })
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't be written.
const room = await getRoomById(c.env.DB, roomId)
if (!room) return refuse('This room does not exist!')
if (!findSubRoom(room, subRoomId)) return refuse('This subroom does not exist!')
if (!canManageRoom(room, accountId)) return refuse('You are not the owner of this room!')
const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
return c.json({ Value: room, Success: true, Error: null, error_id: null })
}
)
// 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 updated ROOM in the `{ success, error, value }` envelope — NOT the new
// subroom, even though the new subroom is what the call produces. The client
// re-renders the room's subroom list from `value`, the same as subroom
// create/delete/accessibility.
.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.',
'',
'Answers the updated ROOM, not the new subroom — the client re-renders the room’s',
'subroom list from `value`. Unlike the room-level `/clone`, whose `value` IS the new',
'room, the thing this call creates is not what comes back.',
].join('\n'),
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 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.room)
}
)
// Move a subroom into another room (form body `newRoomId`). Auth-gated (401), and the
// caller must be able to MANAGE both rooms — owner or co-owner, unlike the creator-only
// gate on the other subroom mutations. Answers the SOURCE room in the PascalCase
// `{ Value, Success, Error, error_id }` envelope, not the lowercase one its siblings use.
.post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/move',
describeRoute({
tags: ['Subrooms'],
summary: 'Move a subroom to another room',
description: [
'Re-parents a subroom onto another room. The subroom keeps its `SubRoomId`, its',
'save history and its permission overrides — all keyed by that globally-unique id',
'— and only its `RoomId` changes. It refuses to move a room’s only subroom, for the',
'reason delete refuses it: the room would be left with no scene to load. Moving a',
'subroom onto the room it is already in is refused as well.',
'',
'The caller must be able to manage BOTH rooms — owner or co-owner (`CreatorAccountId`',
'or a Creator/CoOwner role) — not just the one the subroom leaves.',
'',
'Answers the SOURCE room, which no longer lists the subroom, under `Value` in the',
'PascalCase `{ Value, Success, Error, error_id }` envelope: NOT the lowercase',
'`{ success, error, value }` the other subroom mutations answer. A rejection is HTTP',
'200 with `Success: false`, `Value: null` and the message in `Error`; only a missing',
'token is a 401.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: form(MoveSubRoomRequest, 'The destination room'),
responses: {
200: json(RoomPascalEnvelope, 'The source room, or a rejection with `Success: false`'),
401: { description: 'No bearer token (empty body)' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return c.body(null, 401)
const refuse = (error: string) =>
c.json({ Value: null, Success: false, Error: error, error_id: null })
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const newRoomId = Number.parseInt(String(body.newRoomId ?? ''), 10)
if (!Number.isInteger(newRoomId)) return refuse('You must provide a valid newRoomId!')
if (newRoomId === roomId) return refuse('This subroom is already in that room!')
const room = await getRoomById(c.env.DB, roomId)
if (!room) return refuse('This room does not exist!')
if (!canManageRoom(room, accountId)) return refuse('You are not the owner of this room!')
const canManageTarget = await canManageRoomById(c.env.DB, newRoomId, accountId)
if (canManageTarget === null) return refuse('The destination room does not exist!')
if (!canManageTarget) return refuse('You are not the owner of the destination room!')
const result = await moveSubRoom(c.env.DB, roomId, subRoomId, newRoomId)
if (!result.ok) {
return refuse(
result.reason === 'last_subroom'
? "You can't move a room's only subroom!"
: 'This subroom does not exist!'
)
}
// Both rooms changed shape for the caller's other sessions.
await pushRoomUpdate(c, accountId, result.room)
const target = await getRoomById(c.env.DB, newRoomId)
if (target) await pushRoomUpdate(c, accountId, target)
return c.json({ Value: result.room, Success: true, Error: null, error_id: null })
}
)
// 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',
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 room’s first existing subroom.',
'',
'Answers the updated ROOM, not the bare subroom — the client re-renders the room’s',
'subroom list from `value`. Delete answers the same shape.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(CreateSubRoomRequest, 'The new subroom’s 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 badName = roomNameRejection(name, 'subroom name')
if (badName !== null) return roomEnvelope(c, null, badName)
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]+}',
describeRoute({
tags: ['Subrooms'],
summary: 'Delete a subroom',
description: [
'Removes a subroom from a room. Owner-only, and it refuses to remove a room’s 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)
}
)
// 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',
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 — the blob they last saved through the PUT below,
// or an empty one, which the client reads as "no saved data". Auth-gated (401): the
// answer is the caller's own.
.get(
'/rooms/:roomId{[0-9]+}/playerdata/me',
describeRoute({
tags: ['Rooms'],
summary: 'The caller’s per-room player data',
description: [
'The blob the calling player last saved for this room through',
'`PUT /rooms/{roomId}/playerdata/me`, verbatim, as `Data`. A player who has never',
'saved any — or an unknown room — gets an EMPTY `Data`, which the client reads as',
'“no saved data”; a 404 here would stall the room load.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(PlayerDataDto, 'The caller’s saved blob, or an empty one'),
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)
return c.json({ Data: (await getPlayerData(c.env.DB, roomId, accountId)) ?? '' })
}
)
// Save the caller's per-room player data (form field `data`, a base64 blob the client
// builds and the server never decodes). Auth-gated (401). One row per (room, player) —
// a re-save overwrites, so the table holds only the latest. Answers the same bare
// `{ Data }` the GET serves, carrying what was stored.
.put(
'/rooms/:roomId{[0-9]+}/playerdata/me',
describeRoute({
tags: ['Rooms'],
summary: 'Save the caller’s per-room player data',
description: [
'Stores the `data` form field — a base64 blob the client builds; the server neither',
'decodes nor validates it — as the calling player’s data for this room, in the',
'`playerdata` table. One row per (room, player): a re-save overwrites the blob in',
'place, so only the latest is kept. Any caller may save to any room; nothing here is',
'owner-gated, since the data is the caller’s own. An unknown room is a 404.',
'',
'Answers the same bare `{ Data }` the GET serves, carrying what was stored. That',
'shape is an ASSUMPTION — what the client does with this response has not been',
'observed; it has only been seen to read the GET.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(PlayerDataRequest, 'The blob to store'),
responses: {
200: json(PlayerDataDto, 'The stored blob'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such room (empty body)' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
if (!(await getRoomById(c.env.DB, roomId))) return c.body(null, 404)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const data = typeof body.data === 'string' ? body.data : ''
return c.json({ Data: await setPlayerData(c.env.DB, roomId, accountId, data) })
}
)
// A room's XP settings — whether players earn experience there and how much of it counts
// toward their day. Stored on the room blob by the POST below; a room that was never
// configured is off with a 0 cap. A bare two-key object, no envelope, and no auth —
// nothing in the answer is per-player (`experience/player` below is that half).
.get(
'/rooms/:roomId{[0-9]+}/experience',
describeRoute({
tags: ['Rooms'],
summary: 'A room’s XP settings',
description: [
'Whether players earn XP in the room (`Enabled`) and how much of it counts toward a',
'day (`DailyLimit`), as a bare two-key object — the `progressionEnabled` and',
'`progressionDailyLimit` the room’s owner set with `POST /rooms/{roomId}/experience`.',
'A room that was never configured is off with a 0 cap. No auth: the answer is the',
'same for every caller. 404 for a room that does not exist.',
].join(' '),
parameters: [roomIdParam],
responses: {
200: json(RoomExperience, 'The room’s XP settings'),
404: { description: 'No such room' },
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.body(null, 404)
return c.json(roomProgression(room))
}
)
// Turn a room's progression on or off and set its daily cap. Auth-gated (401) and
// owner/co-owner-only (403), like the leaderboard write, and answers the same bare
// `{ Success, Error, error_id }` envelope. Body is the client's .NET-style form:
// `enabled=True&dailyLimit=1000`.
.post(
'/rooms/:roomId{[0-9]+}/experience',
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s XP settings',
description: [
'Sets `progressionEnabled` and `progressionDailyLimit` on the room, which',
'`GET /rooms/{roomId}/experience` reads back as `Enabled`/`DailyLimit`. Owner or',
'co-owner only (403 otherwise). `enabled` is the client’s `True`/`False` string —',
'anything but a `true` reads false; `dailyLimit` is an integer, and a missing or',
'non-numeric one is 0.',
'',
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
'`error_id`, like the leaderboard write, carrying no entity. An unknown room is a',
'rejection envelope, not a 404.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(ExperienceRequest, 'The room’s XP settings'),
responses: {
200: json(LeaderboardResultEnvelope, 'Stored, 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 experienceEnvelope(c, 'This room does not exist!')
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const enabled = typeof body.enabled === 'string' && body.enabled.toLowerCase() === 'true'
const dailyLimit =
typeof body.dailyLimit === 'string' ? Number.parseInt(body.dailyLimit, 10) : Number.NaN
await setRoomProgression(c.env.DB, roomId, enabled, Number.isNaN(dailyLimit) ? 0 : dailyLimit)
return experienceEnvelope(c)
}
)
// The caller's experience in this room: the `room_xp` row for (room, caller), with the
// room's switch echoed so the client knows whether what it earns here counts. A player
// with no row is 0 XP — reading never writes a row. `ConcurrencyCode` is a GUID minted
// per response; it only travels in requests and is not stored.
.get(
'/rooms/:roomId{[0-9]+}/experience/player',
describeRoute({
tags: ['Rooms'],
summary: 'The caller’s per-room experience',
description: [
'The calling player’s XP in this room (`Experience`), the room’s `Enabled` echoed as',
'`RoomExperienceEnabled`, and a `ConcurrencyCode` GUID minted for this response (not',
'stored). A player who has earned nothing here reads as 0 — no row is written on a read.',
'Auth-gated (401); 404 for a room that does not exist.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(RoomExperiencePlayer, 'The caller’s experience in the room'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such room' },
},
}),
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 c.body(null, 404)
return c.json({
RoomExperienceEnabled: roomProgression(room).Enabled,
Experience: await getRoomExperience(c.env.DB, roomId, accountId),
ConcurrencyCode: crypto.randomUUID(),
})
}
)
// Add to the caller's XP in this room. Auth-gated (401), 404 for no such room. Body is
// the client's form `increment=2500&concurrencyCode=<guid>`; the code is accepted and
// ignored (nothing is stored to check it against). Answers the read's DTO — with the
// NEW total and a fresh code — WRAPPED in the leaderboard write's `{ Value, Success,
// Error, error_id }` envelope; the read serves it bare. The room's switch and daily cap
// are not enforced here — the increment lands whatever they say.
.post(
'/rooms/:roomId{[0-9]+}/experience/player',
describeRoute({
tags: ['Rooms'],
summary: 'Add to the caller’s per-room experience',
description: [
'Adds `increment` to the calling player’s XP in this room, creating their `room_xp`',
'row on the first write, and answers the read’s DTO with the new total and a fresh',
'`ConcurrencyCode`, wrapped in `{ Value, Success, Error, error_id }` — the read serves',
'it bare. The posted `concurrencyCode` is accepted and ignored.',
'A missing or non-numeric `increment` adds 0. Neither the room’s `Enabled` nor its',
'`DailyLimit` is enforced. Auth-gated (401); 404 for a room that does not exist.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(ExperienceIncrementRequest, 'The XP to add'),
responses: {
200: json(
RoomExperiencePlayerEnvelope,
'The caller’s experience in the room, after the add'
),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such room' },
},
}),
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 c.body(null, 404)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const increment =
typeof body.increment === 'string' ? Number.parseInt(body.increment, 10) : Number.NaN
const experience = await incrementRoomExperience(
c.env.DB,
roomId,
accountId,
Number.isNaN(increment) ? 0 : increment
)
return c.json({
Value: {
RoomExperienceEnabled: roomProgression(room).Enabled,
Experience: experience,
ConcurrencyCode: crypto.randomUUID(),
},
Success: true,
Error: null,
error_id: null,
})
}
)
// Single room by id. 404 when the room isn't in D1. A Studio save's
// `CurrentSave.UnitySubAssets` lists the baked bundles; `unityAssetTarget`
// narrows that list to the platform the caller asked for.
.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 `{}`. A save that has a Rec Room Studio build',
'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2',
'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).',
'A maker-pen save keeps those arrays empty. An integer `unityAssetTarget` leaves',
'only the matching target, so a PC client is not also handed the Android bundle.',
'`include` and `unityAssetVersion` are accepted and ignored.',
].join(' '),
parameters: [
roomIdParam,
stringQuery('include', 'Accepted and ignored'),
stringQuery(
'unityAssetTarget',
'When an integer, each current save’s `UnitySubAssets` keeps only that target (0 Windows, 2 Android/Quest)'
),
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))
if (!room) return c.notFound()
filterRoomUnityAssets(room, unityAssetTargetQuery(c))
return c.json(room)
}
)
// The republish limits, verbatim from the reference server. Fixed values, no auth:
// the client reads them to render its publish UI, before any room is in play.
.get(
'/publishState/configs',
describeRoute({
tags: ['Rooms'],
summary: 'Room republish limits',
description: [
'The limits the client enforces around republishing a room — how many updates are',
'allowed per rolling window, and the cooldown and expiry around them. Fixed values',
'from the reference server; nothing here enforces them server-side yet, so this is',
'what the client shows and gates its own UI on.',
'',
'Note the envelope differs from the room mutations’: `error` is null (not `""`) and',
'there is an extra `error_id`.',
].join(' '),
responses: { 200: json(PublishStateConfigsEnvelope, 'The republish limits') },
}),
(c) =>
c.json({
value: {
UpdateMaxCount: 3,
UpdateRollingWindowInDays: 365,
UpdateExpirationInDays: 30,
UpdateCooldownInDays: 45,
},
success: true,
error_id: null,
error: null,
})
)
// The baked asset bundles behind a room load. The client posts the unity asset ids its
// saves name, with the target it runs on and the asset version it wants, and gets back
// a BARE ARRAY of blob references — `Filename` is what it then downloads from the CDN.
// Form-encoded, `id` repeated once per GUID. No auth, like `…/unityasset`: the room
// document already names these files, and a 401 here would only make the room load
// nothing with no error anywhere — which is also what `[]` does, so an asset with no
// stored build is simply left out rather than answered with a blank row.
.post(
'/unity_assets/baked/bulk',
describeRoute({
tags: ['Subrooms'],
summary: 'The baked bundles for unity assets',
description: [
'The stored builds of the unity assets named by `id` (repeated once per GUID) on',
'`target` (0 Windows, 2 Android/Quest), as a bare array of',
'`{ UnityAssetId, Target, Version, Filename, Hash }` — one entry per asset that has a',
'build, in the order asked; `[]` when none does. `Filename` is the blob the client',
'downloads from the CDN, so an entry is never served without one.',
'',
'`version` is a preference, not a filter: an asset built more than once answers the',
'build at that version when there is one and its newest otherwise. A body naming no',
'ids, or a non-integer `target`, answers `[]`.',
].join(' '),
requestBody: form(BakedUnityAssetBulkRequest, 'The assets wanted, and for which target'),
responses: {
200: json(BakedUnityAssetBulkList, 'The baked bundles, one per asset found'),
},
}),
async (c) => {
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
const first = (v: unknown): string => {
const one = Array.isArray(v) ? v[0] : v
return typeof one === 'string' ? one.trim() : ''
}
const target = Number.parseInt(first(body.target), 10)
if (!Number.isInteger(target)) return c.json([])
// Absent or unparseable, the version prefers nothing and the newest build is served.
const version = Number.parseInt(first(body.version), 10)
// `all: true` keeps the repeated field a list; a single value arrives as a string.
const ids = [body.id, body.ids]
.flat()
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((v) => v.trim())
.filter((v) => v !== '')
return c.json(
await getBakedUnityAssets(c.env.DB, ids, target, Number.isInteger(version) ? version : -1)
)
}
)
// Photon access token + room permissions the client needs to spawn into a room.
.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 caller’s current instance, read from the shared `presence`',
'table (null when they’re in none).',
'',
'The table depends on who asks. A MANAGER — the creator or a co-owner — gets the',
'whole table: a row per (permission, role) for every role tier (0, 10, 20, 30, 255),',
'because the permissions screen (`PUT …/subrooms/{subRoomId}/permissions`) reads',
'each cell from the row at exactly that role. An entry stored on the subroom is',
'served at its own role with `Override: true`; every other cell carries the value',
'that reaches that role (the entry at the highest lower role, else the default) with',
'`Override: false`, so an un-overridden cell shows and stays at its default.',
'',
'Everyone else gets their own rows only: each permission’s value for the role they',
'hold in the room they are standing in — the entry stored at their role, else one',
'stored at a lower role, else the default: managers may build, nobody else may.',
'Entries stored under a role they do not hold are left out. Every value is served at',
'Role 0 and, for a host or moderator, again at their role, all with `Override: true`.',
'',
'`PhotonAccessToken` is always empty: the reference server signs it with a',
'secret/algorithm we don’t have, and our Photon setup accepts an empty token. The',
'global maker pen is granted only to the hardcoded dev accounts, whatever the table says.',
].join('\n'),
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 owner’s 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 client’s 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 client’s 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' }],
tags: [
{
name: 'Custom',
description: [
'Endpoints the game client never calls. Everything else here reimplements a',
'call the Rec Room client makes; these are recflare’s own additions, used by the',
'website (`www`) for things the real service did from an admin tool.',
].join(' '),
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
},
},
},
},
})
)
)
/**
* How long a `room_vote` ballot is kept. The table is append-only (see the `api` worker's
* votes-db.ts, which owns the schema) and nothing there reads a ballot older than this:
* a vote stays open for 60 seconds (`VOTE_WINDOW_MS`) and the tally only counts ballots
* from the open vote's call on, and the caller throttle looks back exactly
* `VOTE_CALL_COOLDOWN_MS` — 5 minutes. Older rows are dead weight, so the cron drops them.
* Must not be shorter than that cooldown, or a purge would let a caller call again early.
*/
const ROOM_VOTE_RETENTION_MS = 5 * 60_000
/** Delete every ballot cast before `now - ROOM_VOTE_RETENTION_MS`. Returns rows removed. */
async function deleteStaleRoomVotes(db: D1Database, now = Date.now()): Promise<number> {
const res = await db
.prepare('DELETE FROM room_vote WHERE voted_at < ?1')
.bind(new Date(now - ROOM_VOTE_RETENTION_MS).toISOString())
.run()
return res.meta.changes ?? 0
}
/**
* Cron: sweep the short-lived rows on the shared database — `room_vote` ballots older than
* {@link ROOM_VOTE_RETENTION_MS} and `room_invite` rows older than
* `ROOM_INVITE_TTL_SECONDS` (both 5 minutes). Neither table is this worker's — `api` and
* `match` own them — but both are room housekeeping, so it runs here.
*/
async function sweepStaleRows(env: Env): Promise<void> {
const votes = await deleteStaleRoomVotes(env.DB)
const invites = await deleteExpiredRoomInvites(env.DB)
// The tagged logger is request-scoped (its middleware never runs for a cron), so
// log plainly here — Workers observability picks it up either way.
console.log(`room sweep: removed ${votes} stale ballots, ${invites} expired invites`)
}
// The HTTP surface is a standard Hono app, exported by name so it can be mounted
// uniformly like every other worker (e.g. by the `mono` facade). The cron that sweeps
// stale ballots and expired invites is exported alongside it.
export { app }
export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env, ctx) => {
ctx.waitUntil(sweepStaleRows(env))
}
// Standalone entry: a Worker only runs `scheduled` when it's on the default export,
// so rooms keeps the object form the runtime requires to fire its `*/5 * * * *` cron.
export default { fetch: app.fetch, scheduled } satisfies ExportedHandler<Env>