support for 202507 endpoints (#37)

* [auth][api] accept the 20250424.01 client

Version check now answers "current" for a set of builds rather than one:
SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is
unchanged and still what the server reports for itself (presence, rn.ver).

Adds GET /api/versioncheck/islandedversions, always [] — we never island a
build off into its own matchmaking pool.

The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a
deviceId/platformAuth/time form body where the 2023 build GETs it, so that
route now takes both methods. The body is accepted and ignored for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by GitHub
parent 66c09806f9
commit 34171417e3
162 changed files with 114930 additions and 469 deletions
+5
View File
@@ -32,6 +32,11 @@ deserializer for that call — not a choice, and not something to unify:
Both answer HTTP 200 even for a rejection: the client reads the flag, not the status.
Only a missing or invalid token is a real 401, and only the owner/co-owner gate is a 403.
Reads are the exception: they carry no envelope at all. A paged read (the save history at
`…/subrooms/{subRoomId}/saves` and its lighter `…/saves/no_unity_assets` twin) is a bare
`{ Results, TotalResults, TotalCount }` wrapper — `TotalResults` and `TotalCount` are the
same number, because the client's paged DTO and the reference disagree on the name.
## Development
### Run in dev mode
+141 -7
View File
@@ -104,7 +104,7 @@ export function stringQuery(name: string, description: string): OpenAPIV3_1.Para
}
/** An optional integer query parameter. */
function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
}
@@ -294,6 +294,13 @@ export const RoomDto = z.object({
PublishedAt: z.string(),
BecameRRStudioRoomAt: z.string().nullable(),
Stats: RoomStatsDto,
BoostCount: z
.int()
.describe('Boosts on the room. Nothing grants boosts here, so always 0 — but present'),
CurrentSnapshotId: z
.int()
.nullable()
.describe('The rooms published snapshot. Nothing takes snapshots here, so always null'),
RankingContext: z.unknown().nullable(),
IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'),
IsPlacePlay: z.boolean(),
@@ -333,6 +340,35 @@ export const PagedRooms = z.object({
TotalResults: z.int().describe('The full match count, not the page size'),
})
/**
* `GET /rooms/autocomplete_search` — the search box's suggestions: a bare array of plain
* STRINGS, not rooms and not an envelope. Each is a query the player can submit as-is; a
* tag suggestion carries its `#` so submitting it searches by tag.
*/
export const SearchSuggestions = z
.array(z.string())
.describe('Suggested search terms, best match first; empty when nothing matches')
/**
* `GET /rooms/{roomId}/experience` — whether players earn XP in a room and how much of it
* counts in a day. A bare two-key object, no envelope.
*
* Nothing here meters per-room XP: progression is the `api` worker's, and it applies no
* room-scoped daily cap. So this is the config the client reads, not a limit this server
* enforces — the same answer for every room.
*/
export const RoomExperience = z.object({
Enabled: z.boolean().describe('Whether XP is earned in the room at all. Always false here'),
DailyLimit: z.int().describe('XP from this room that counts toward a players day'),
})
/**
* `GET /dormroom/me` — the dorm's `RoomId` as a BARE JSON number, not a room and not an
* envelope around one. The caller follows it with `GET /rooms/{roomId}` when it wants the
* room itself, so sending the whole DTO here was a payload nobody read.
*/
export const DormRoomId = z.int().describe('The callers dorm RoomId')
/**
* A room lookup result: the room, or `{}` when nothing matched. The by-id/by-name
* lookups answer an empty object rather than a 404 — the client reads that as "no room".
@@ -344,6 +380,25 @@ export const MissingLookupParam = z
.string()
.describe("`\"Either 'id' or 'name' query parameter is required\"`")
/**
* `GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned` — the ban check's envelope.
*
* NOT the `{ success, error, value }` the room mutations answer with: this one carries an
* `error_id` as well, and its `error` is NULL rather than the empty string those use. Same
* distinction the client's other envelopes draw, so don't unify them.
*/
export const IsBannedEnvelope = z.object({
success: z.literal(true).describe('The check ran; whether the player is banned is `value`'),
error: z.string().nullable().describe('Null — the check itself does not fail'),
error_id: z.string().nullable().describe('Null. Present as a key, unlike the room envelope'),
value: z.boolean().describe('Whether that player is banned from that room'),
})
/** The bare JSON string the bulk lookups answer when the id list is over the cap. */
export const TooManyLookupIds = z
.string()
.describe('`"At most 100 room ids may be looked up at once"`')
// ---- Interaction -----------------------------------------------------------
/**
@@ -505,6 +560,19 @@ export const CloningRequest = z.object({
cloningAllowed: z.string().describe('`True` / `False`'),
})
/**
* `POST /rooms/bulk` form body — the room ids to look up, as a REPEATED `id` field
* (`id=888&id=532&…`), one value per id. This is how the client asks for a whole room list
* at once (70-odd ids in the wild), which is more than belongs in a query string.
*/
export const BulkRoomsRequest = z.object({
id: z.string().describe('Repeated once per room id; each value may also be comma-separated'),
excludePrivateRooms: z
.string()
.optional()
.describe('`True` drops rooms that are not publicly visible. Default `False`'),
})
/**
* `PUT /rooms/{roomId}/restrictions` — the room's platform/movement support flags. Only
* the fields actually posted are changed, and the names are matched case-insensitively.
@@ -626,14 +694,51 @@ export const SaveSubRoomDataRequest = z.object({
})
/**
* `GET /rooms/{roomId}/subrooms/{subRoomId}/saves` — the room-history page. We keep no
* save history (a save overwrites the subroom's blob inline), so it's always empty.
* `GET /rooms/{roomId}/subrooms/{subRoomId}/saves` — the room-history page. Every save
* appends a `subroom_save` row rather than overwriting, so this is the subroom's full
* history, newest first, paged by `skip`/`take`.
*/
export const SubRoomSavesPage = z.object({
Results: z
.array(SubRoomDataSaveDto)
.describe('At most one — the current save; we keep no history'),
TotalResults: z.int(),
Results: z.array(SubRoomDataSaveDto).describe('The page of saves, newest first'),
TotalResults: z.int().describe('The whole historys size, not the pages'),
TotalCount: z.int().describe('Same value as `TotalResults` — the two references disagree'),
})
/**
* One save as `GET …/subrooms/{subRoomId}/saves/no_unity_assets` lists it: the same
* PascalCase row as {@link SubRoomDataSaveDto} with the Unity-asset payloads left out —
* no `UnitySubAssets`, no `ReferencedUnityAssets`, no `Tags` — keeping only the asset
* IDENTIFIERS (`UnityAssetId`, `ReferencedUnityAssetIds`).
*
* The one field that differs rather than disappearing is `UnityAssetId`: always present
* here, null when the save carried none, where the full row emits it only when it did.
*/
export const SubRoomDataSaveNoUnityAssetsDto = z.object({
SubRoomDataSaveId: z.int(),
SubRoomId: z.int(),
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'),
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
DataBlobHash: z.string().nullable(),
PersistenceVersion: z.int(),
OMVersion: z.int(),
SavedByAccountId: z.int().nullable(),
SavedOnPlatform: z.int().describe('0 — the save request carries no platform'),
SavedOnDeviceClass: z.int().describe('0 — the save request carries no device class'),
Description: z.string().describe('The save comment; empty string when none'),
ModerationState: z.int(),
CreatedAt: z.string(),
UgcSubVersion: z.int(),
})
/**
* `GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets` — the same history page
* as {@link SubRoomSavesPage}, carrying the lighter rows. A BARE paged wrapper: no
* `{ success, error, value }` envelope around it, unlike the room mutations.
*/
export const SubRoomSavesNoUnityAssetsPage = z.object({
Results: z.array(SubRoomDataSaveNoUnityAssetsDto).describe('The page of saves, newest first'),
TotalResults: z.int().describe('The whole historys size, not the pages'),
TotalCount: z.int().describe('Same value as `TotalResults` — the two references disagree'),
})
@@ -675,3 +780,32 @@ export const PhotonAccessTokenDto = z.object({
export const PlayerDataDto = z.object({
Data: z.string().describe('Always empty — no per-room player data is stored'),
})
/**
* `GET /rooms/{roomId}/experience/player` — the caller's per-room experience/progression
* entries. Stubbed empty; the element shape is unknown until something stores one.
*/
export const RoomExperiencePlayer = z
.array(z.unknown())
.describe('Always empty — no per-room experience is tracked')
/**
* `GET /publishState/configs` — the limits the client enforces on republishing a room:
* how many updates are allowed in the rolling window, and the cooldown/expiry around
* them. Served as fixed values from the reference server.
*
* The envelope is NOT the `{ success, error, value }` one the room mutations use: `error`
* is null rather than `""`, and there's an extra `error_id`. Kept as-is — the client
* reads both keys.
*/
export const PublishStateConfigsEnvelope = z.object({
value: z.object({
UpdateMaxCount: z.int().describe('Updates allowed per rolling window'),
UpdateRollingWindowInDays: z.int().describe('Length of that window, in days'),
UpdateExpirationInDays: z.int().describe('Days before an update expires'),
UpdateCooldownInDays: z.int().describe('Days between updates'),
}),
success: z.literal(true),
error_id: z.null(),
error: z.null(),
})
+518 -15
View File
@@ -5,6 +5,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
areFriends,
autocompleteRoomSearch,
banPlayerFromRoom,
canManageRoom,
cloneRoom,
@@ -15,10 +16,12 @@ import {
deleteSubRoom,
findSubRoom,
getBaseRooms,
getContributedRooms,
getFavoritedRooms,
getFeaturedRooms,
getHotRooms,
getInteraction,
getOrCreateDormRoom,
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
@@ -32,6 +35,7 @@ import {
getSubRoomSaveById,
getSubRoomSaves,
getVisitedRooms,
isPlayerBannedFromRoom,
modifySubRoom,
publishSubRoomSave,
removeCheer,
@@ -58,7 +62,7 @@ import {
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
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.
@@ -68,15 +72,19 @@ import {
AUTHED,
bannedPlayerIdParam,
BanRequest,
BulkRoomsRequest,
CloneRoomRequest,
CloningRequest,
CreateSubRoomRequest,
DescriptionRequest,
DormRoomId,
FeaturedRoomGroupDto,
FORBIDDEN_RESPONSE,
form,
ImageRequest,
InteractionDto,
intQuery,
IsBannedEnvelope,
json,
jsonBody,
LoadScreenRequest,
@@ -90,26 +98,32 @@ import {
PlayerDataDto,
playerIdParam,
PublishSaveRequest,
PublishStateConfigsEnvelope,
RestrictionsRequest,
RoleRequest,
RoomBanEntryDto,
RoomBanEnvelope,
RoomDto,
RoomEnvelope,
RoomExperience,
RoomExperiencePlayer,
roomIdParam,
RoomLookup,
RoomResultEnvelope,
RoomSaveEnvelope,
saveIdParam,
SaveSubRoomDataRequest,
SearchSuggestions,
ServiceStatus,
stringQuery,
SubRoomAccessibilityRequest,
SubRoomDataSaveResponseDto,
subRoomIdParam,
SubRoomPermissionsRequest,
SubRoomSavesNoUnityAssetsPage,
SubRoomSavesPage,
TagRequest,
TooManyLookupIds,
UNAUTHORIZED_EMPTY,
UNAUTHORIZED_ENVELOPE,
UNAUTHORIZED_RESPONSE,
@@ -139,6 +153,20 @@ function firstId(idParam: string): number | undefined {
}
/** 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)
}
function allIds(idParam: string): number[] {
return idParam
.split(',')
@@ -281,6 +309,34 @@ 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`
@@ -513,6 +569,43 @@ function toSaveResponse(save: Record<string, unknown>) {
}
}
/**
* 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 })
@@ -542,6 +635,22 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
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(
'*',
@@ -639,6 +748,51 @@ const app = new Hono<App>()
}
)
// 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 clients 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 clients 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
@@ -730,28 +884,48 @@ const app = new Hono<App>()
// Featured rooms — a single always-active group whose `Rooms` are a randomly
// ordered set of public, non-dorm rooms. No real curation yet, so `current`
// just returns a shuffled list of eligible rooms in the featured-group shape.
// @todo This is not working. It somehow causes the other room listings to fail
// completely with NREs. I think it is the featured room load that is somehow
// corrupting the room cache. I tried sending the normal room shape but that
// did not seem to work.
//
// 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(
'/XXXfeaturedrooms/current',
'/featuredrooms/current',
describeRoute({
tags: ['Discovery'],
summary: 'Featured rooms (parked — the path is deliberately broken)',
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.',
'',
'**Parked.** The path the client calls is `/featuredrooms/current`; this is registered',
'under an `XXX` prefix so the client never reaches it. Serving it made the OTHER room',
'listings fail with NREs in the client, apparently by corrupting its room cache —',
'sending the normal room shape instead did not help. It stays registered so the shape',
'is documented and the route is one rename away once the cause is found.',
'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 tokens `rn.ver` claim, which is why this needs a token at all: nothing in',
'the group itself is per-player.',
].join('\n'),
responses: { 200: json(FeaturedRoomGroupDto, 'The featured-room group') },
security: AUTHED,
responses: {
200: json(FeaturedRoomGroupDto, 'The featured-room group'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'The callers 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))
}
)
@@ -775,7 +949,10 @@ const app = new Hono<App>()
],
responses: {
200: json(RoomDto.array(), 'The rooms that matched (missing ids are omitted)'),
400: json(MissingLookupParam, 'Neither `id` nor `name` was supplied'),
400: json(
MissingLookupParam,
'Neither `id` nor `name` was supplied, or more than 100 ids were'
),
},
}),
async (c) => {
@@ -785,13 +962,67 @@ const app = new Hono<App>()
return c.json("Either 'id' or 'name' query parameter is required", 400)
}
if (idParam) {
return c.json(await getRoomsByIds(c.env.DB, allIds(idParam)))
const ids = allIds(idParam)
if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
return c.json(await getRoomsByIds(c.env.DB, ids))
}
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 arent 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 rooms accessibility, which is what makes a players 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 = (field('excludePrivateRooms')[0] ?? '').toLowerCase() === 'true'
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
@@ -845,6 +1076,72 @@ const app = new Hono<App>()
ownedRooms
)
// Rooms the caller CONTRIBUTES to — someone else's rooms 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.
//
// Rooms the caller created are excluded: a room's `Roles` carries its creator too, so
// without that this would repeat `createdby/me` wholesale, and the client shows the two
// as separate lists. 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 contributes to',
description: [
'The rooms that name the caller in their `Roles` — Host, Moderator or CoOwner — as a',
'bare array of rooms. Rooms the caller CREATED are excluded: those are',
'`ownedby/me`/`createdby/me`, and a rooms roles list its creator too, so including',
'them would just repeat that list. 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 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 callers dorm id',
description: [
'The `RoomId` of the callers 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 callers 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(
@@ -1632,6 +1929,66 @@ const app = new Hono<App>()
}
)
// 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 clients 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)
const banned = await isPlayerBannedFromRoom(
c.env.DB,
Number.parseInt(c.req.param('roomId'), 10),
Number.parseInt(c.req.param('playerId'), 10)
)
return c.json({ success: true, error: null, error_id: null, value: banned })
}
)
// 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(
@@ -1990,6 +2347,75 @@ const app = new Hono<App>()
}
)
// 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 subrooms 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 rooms 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 clients 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 subrooms 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.
@@ -2611,6 +3037,51 @@ const app = new Hono<App>()
(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 rooms 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` workers 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 rooms 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 callers 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(
@@ -2637,6 +3108,38 @@ const app = new Hono<App>()
}
)
// 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',
+445 -13
View File
@@ -34,10 +34,22 @@ function b64url(input: ArrayBuffer | string): string {
}
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none — what a plain player's looks like to the role gates.
async function bearer(sub: string, roles?: string[]): Promise<Record<string, string>> {
async function bearer(
sub: string,
roles?: string[],
// The client build the token was minted for (`rn.ver`), which is what
// `/featuredrooms/current` gates on. Omitted by default, like a token from a grant that
// posted no `ver`.
version?: string
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
JSON.stringify({
sub,
exp: now + 3600,
...(roles && { role: roles }),
...(version && { 'rn.ver': version }),
})
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -89,6 +101,18 @@ beforeAll(async () => {
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Accounts table (owned by the auth worker) — provisioning a dorm reads the username
// to name the room. Seed the player `dormroom/me` provisions a fresh dorm for.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS account (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 999, username: 'Dormer' }))
.run()
// Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to
// check the caller is a friend of the player whose history they're asking for.
await env.DB.prepare(
@@ -129,6 +153,44 @@ describe('rooms endpoints', () => {
expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
})
// Neither is stored — the seed blobs predate both keys — so they are defaulted on read.
// The client's room DTO always carries them, and an ABSENT key is not the same as a
// zero/null one to its parser.
it('GET /rooms/:id carries BoostCount and CurrentSnapshotId', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/1`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body).toHaveProperty('BoostCount', 0)
expect(body).toHaveProperty('CurrentSnapshotId', null)
})
// Pinned whole: these are the numbers the client's publish UI counts against, and
// `error: null` / `error_id` is a different envelope from the room mutations' — a
// "cleanup" that unified the two would break the client silently.
it('GET /publishState/configs returns the republish limits', async () => {
const res = await SELF.fetch(`${ORIGIN}/publishState/configs`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
value: {
UpdateMaxCount: 3,
UpdateRollingWindowInDays: 365,
UpdateExpirationInDays: 30,
UpdateCooldownInDays: 45,
},
success: true,
error_id: null,
error: null,
})
})
// Stub. Registered (not 404) matters more than the body: the client asks for this on
// room entry, and an unregistered path stalls the load rather than erroring visibly.
it('GET /rooms/:id/experience/player returns [] for any room', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/92/experience/player`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('GET /rooms/:id 404s for a room not in D1', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/99999`)
expect(res.status).toBe(404)
@@ -163,6 +225,62 @@ describe('rooms endpoints', () => {
expect(body.map((r) => r.Name)).toEqual(['RecCenter'])
})
it('POST /rooms/bulk takes repeated id fields in a form body', async () => {
const post = async (body: string) =>
SELF.fetch(`${ORIGIN}/rooms/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
// The client's form: one `id` per room, plus the filter. An id that isn't in D1 is
// simply absent, so the answer can be shorter than the request.
const res = await post('id=1&id=2&id=999999&excludePrivateRooms=False')
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number; Name: string }>
expect(body.map((r) => r.Name).sort()).toEqual(['DormRoom', 'RecCenter'])
// Comma-separated values inside an `id` work too, as they do on the GET.
const commas = (await (await post('id=1,2')).json()) as Array<{ Name: string }>
expect(commas.map((r) => r.Name).sort()).toEqual(['DormRoom', 'RecCenter'])
// `excludePrivateRooms=True` drops the non-public rooms — the dorm here.
const publicOnly = (await (await post('id=1&id=2&excludePrivateRooms=True')).json()) as Array<{
Name: string
Accessibility: number
}>
expect(publicOnly.map((r) => r.Name)).toEqual(['RecCenter'])
expect(publicOnly.every((r) => r.Accessibility === 1)).toBe(true)
// No ids is an empty array, not a 400 — unlike the GET, which needs an `id` or `name`.
expect(await (await post('excludePrivateRooms=False')).json()).toEqual([])
// D1 binds one parameter per id and caps a query at 100, so a longer list is refused
// rather than split — a caller asking about more than a hundred rooms at once has lost
// track of what it is rendering.
const idList = (n: number) => Array.from({ length: n }, (_, i) => 500000 + i)
expect(
(
await post(
idList(100)
.map((id) => `id=${id}`)
.join('&')
)
).status
).toBe(200)
const overCap = await post(
idList(101)
.map((id) => `id=${id}`)
.join('&')
)
expect(overCap.status).toBe(400)
expect(await overCap.json()).toBe('At most 100 room ids may be looked up at once')
// The GET form has the same cap, counting the ids inside its comma-separated `id`.
const overCapGet = await SELF.fetch(`${ORIGIN}/rooms/bulk?id=${idList(101).join(',')}`)
expect(overCapGet.status).toBe(400)
})
it('GET /rooms/ownedby/me is auth-gated and scoped to the caller', async () => {
// No token → 401, no stub-account fallback (would otherwise leak account 1).
const noAuth = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`)
@@ -181,6 +299,49 @@ describe('rooms endpoints', () => {
expect(other).toEqual([])
})
it('GET /dormroom/me serves the callers dorm id, not the room', async () => {
// No token → 401. Without this the endpoint would hand out (and provision) a dorm
// for whichever account a fallback picked.
const noAuth = await SELF.fetch(`${ORIGIN}/dormroom/me`)
expect(noAuth.status).toBe(401)
// Account 1 owns the seeded dorm (RoomId 1). The body is that id ALONE — a bare
// JSON number, not the room and not an object wrapping the id.
const res = await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('1') })
expect(res.status).toBe(200)
expect(await res.json()).toBe(1)
// It is the id of a room that really is the caller's dorm — the caller fetches the
// room itself from /rooms/{id}.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/1`)).json()) as {
RoomId: number
IsDorm: boolean
CreatorAccountId: number
}
expect(room).toMatchObject({ RoomId: 1, IsDorm: true, CreatorAccountId: 1 })
// A player who has never entered their dorm gets one provisioned rather than a
// 404 — the get-or-create still happens, only the payload shrank. And it belongs
// to THEM, not the template dorm they were cloned from.
const fresh = (await (
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
).json()) as number
expect(typeof fresh).toBe('number')
expect(fresh).not.toBe(1)
const provisioned = (await (await SELF.fetch(`${ORIGIN}/rooms/${fresh}`)).json()) as {
IsDorm: boolean
CreatorAccountId: number
}
expect(provisioned).toMatchObject({ IsDorm: true, CreatorAccountId: 999 })
// Idempotent: the second call is the same dorm, not a second one.
const again = (await (
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
).json()) as number
expect(again).toBe(fresh)
})
// The website's "My rooms" list is a browser calling this worker from another origin,
// so a response without CORS headers is one the browser throws away — and the page
// can't tell that apart from the server being down. Pinned on the preflight too: the
@@ -246,6 +407,102 @@ describe('rooms endpoints', () => {
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
})
it('GET /rooms/contributedby/me lists rooms the caller has a role in, not their own', async () => {
const seed = (data: Record<string, unknown>) =>
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
// A room somebody else made, where 820 is a co-owner...
await seed({
RoomId: 30401,
Name: 'ContribCoOwner',
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [
{ AccountId: 821, Role: 255 },
{ AccountId: 820, Role: 30 },
],
})
// ...one where they're only a host (every tier counts, not just owner-level)...
await seed({
RoomId: 30402,
Name: 'ContribHost',
CreatorAccountId: 821,
// Unpublished: a contributor works on the room before it goes public, so
// accessibility is not filtered here.
Accessibility: 0,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 10 }],
})
// ...one they created themselves, whose Roles name them as Creator...
await seed({
RoomId: 30403,
Name: 'ContribOwn',
CreatorAccountId: 820,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 255 }],
})
// ...one they have nothing to do with, and one with no Roles key at all (the older
// seeded rooms have none — json_each must drop them, not error).
await seed({
RoomId: 30404,
Name: 'ContribOther',
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 822, Role: 30 }],
})
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('820'),
})
expect(res.status).toBe(200)
const rooms = (await res.json()) as Array<{ RoomId: number; Name: string }>
// A bare array of the canonical room DTO — no envelope, no paging wrapper.
expect(Array.isArray(rooms)).toBe(true)
expect(rooms.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([30401, 30402])
// The caller's OWN room is excluded, or this would just repeat createdby/me.
expect(rooms.some((r) => r.RoomId === 30403)).toBe(false)
expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) })
// A player who contributes to nothing gets an empty array, not a 404.
const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('829'),
})
expect(await none.json()).toEqual([])
// Auth-scoped: `me` is the token, so no token is a 401.
expect((await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`)).status).toBe(401)
// The DB is shared across this file, and these are the only player-made public rooms
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30405').run()
})
it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/experience`)
expect(res.status).toBe(200)
// A bare two-key object — no `{ success, error, value }` envelope around it. Disabled:
// no room awards XP here, and DailyLimit is the cap that would apply if one did.
expect(await res.json()).toEqual({ Enabled: false, DailyLimit: 1000 })
// Nothing is stored per room, so every room answers the same — including one that
// doesn't exist, which is never looked up.
expect(await (await SELF.fetch(`${ORIGIN}/rooms/77/experience`)).json()).toEqual({
Enabled: false,
DailyLimit: 1000,
})
expect(await (await SELF.fetch(`${ORIGIN}/rooms/99999/experience`)).json()).toEqual({
Enabled: false,
DailyLimit: 1000,
})
// The id is digits-only, like the other room-scoped routes.
expect((await SELF.fetch(`${ORIGIN}/rooms/abc/experience`)).status).toBe(404)
})
it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`)
expect(res.status).toBe(200)
@@ -276,6 +533,46 @@ describe('rooms endpoints', () => {
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
})
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
const suggest = async (query: string, extra = '') =>
(await (
await SELF.fetch(
`${ORIGIN}/rooms/autocomplete_search?query=${encodeURIComponent(query)}${extra}`
)
).json()) as string[]
// A bare array of STRINGS — not rooms, not an envelope.
const rec = await suggest('rec', '&take=4&searchSessionId=abc-123')
expect(Array.isArray(rec)).toBe(true)
for (const s of rec) expect(typeof s).toBe('string')
expect(rec).toContain('RecCenter')
expect(rec.length).toBeLessThanOrEqual(4)
// Every suggestion finds something when submitted — the point of the endpoint.
for (const term of rec) {
const found = (await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=${encodeURIComponent(term)}`)
).json()) as { TotalResults: number }
expect(found.TotalResults, `"${term}" must find rooms`).toBeGreaterThan(0)
}
// Tags are suggested with their `#`, and a `#` query suggests tags only.
const tags = await suggest('#rro')
expect(tags).toEqual(['#rro'])
expect(tags.every((t) => t.startsWith('#'))).toBe(true)
// `take` caps the list; an empty query suggests nothing rather than everything.
expect((await suggest('e', '&take=2')).length).toBeLessThanOrEqual(2)
expect(await suggest('')).toEqual([])
expect(await suggest('zzzznothingmatchesthis')).toEqual([])
// Deterministic: the same query suggests the same things in the same order.
expect(await suggest('rec')).toEqual(rec)
// Dorms are excluded, exactly as they are from search.
expect(await suggest('dormroom')).toEqual([])
})
it('GET /rooms/search excludes dorms and respects pagination shape', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=dormroom`)
expect(res.status).toBe(200)
@@ -580,9 +877,7 @@ describe('rooms endpoints', () => {
it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => {
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
const feed = async (): Promise<Feed> =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)
).json()) as Feed
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)).json()) as Feed
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
// No room carries a `community` tag, and every seeded room belongs to Coach
@@ -672,13 +967,13 @@ describe('rooms endpoints', () => {
expect(body.length).toBeLessThanOrEqual(3)
})
// Skipped: the endpoint is disabled. Serving it broke the client — the other room
// listings started failing with NREs, apparently because the featured-room load
// corrupts the client's room cache — so the route is registered under an `XXX`
// prefix (see rooms.app.ts) and this path 404s. The handler and its test are kept
// intact for whenever the cause is found; un-prefix the route to re-enable both.
it.skip('GET /featuredrooms/current returns a featured-room group of public rooms', async () => {
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`)
// Served only to the client builds that render it — the 2023 client's other room
// listings start failing with NREs when it gets this payload, which is why the route
// was parked entirely for a while (see FEATURED_ROOMS_VERSIONS in rooms.app.ts).
it('GET /featuredrooms/current serves the group to a supported client build', async () => {
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, '20250718.01'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
FeaturedRoomGroupId: number
@@ -696,6 +991,20 @@ describe('rooms endpoints', () => {
expect(body.Rooms.some((r) => r.RoomId === 1)).toBe(false)
})
it('GET /featuredrooms/current withholds the group from other client builds', async () => {
// The 2023 build gets the 404 it got while the route was parked — the state in which
// its room listings work. Same for a token with no `rn.ver` at all.
for (const version of ['20230414', '20231207', undefined]) {
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, version),
})
expect(res.status, `build ${version}`).toBe(404)
}
// And no token at all is a 401, not a 404: the build is read off the token.
expect((await SELF.fetch(`${ORIGIN}/featuredrooms/current`)).status).toBe(401)
})
it('GET /rooms/:id/similar returns { Results, TotalResults } of tag-sharing rooms (excluding self)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar`)
expect(res.status).toBe(200)
@@ -1184,6 +1493,46 @@ describe('rooms endpoints', () => {
expect(await bansOf(2)).toHaveLength(2)
})
it('GET /Room_server/rooms/:id/bans/:playerId/isBanned answers the real ban state', async () => {
const isBanned = async (roomId: number, playerId: number, sub = '300') =>
SELF.fetch(`${ORIGIN}/Room_server/rooms/${roomId}/bans/${playerId}/isBanned`, {
headers: await bearer(sub),
})
// Auth-gated, but any authenticated caller may ask — a ban is not a secret from the
// player it stops.
expect((await SELF.fetch(`${ORIGIN}/Room_server/rooms/2/bans/205/isBanned`)).status).toBe(401)
// Nobody is banned from room 3.
const clean = await isBanned(3, 4242)
expect(clean.status).toBe(200)
// `success` says the CHECK ran; `value` is the answer. Note `error_id` is present and
// `error` is null — not the room mutations' `{ success, error, value }` with `""`.
expect(await clean.json()).toEqual({
success: true,
error: null,
error_id: null,
value: false,
})
// Ban someone from room 3, and the same call now says so.
await env.DB.prepare(
'INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)' +
" VALUES (?1, ?2, 0, 1, '2026-01-01T00:00:00Z')"
)
.bind(3, 4242)
.run()
expect(await (await isBanned(3, 4242)).json()).toMatchObject({ success: true, value: true })
// The ban is per (room, player): another room and another player are unaffected.
expect(await (await isBanned(2, 4242)).json()).toMatchObject({ value: false })
expect(await (await isBanned(3, 4243)).json()).toMatchObject({ value: false })
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
.bind(3, 4242)
.run()
})
it('POST /rooms/:id/bans kicks the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
@@ -2826,6 +3175,80 @@ describe('rooms endpoints', () => {
await clearPresence(999)
})
it('GET /rooms/:id/subrooms/:sid/saves/no_unity_assets lists the same history, lighter', async () => {
const get = async (path: string, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, sub === undefined ? {} : { headers: await bearer(sub) })
const light = '/rooms/2/subrooms/2/saves/no_unity_assets'
const res = await get(light, '1')
expect(res.status).toBe(200)
const page = (await res.json()) as {
Results: Array<Record<string, unknown>>
TotalResults: number
TotalCount: number
}
// The same history the full list serves — same rows, same order, same counts.
const full = (await (await get('/rooms/2/subrooms/2/saves', '1')).json()) as {
Results: Array<{ SubRoomDataSaveId: number; DataBlob: string }>
TotalResults: number
}
expect(page.TotalResults).toBe(full.TotalResults)
expect(page.TotalCount).toBe(page.TotalResults)
expect(page.Results.map((r) => r.SubRoomDataSaveId)).toEqual(
full.Results.map((r) => r.SubRoomDataSaveId)
)
// The lighter row: the Unity-asset PAYLOADS are gone (and `Tags` with them), the asset
// IDs stay, and `UnityAssetId` is present-and-null rather than omitted.
const row = page.Results[0]!
expect(Object.keys(row)).toEqual([
'SubRoomDataSaveId',
'SubRoomId',
'UnityAssetId',
'ReferencedUnityAssetIds',
'DataBlob',
'DataBlobHash',
'PersistenceVersion',
'OMVersion',
'SavedByAccountId',
'SavedOnPlatform',
'SavedOnDeviceClass',
'Description',
'ModerationState',
'CreatedAt',
'UgcSubVersion',
])
expect(row.UnityAssetId).toBe(null)
expect(row.ReferencedUnityAssetIds).toEqual([])
expect(row.SubRoomId).toBe(2)
expect(row.DataBlob).toBe(full.Results[0]!.DataBlob)
// skip/take page it the same way.
const paged = (await (await get(`${light}?skip=1&take=1`, '1')).json()) as {
Results: Array<{ SubRoomDataSaveId: number }>
TotalResults: number
}
expect(paged.Results).toHaveLength(1)
expect(paged.Results[0]!.SubRoomDataSaveId).toBe(full.Results[1]!.SubRoomDataSaveId)
expect(paged.TotalResults).toBe(full.TotalResults)
// A never-saved subroom pages empty rather than 404ing, as the full list does.
expect(await (await get('/rooms/3/subrooms/3/saves/no_unity_assets', '1')).json()).toEqual({
Results: [],
TotalResults: 0,
TotalCount: 0,
})
// Same gate as the list it mirrors — it exposes the same unpublished saves.
expect((await get(light)).status).toBe(401)
expect((await get(light, '999')).status).toBe(403)
expect((await get(light, '2')).status).toBe(403)
await putInRoom(999, 2)
expect((await get(light, '999')).status).toBe(200)
await clearPresence(999)
expect((await get(light, '999')).status).toBe(403)
})
it('GET /rooms/:id/subrooms/:sid/saves/:saveId is the detail behind a history row', async () => {
const get = async (path: string, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, sub === undefined ? {} : { headers: await bearer(sub) })
@@ -2910,11 +3333,16 @@ describe('rooms endpoints', () => {
'DELETE /rooms/{roomId}/interactionby/me/favorite',
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
'GET /',
'GET /XXXfeaturedrooms/current',
'GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned',
'GET /dormroom/me',
'GET /featuredrooms/current',
'GET /photon_access_token',
'GET /publishState/configs',
'GET /rooms',
'GET /rooms/autocomplete_search',
'GET /rooms/base',
'GET /rooms/bulk',
'GET /rooms/contributedby/me',
'GET /rooms/createdby/me',
'GET /rooms/favoritedby/me',
'GET /rooms/hot',
@@ -2926,12 +3354,16 @@ describe('rooms endpoints', () => {
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/experience',
'GET /rooms/{roomId}/experience/player',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
'GET /roomserver/rooms/createdby/me',
'POST /rooms/bulk',
'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/subrooms',