mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
a3a5851373
* [rooms] Preserve legacy Unity bundle save DTOs for the Studio loader * [rooms] Resolve JSON baked bundle requests with exact target and version
5198 lines
222 KiB
TypeScript
5198 lines
222 KiB
TypeScript
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>
|