mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
4203 lines
176 KiB
TypeScript
4203 lines
176 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||
|
||
import {
|
||
Accessibility,
|
||
answerRoomRoleInvite,
|
||
applyRoomTagEdit,
|
||
areFriends,
|
||
autocompleteRoomSearch,
|
||
banPlayerFromRoom,
|
||
canManageRoom,
|
||
cloneRoom,
|
||
cloneSubRoom,
|
||
countRoomsByCreator,
|
||
createNotification,
|
||
createSubRoom,
|
||
deleteRoom,
|
||
deleteRoomLeaderboard,
|
||
deleteSubRoom,
|
||
findSubRoom,
|
||
getAccount,
|
||
getBaseRooms,
|
||
getContributedRooms,
|
||
getFavoritedRooms,
|
||
getFeaturedRooms,
|
||
getHotRooms,
|
||
getInteraction,
|
||
getOrCreateDormRoom,
|
||
getPlayerIdsInRoom,
|
||
getPresence,
|
||
getPublicRoomsByCreator,
|
||
getRecommendedRooms,
|
||
getRoomBanHistory,
|
||
getRoomBans,
|
||
getRoomById,
|
||
getRoomByName,
|
||
getRoomsByCreator,
|
||
getRoomsByIds,
|
||
getSimilarRooms,
|
||
getSubRoomPermissions,
|
||
getSubRoomSaveById,
|
||
getSubRoomSaves,
|
||
getTrendingRooms,
|
||
getVisitedRooms,
|
||
inviteRoomRole,
|
||
isPlayerBannedFromRoom,
|
||
isRoomOwner,
|
||
MessageType,
|
||
modifySubRoom,
|
||
movePlayerToDorm,
|
||
publishSubRoomSave,
|
||
removeCheer,
|
||
removeFavorite,
|
||
removeRoomRole,
|
||
Role,
|
||
roomNameRejection,
|
||
roomRoles,
|
||
saveSubRoomData,
|
||
searchRooms,
|
||
setRoomDescription,
|
||
setRoomImage,
|
||
setRoomLeaderboard,
|
||
setRoomName,
|
||
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,
|
||
bannedPlayerIdParam,
|
||
BanRequest,
|
||
BulkRoomsRequest,
|
||
CloneRoomRequest,
|
||
CloningRequest,
|
||
CreateSubRoomRequest,
|
||
CreatorRequest,
|
||
CuratedPlaylists,
|
||
DescriptionRequest,
|
||
DormRoomId,
|
||
FeaturedRoomGroupDto,
|
||
FORBIDDEN_RESPONSE,
|
||
form,
|
||
ImageRequest,
|
||
InteractionDto,
|
||
intQuery,
|
||
InviteRoleRequest,
|
||
IsBannedEnvelope,
|
||
IsBannedPascalEnvelope,
|
||
json,
|
||
jsonBody,
|
||
leaderboardIdParam,
|
||
LeaderboardRequest,
|
||
LeaderboardResultEnvelope,
|
||
LoadScreenRequest,
|
||
MissingLookupParam,
|
||
ModifySubRoomRequest,
|
||
NameRequest,
|
||
NOT_FRIENDS_RESPONSE,
|
||
PagedRooms,
|
||
pageParams,
|
||
PhotonAccessTokenDto,
|
||
PlayerDataDto,
|
||
playerIdParam,
|
||
PublishSaveRequest,
|
||
PublishStateConfigsEnvelope,
|
||
RestrictionsRequest,
|
||
RoleRequest,
|
||
RoomBanEntryDto,
|
||
RoomBanEnvelope,
|
||
RoomBanHistoryEnvelope,
|
||
RoomBansRemovedEnvelope,
|
||
RoomDto,
|
||
RoomEnvelope,
|
||
RoomExperience,
|
||
RoomExperiencePlayer,
|
||
roomIdParam,
|
||
RoomLookup,
|
||
RoomResultEnvelope,
|
||
RoomRoleDto,
|
||
RoomSaveEnvelope,
|
||
saveIdParam,
|
||
SaveSubRoomDataRequest,
|
||
SearchSuggestions,
|
||
ServiceStatus,
|
||
ShowcasedRooms,
|
||
stringQuery,
|
||
SubRoomAccessibilityRequest,
|
||
SubRoomDataSaveResponseDto,
|
||
subRoomIdParam,
|
||
SubRoomPermissionsRequest,
|
||
SubRoomSavesNoUnityAssetsPage,
|
||
SubRoomSavesPage,
|
||
SuccessEnvelope,
|
||
TagRequest,
|
||
TooManyLookupIds,
|
||
UNAUTHORIZED_EMPTY,
|
||
UNAUTHORIZED_ENVELOPE,
|
||
UNAUTHORIZED_RESPONSE,
|
||
UnbanBulkRequest,
|
||
WarningRequest,
|
||
} from './openapi'
|
||
|
||
import type { Context } from 'hono'
|
||
import type { RoomBan, RoomBanRecord, RoomPermission } from '@repo/domain'
|
||
import type { MessageReceivedPayload } from '../../notify/src/notification-payloads'
|
||
import type { App } 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
|
||
}
|
||
|
||
/**
|
||
* Room permissions + Photon token the client needs to spawn into a room. The
|
||
* global (Role 0) maker pen is added only for the hardcoded dev accounts, and
|
||
* `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.
|
||
*
|
||
* `overrides` are the permissions the room's creator saved on the subroom the caller is
|
||
* in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the
|
||
* defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by —
|
||
* and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account
|
||
* standing in it as well.
|
||
*/
|
||
function photonAccessToken(
|
||
accountId: number,
|
||
roomInstanceId: number | null,
|
||
overrides: RoomPermission[] = []
|
||
) {
|
||
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
|
||
Override,
|
||
Permission,
|
||
Role,
|
||
Type: 0,
|
||
Value: 'True',
|
||
})
|
||
const permissions: RoomPermission[] = [
|
||
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
|
||
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
|
||
perm('CAN_SAVE_INVENTIONS', 0, true),
|
||
perm('CAN_SPAWN_INVENTIONS', 0, true),
|
||
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 0, true),
|
||
perm('CAN_USE_MAKER_PEN', 30, false),
|
||
perm('CAN_USE_ROOM_RESET_BUTTON', 30, true),
|
||
perm('CAN_USE_DELETE_ALL_BUTTON', 30, true),
|
||
perm('CAN_SAVE_INVENTIONS', 30, true),
|
||
perm('CAN_SPAWN_INVENTIONS', 30, true),
|
||
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
|
||
]
|
||
|
||
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
|
||
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
|
||
}
|
||
|
||
// The subroom's stored table wins, applied LAST and over the dev grant too: a
|
||
// (Permission, Role) the table already carries is replaced in place — so the order
|
||
// doesn't shift under the client, and no pair is ever listed twice with two values —
|
||
// and one it doesn't (e.g. CAN_INVITE) is appended.
|
||
for (const override of overrides) {
|
||
const i = permissions.findIndex(
|
||
(p) => p.Permission === override.Permission && p.Role === override.Role
|
||
)
|
||
if (i === -1) permissions.push(override)
|
||
else permissions[i] = override
|
||
}
|
||
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), and returns the
|
||
* permissions + token.
|
||
*/
|
||
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
|
||
// The permission overrides are the ones saved on the subroom the caller is standing in.
|
||
// A player in no instance — sitting in the lobby, or an instance predating subroom
|
||
// tracking — gets the default table untouched.
|
||
const overrides =
|
||
typeof instance?.subRoomId === 'number'
|
||
? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
|
||
: []
|
||
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
|
||
}
|
||
|
||
/**
|
||
* May this caller read the room's saves? The room's creator always may. 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 creator's latest — from
|
||
* the save list, so refusing everyone but the creator leaves a visitor unable to load what
|
||
* the instance is actually running.
|
||
*
|
||
* Presence is the shared `presence` table the `match` heartbeat maintains, so this grant
|
||
* lasts only as long as the player is actually there (rows carry an absolute expiry and
|
||
* expired ones don't read back). Co-owners get nothing extra from being co-owners — a
|
||
* co-owner standing in the room passes because of where they are, not what they hold.
|
||
*
|
||
* The presence read only happens for a non-creator, so the owner's own path stays one query.
|
||
*/
|
||
async function canReadSaves(
|
||
c: Context<App>,
|
||
room: Record<string, unknown>,
|
||
roomId: number,
|
||
accountId: number
|
||
): Promise<boolean> {
|
||
if (room.CreatorAccountId === accountId) 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.
|
||
*/
|
||
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` are always null: we resolve no baked Unity assets.
|
||
*/
|
||
function toSaveResponse(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),
|
||
unityAsset: null,
|
||
unityAssetHash: 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) ?? '',
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
}
|
||
}
|
||
|
||
/** 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 })
|
||
}
|
||
|
||
/** 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
|
||
|
||
/**
|
||
* The XP settings every room reports (`GET /rooms/{roomId}/experience`). Constants because
|
||
* nothing stores them per room and nothing enforces them: the `api` worker's progression
|
||
* grants XP without a room-scoped daily cap, so these are what the client is told, not a
|
||
* limit this server applies.
|
||
*
|
||
* Disabled, which is the honest answer here — no room awards XP. `DailyLimit` is kept at
|
||
* the reference's number rather than zeroed: it is the cap that WOULD apply, and the client
|
||
* reads both keys whatever `Enabled` says.
|
||
*/
|
||
const ROOM_XP_ENABLED = false
|
||
const ROOM_XP_DAILY_LIMIT = 1000
|
||
|
||
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())
|
||
|
||
.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 only. 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.',
|
||
'`#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
|
||
// rooms search considers. 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 rooms `/rooms/search`',
|
||
'searches, so every suggestion is one that finds something when submitted; 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 a friend's profile.
|
||
// Auth-gated (401), and FRIENDS-ONLY: a valid token for someone who isn't that
|
||
// player and isn't a mutual friend of theirs is a 403, since where a player has
|
||
// been is not public. 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.
|
||
.get(
|
||
'/rooms/visitedby/:playerId{[0-9]+}',
|
||
describeRoute({
|
||
tags: ['Rooms'],
|
||
summary: 'A friend’s visited rooms',
|
||
description: [
|
||
'The rooms another player has visited, as a bare array. Friends only: the caller must',
|
||
'be that player or a mutual friend of theirs (403 otherwise) — visit history is not',
|
||
'public.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
parameters: [playerIdParam, ...pageParams(100)],
|
||
responses: {
|
||
200: json(RoomDto.array(), 'That player’s visited rooms'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: NOT_FRIENDS_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 always readable (the client sometimes sends the id
|
||
// rather than `me`); anyone else's needs a mutual friendship.
|
||
if (playerId !== accountId && !(await areFriends(c.env.DB, accountId, playerId))) {
|
||
return c.body(null, 403)
|
||
}
|
||
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, 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 starts with no tags,',
|
||
'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 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)
|
||
}
|
||
)
|
||
|
||
// 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 or anyone whose presence puts them in the room (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.',
|
||
'`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
|
||
'',
|
||
'The list includes STAGED saves that were never published, so it is not public:',
|
||
'the room’s creator may read it, and so may anyone standing IN the room (their live',
|
||
'presence says so). 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', 'Accepted and ignored'),
|
||
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)
|
||
|
||
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 or anyone whose live',
|
||
'presence puts them in the room, 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, in the SAME camelCase projection the room save that created it',
|
||
'returned — not the PascalCase rows `…/saves` lists. 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, or anyone whose presence puts',
|
||
'them in the room. A save id resolves whether or not it was ever published, so this',
|
||
'reads unpublished work.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
parameters: [roomIdParam, subRoomIdParam, saveIdParam],
|
||
responses: {
|
||
200: json(SubRoomDataSaveResponseDto, '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)
|
||
return save ? c.json(toSaveResponse(save)) : c.notFound()
|
||
}
|
||
)
|
||
|
||
// 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), `RoomData` the
|
||
// metadata blob. `OwnershipProof` is accepted and ignored.
|
||
const body = (await c.req.json().catch(() => ({}))) as {
|
||
RoomData?: { Filename?: string }
|
||
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,
|
||
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)
|
||
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 creator-only (403), like the other
|
||
// subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing,
|
||
// so there is no envelope to match.
|
||
.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`, and reads',
|
||
'always serve `true`. `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`: a stored entry replaces the default',
|
||
'with the same (`Permission`, `Role`) in the table the client applies when it spawns,',
|
||
'and one naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is added to it.',
|
||
'The overrides apply to the subroom the caller is standing in, resolved from presence.',
|
||
'',
|
||
'Creator-only — co-owners may build in a room but not decide what a role may do.',
|
||
'The response body is EMPTY: the client doesn’t read one.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
parameters: [roomIdParam, subRoomIdParam],
|
||
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
|
||
responses: {
|
||
200: { description: 'Stored (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: FORBIDDEN_RESPONSE,
|
||
404: { description: 'No such room or 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)
|
||
|
||
// 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 || !findSubRoom(room, subRoomId)) return c.notFound()
|
||
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
|
||
|
||
const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
|
||
await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
|
||
return c.body(null, 200)
|
||
}
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
)
|
||
|
||
// 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. Stub → empty blob (client reads `Data`).
|
||
.get(
|
||
'/rooms/:roomId{[0-9]+}/playerdata/me',
|
||
describeRoute({
|
||
tags: ['Rooms'],
|
||
summary: 'The caller’s per-room player data',
|
||
description: [
|
||
'Per-room save data for the calling player. Nothing stores any yet, so this is a stub',
|
||
'serving an empty blob — which the client reads as “no saved data”. No auth: there’s',
|
||
'no caller-specific state to protect until something writes here.',
|
||
].join(' '),
|
||
parameters: [roomIdParam],
|
||
responses: { 200: json(PlayerDataDto, 'An empty data blob') },
|
||
}),
|
||
(c) => c.json({ Data: '' })
|
||
)
|
||
|
||
// A room's XP settings — whether players earn experience there and how much of it counts
|
||
// toward their day. Fixed values, the same for every room: progression lives in the `api`
|
||
// worker and applies no per-room daily cap, so there is nothing room-scoped to read and
|
||
// nothing here enforces the number. It is what the client displays and meters against.
|
||
//
|
||
// A bare two-key object, no `{ success, error, value }` envelope, and no auth — nothing
|
||
// in the answer is per-player (`experience/player` below is the per-player half). The
|
||
// room isn't looked up either: the answer would be the same for a room that doesn't
|
||
// exist, so a lookup would only add a way to fail.
|
||
.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. Fixed values, and `Enabled` is FALSE —',
|
||
'no room awards XP here. Progression is the `api` worker’s and applies no per-room',
|
||
'cap, so nothing is stored per room and nothing enforces the limit; the client is what',
|
||
'reads it. No auth: the answer is the same for every caller and every room.',
|
||
].join(' '),
|
||
parameters: [roomIdParam],
|
||
responses: { 200: json(RoomExperience, 'The room’s XP settings — always the same') },
|
||
}),
|
||
(c) => c.json({ Enabled: ROOM_XP_ENABLED, DailyLimit: ROOM_XP_DAILY_LIMIT })
|
||
)
|
||
|
||
// The caller's per-room experience/progression. Stub → empty list.
|
||
.get(
|
||
'/rooms/:roomId{[0-9]+}/experience/player',
|
||
describeRoute({
|
||
tags: ['Rooms'],
|
||
summary: 'The caller’s per-room experience',
|
||
description: [
|
||
'Per-room experience/progression for the calling player. Nothing tracks any yet, so',
|
||
'this is an empty list — which the client reads as “no progress in this room”, where',
|
||
'a 404 would stall the room load. No auth, matching `playerdata/me`: the answer is',
|
||
'the same for every caller until something writes here.',
|
||
].join(' '),
|
||
parameters: [roomIdParam],
|
||
responses: { 200: json(RoomExperiencePlayer, 'An empty list') },
|
||
}),
|
||
(c) => c.json([])
|
||
)
|
||
|
||
// Single room by id. 404 when the room isn't in D1. Ignores the
|
||
// include/unityAsset* query params.
|
||
.get(
|
||
'/rooms/:roomId{[0-9]+}',
|
||
describeRoute({
|
||
tags: ['Rooms'],
|
||
summary: 'A room by id',
|
||
description: [
|
||
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
|
||
'unknown room here is a 404, not `{}`. The `include`/`unityAsset*` query params the',
|
||
'client sends are accepted and ignored.',
|
||
].join(' '),
|
||
parameters: [
|
||
roomIdParam,
|
||
stringQuery('include', 'Accepted and ignored'),
|
||
stringQuery('unityAssetTarget', 'Accepted and ignored'),
|
||
stringQuery('unityAssetVersion', 'Accepted and ignored'),
|
||
],
|
||
responses: { 200: json(RoomDto, 'The room'), 404: { description: 'No such room' } },
|
||
}),
|
||
async (c) => {
|
||
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||
return room ? c.json(room) : c.notFound()
|
||
}
|
||
)
|
||
|
||
// 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,
|
||
})
|
||
)
|
||
|
||
// 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).',
|
||
'',
|
||
'`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 (Role 0) maker pen is granted only to the hardcoded dev accounts.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
handlePhotonAccessToken
|
||
)
|
||
|
||
// 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' }],
|
||
components: {
|
||
securitySchemes: {
|
||
bearerAuth: {
|
||
type: 'http',
|
||
scheme: 'bearer',
|
||
bearerFormat: 'JWT',
|
||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
)
|
||
)
|
||
|
||
export default app
|