mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client * [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
This commit is contained in:
+141
-7
@@ -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 room’s 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 player’s 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 caller’s 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 history’s size, not the page’s'),
|
||||
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 history’s size, not the page’s'),
|
||||
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
@@ -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 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
|
||||
@@ -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 token’s `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 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))
|
||||
}
|
||||
)
|
||||
@@ -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 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 = (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 room’s 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 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(
|
||||
@@ -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 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)
|
||||
|
||||
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 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.
|
||||
@@ -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 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(
|
||||
@@ -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',
|
||||
|
||||
@@ -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 caller’s 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',
|
||||
|
||||
Reference in New Issue
Block a user