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:
devin
2026-08-18 23:07:24 -04:00
committed by Devin Zuczek
parent 66c09806f9
commit 178d3b5b0e
162 changed files with 114930 additions and 469 deletions
+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',