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
+27
View File
@@ -0,0 +1,27 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { json, JsonArray, stringParam } from '../openapi'
import type { App } from '../context'
// ---- Account ---------------------------------------------------------------
// The identity service's account-scoped reads. Nothing here is backed by storage — the
// client calls it while loading the account, and an empty list is a complete answer for a
// server that links no external channels to an account.
export const accountRoutes = new Hono<App>({ strict: false }).get(
'/iam/me/channels/:type',
describeRoute({
tags: ['Account'],
summary: 'The callers channels of a type',
description:
'The channels of the given `{type}` linked to the callers account. This server ' +
'links none, so the list is always empty — a real answer rather than a placeholder ' +
'for one, since the client renders "nothing linked" from it. `{type}` is accepted ' +
'but not inspected, and the route is not auth-gated: the answer is the same for ' +
'every caller and every type.',
parameters: [stringParam('type', 'The channel type. Accepted but not inspected.')],
responses: { 200: json(JsonArray, 'Always an empty list') },
}),
(c) => c.json([])
)
+234
View File
@@ -2,9 +2,12 @@ import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
CURRENT_OUTFIT_SLOT,
getOutfit,
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
setOutfit,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
@@ -31,6 +34,7 @@ import {
import {
AUTHED,
BareBoolean,
BulkCustomAvatarItemsRequest,
CustomAvatarItemsPage,
ErrorResponse,
form,
@@ -46,6 +50,10 @@ import {
json,
JsonArray,
jsonBody,
LegacyAvatarItemSaves,
OutfitSaveResponse,
OutfitsMeRequest,
OutfitsMeResponse,
pageParams,
SaveInventionRequest,
SetTagsRequest,
@@ -156,6 +164,28 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// A batch lookup of LOCKED avatar items — the items the client shows greyed out, so it
// posts the ids it wants the locked state for. Nothing here locks avatar items (the
// catalogs `econ` serves are all unlocked), so nothing comes back and the client renders
// none as locked. Unlike its custom-item sibling above this one is NOT auth-gated: the
// reference answers the empty array outright, without validating a token first.
.post(
'/api/avatar/v1/lockeditems/bulk',
describeRoute({
tags: ['Avatar'],
summary: 'Locked avatar items in bulk',
description:
'Resolves a batch of avatar-item ids to the ones that are LOCKED for the caller, as ' +
'a bare array. Nothing on this server locks avatar items, so it is always `[]` and ' +
'the posted ids are not parsed — a miss is not an error, the client simply renders ' +
'nothing as locked.\n\n' +
'No auth, matching the reference, which returns the empty array without checking a ' +
'token — in contrast to `/api/customAvatarItems/v1/bulk`, which validates one first.',
responses: { 200: json(JsonArray, 'The locked items — always empty here') },
}),
(c) => c.json([])
)
// Custom avatar item gates — real Rec Room client endpoints with no backing
// implementation yet; we enable them. Flip to `false` to disable the
// corresponding flow. `isCreationAllowedForAccount` wraps its answer in the
@@ -218,6 +248,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
(c) => c.json([])
)
// A batch lookup of custom avatar items by id. The reference filters a static catalog
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
// ships empty too), so every id misses and the array is empty.
//
// Auth-gated, and the token is checked before anything else, as the reference does.
.post(
'/api/customAvatarItems/v1/bulk',
describeRoute({
tags: ['Avatar'],
summary: 'Custom avatar items in bulk',
description:
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
'answers this route with the filtered array alone.\n\n' +
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
'client reads the items it got back rather than the ids it asked for. Nothing ' +
'stores custom items here, so every id misses and this is always `[]` — which is ' +
'why the posted ids are not parsed.',
security: AUTHED,
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
responses: {
200: json(JsonArray, 'The matching items — always empty here'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
.get(
@@ -234,6 +300,147 @@ export const avatarRoutes = new Hono<App>({ strict: false })
(c) => c.json({ Results: [], TotalResults: 0 })
)
// The client asks which legacy avatar items have been rebuilt as custom items, so it
// can render the custom version instead. Nothing stores custom items yet, so nothing
// has a save — an empty list means "use the legacy items as-is".
.post(
'/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
describeRoute({
tags: ['Avatar'],
summary: 'Custom-item saves for legacy avatar items',
description:
'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' +
'by the legacy items `AvatarItemDesc`. Nothing stores custom items yet, so the map ' +
'is always empty — which the client reads as “render the legacy items as-is”. The ' +
'request body is ignored.\n\n' +
'The value shape is the official one, recorded here for documentation; we never ' +
'emit one until custom items are stored.',
responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') },
}),
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
)
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
// player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
// worker's saved-outfit slots live in); a player who has never saved gets the
// brand-new-account envelope instead.
.get(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
summary: 'The callers outfit',
description:
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
'handed back exactly as it was saved, since the payloads heavy fields are the ' +
'clients own JSON-in-a-string documents.\n\n' +
'A player who has never saved gets the brand-new-account envelope: all-null ' +
'`LegacyData`, no `Selections`, `DataVersion` 9.',
security: AUTHED,
responses: {
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
if (outfit !== null) return c.json(outfit)
return c.json({
LegacyData: {
SelectionsV1: null,
SelectionsV2: null,
FaceFeatures: null,
SkinColor: null,
HairColor: null,
},
Selections: [],
DataVersion: 9,
CustomizationSettings: null,
ThumbnailFileName: null,
Name: null,
Accessibility: 0,
Slot: 0,
})
}
)
// Saving an outfit through the same bare path — into the slot the body names, which
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
// it has to parse back.
//
// Answers the bare `{ Success, Error, error_id }` envelope — no `Value` key, and NOT the
// outfit that was just saved: the client keeps what it sent and only reads whether the
// save worked.
.put(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
summary: 'Save the callers outfit',
description:
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
'The payload is stored verbatim: its heavy fields (`SelectionsV2`, `FaceFeatures`, ' +
'`CustomizationSettings`) are whole JSON documents encoded as strings by the ' +
'clients own serializer, so nothing here parses or re-encodes them.\n\n' +
'The response is the base envelope with no `Value` key — three keys, and the outfit ' +
'is not echoed back. The mixed casing (`Success`/`Error` but `error_id`) is the ' +
'references, not a typo.',
security: AUTHED,
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
responses: {
200: json(OutfitSaveResponse, 'Saved — `{ Success: true, Error: null, error_id: null }`'),
400: json(ErrorResponse, 'Unparseable body'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
// The client sends `Slot`; a body without one saves the worn outfit.
const outfit = {
...body,
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
}
await setOutfit(c.env.DB, id, outfit)
return c.json({ Success: true, Error: null, error_id: null })
}
)
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
// belong in this list (and in what shape) has not been pinned down, so it answers []
// rather than guessing.
.get(
'/outfits/me/saved',
describeRoute({
tags: ['Avatar'],
summary: 'The callers saved outfits',
description:
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
'list should carry, and in what shape, is not pinned down yet.',
security: AUTHED,
responses: {
200: json(JsonArray, 'An empty list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
// or 404 when there's no such invention.
.get(
@@ -720,6 +927,33 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// shelf, and the "from creators you follow" row, from.
//
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
// the client reads it as "this creator has published nothing" and renders an empty
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
// a filter on the invention table's creator column, the same feed shape as `toptoday`
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
.get(
'/api/inventions/v1/fromcreators',
describeRoute({
tags: ['Inventions'],
summary: 'Inventions by particular creators (stub)',
description:
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
'a creators shelf, and the "from creators you follow" row. STUB: always an empty ' +
'array for now, which the client renders as "nothing published" rather than as a ' +
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
parameters: [
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
}),
(c) => c.json([])
)
// Invention search/browse: published inventions matching `value` (matched against
// name + description; absent → browse everything published), newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
+41 -4
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { GAME_VERSION } from '@repo/domain'
import { isSupportedGameVersion } from '@repo/domain'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
@@ -10,8 +10,10 @@ import {
ApiConfigV2,
AzureSpeechConfig,
BacktraceConfig,
IslandedVersions,
json,
JsonObject,
StatsigUserProperties,
VersionCheck,
} from '../openapi'
@@ -100,18 +102,33 @@ export const configRoutes = new Hono<App>({ strict: false })
summary: 'Client version check',
description:
'Whether the client build is current. Compares the clients `?v=` build against ' +
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
'client is on a different build.',
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' +
'client is on one of them, 1 when it is on some other build.',
responses: { 200: json(VersionCheck, 'Version status') },
}),
(c) =>
c.json({
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
)
// Islanding splits players onto version-specific matchmaking pools. We serve every
// supported build from one pool, so the list is empty — the client reads it as
// "nobody is islanded" and matchmakes normally.
.get(
'/api/versioncheck/islandedversions',
describeRoute({
tags: ['Config'],
summary: 'Islanded client builds',
description:
'The builds that are islanded off into their own matchmaking pool. This server ' +
'never islands a build, so the list is always empty.',
responses: { 200: json(IslandedVersions, 'Always an empty list') },
}),
(c) => c.json([])
)
.get(
'/api/gameconfigs/v1/all',
describeRoute({
@@ -123,6 +140,26 @@ export const configRoutes = new Hono<App>({ strict: false })
(c) => c.json(gameConfigsV1All)
)
// The property bag the client would attach to its Statsig user. The reference server
// doesn't send properties at all here — it answers a lone `success` carrying its
// `StatsigEnabled` config value, as a bool — so that is what this mirrors. This server
// runs no experiments and collects no analytics (see the placeholder keys
// `/api/config/v1/amplitude` serves), so the value is fixed and the same for everyone.
.post(
'/statsigUserProperties',
describeRoute({
tags: ['Config'],
summary: 'Statsig user properties',
description:
'Despite the name, the reference server returns no properties here — just ' +
'`success`, its `StatsigEnabled` config value as a bool. This server mirrors that ' +
'with a fixed `true`; it runs no experiments and collects no analytics, so nothing ' +
'here is per-account and it is not auth-gated.',
responses: { 200: json(StatsigUserProperties, 'The fixed `StatsigEnabled` flag') },
}),
(c) => c.json({ success: true })
)
// Voice chat config. The client fetches it to set up voice.
// No reference shape, so return an empty object until the client needs fields.
.get(
+262 -14
View File
@@ -6,6 +6,7 @@ import {
deleteImage,
getCheeredImageIds,
getImageByName,
getImagesByIds,
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
@@ -14,24 +15,29 @@ import {
setImageCheer,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImageMetadata,
toImagesPlayer,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
CheeredBulkRequest,
CheeredEntry,
CheerImageRequest,
DeleteImageRequest,
ErrorResponse,
form,
idParam,
ImageMetadataDto,
ImagesPlayerDto,
intQuery,
json,
JsonArray,
jsonBody,
pageParams,
PhotoTaggingSettingRequest,
PhotoTaggingSettingResponse,
SavedImageDto,
SlideshowResponse,
stringQuery,
@@ -41,6 +47,7 @@ import {
UploadImageResponse,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
@@ -53,6 +60,123 @@ const typeFolder: Record<number, string> = {
[SavedImageType.InventionThumbnail]: 'invention',
}
/**
* The player-settings key the photo-tagging preference is stored under, in the same
* per-player bag the `playersettings` worker owns (`player:<id>` → `{ key: value }`). It
* gets its own endpoints rather than being written through `/playersettings` because the
* client asks for it by name, but there is no separate store behind it — which is why the
* write below merges.
*
* Unlike the loose matching `match` does for `avoidJuniors`, the spelling is exact: nothing
* but these two routes reads or writes this key, so there is no client spelling to guess.
*/
const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting'
/**
* The preference a player has before they have ever set one. The value is an opaque enum
* ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET
* enum reads as — the reference's own default.
*/
const PHOTO_TAGGING_DEFAULT = 0
/** The player's settings map, or null when they have none / KV is unreachable. */
async function getPlayerSettings(
env: App['Bindings'],
accountId: number
): Promise<Record<string, string> | null> {
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${accountId}`,
'json'
).catch(() => null)
}
/** The caller's stored photo-tagging preference, or the default when they have none. */
async function readPhotoTaggingSetting(env: App['Bindings'], accountId: number): Promise<number> {
const stored = await getPlayerSettings(env, accountId)
const raw = stored?.[PHOTO_TAGGING_KEY]
const parsed = Number.parseInt(String(raw ?? ''), 10)
return Number.isNaN(parsed) ? PHOTO_TAGGING_DEFAULT : parsed
}
/**
* Write the preference back into the player's settings map.
*
* The write MERGES, as the `playersettings` worker's own PUT does: the map holds every
* setting the player has (OOBE state, tutorial mask, …), so storing this one on its own
* would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true there,
* and racing writers here means one player toggling two of their own options at once.
*/
async function writePhotoTaggingSetting(
env: App['Bindings'],
accountId: number,
setting: number
): Promise<void> {
const stored = (await getPlayerSettings(env, accountId)) ?? {}
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${accountId}`,
JSON.stringify({ ...stored, [PHOTO_TAGGING_KEY]: String(setting) })
)
}
/**
* The posted `Setting`, out of a JSON body (`{ "Setting": 1 }`, what the client sends) or a
* form one. Both casings are accepted, and a numeric string parses — the value is an
* integer either way. `undefined` when the body carries nothing readable, which the caller
* treats as "leave it alone" rather than as a write of 0.
*/
async function readPostedSetting(c: Context<App>): Promise<number | undefined> {
const body = (c.req.header('content-type') ?? '').includes('application/json')
? ((await c.req.json().catch(() => null)) as Record<string, unknown> | null)
: await c.req.parseBody().catch(() => null)
if (body === null || typeof body !== 'object' || Array.isArray(body)) return undefined
const raw = (body as Record<string, unknown>).Setting ?? (body as Record<string, unknown>).setting
if (typeof raw === 'number') return Number.isFinite(raw) ? Math.trunc(raw) : undefined
if (typeof raw !== 'string') return undefined
const parsed = Number.parseInt(raw.trim(), 10)
return Number.isNaN(parsed) ? undefined : parsed
}
/**
* The saved-image ids a cheer lookup is asking about, from wherever the client put them.
*
* The client POSTs them as a form body of repeated `id` fields — a photo grid asks about a
* whole page at once, ~100 ids, which is more than it wants to hang off a URL — and the
* same repeated-field spelling also works as a query string, which is how the GET form of
* this route takes them. Both are read, so one handler serves either.
*
* Each value may itself be a comma-separated list, and unparseable entries are dropped
* rather than failing the request: a stray id must not cost the caller the rest of the page.
*/
async function cheerLookupIds(c: Context<App>): Promise<number[]> {
const raw = [...(c.req.queries('id') ?? [])]
if (c.req.method !== 'GET') {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const key = Object.keys(body).find((k) => k.toLowerCase() === 'id')
const posted = key === undefined ? [] : body[key]
for (const value of Array.isArray(posted) ? posted : [posted]) {
if (typeof value === 'string') raw.push(value)
}
}
return raw
.flatMap((value) => value.split(','))
.map((value) => Number.parseInt(value.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId))
}
/**
* One `{ SavedImageId, IsCheered }` per requested id, in request order — the shared
* handler behind both the GET and the POST form of the bulk cheer lookup. The cheer state
* is the CALLER's, so two players asking about the same photo get different answers.
*/
async function cheerLookup(c: Context<App>) {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids = await cheerLookupIds(c)
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })))
}
// ---- Images ----------------------------------------------------------------
export const imageRoutes = new Hono<App>({ strict: false })
.get(
@@ -350,6 +474,44 @@ export const imageRoutes = new Hono<App>({ strict: false })
}
)
// Bulk image metadata by id (`?ids=207&ids=106`) — the client resolving a set of photo
// ids it already holds. A bare array in REQUEST order, so it can line the records up
// with what it asked for; an id with no record (or one that isn't public) is simply
// absent, which is why this answers 200 with a short list rather than 404ing the lot.
//
// Serves the RAW `SavedImage`, like `v6` (metadata by filename) and the room feed — NOT
// the `ImagesPlayer` projection the player photo LISTS use. Those are a rendered grid,
// where the raw record comes up blank; this is a metadata lookup.
//
// Public-only, as every image read here is: ids are sequential, so honouring whatever
// id is named would hand out private photos to anyone who counts.
.get(
'/api/images/v5/bulk',
describeRoute({
tags: ['Images'],
summary: 'Image metadata by id, in bulk',
description:
'The stored `SavedImage` records for the given ids (`?ids=207&ids=106`), as a bare ' +
'array in request order. An id with no record, or one that is not public, is absent ' +
'from the answer rather than an error — the list can be shorter than the request. ' +
'Serves the raw `SavedImage` (as `v6` does), not the `ImagesPlayer` projection the ' +
'player photo lists use.',
parameters: [
intQuery('ids', 'Repeatable; each value may also be a comma-separated list of image ids'),
],
responses: { 200: json(SavedImageDto.array(), 'The matching records, in request order') },
}),
async (c) => {
const ids =
c.req
.queries('ids')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
return c.json(await getImagesByIds(c.env.DB, ids))
}
)
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get(
@@ -358,11 +520,15 @@ export const imageRoutes = new Hono<App>({ strict: false })
tags: ['Images'],
summary: 'Image metadata by filename',
description:
'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' +
'has no metadata row.',
'An images metadata for a bucket key. 404s when the object exists but has no ' +
'metadata row.\n\n' +
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' +
'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' +
'`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' +
'`""`. Three shapes of one row; keep them straight.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
200: json(SavedImageDto, 'The image record'),
200: json(ImageMetadataDto, 'The images metadata'),
400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' },
},
@@ -371,7 +537,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
return image ? c.json(toImageMetadata(image)) : c.notFound()
}
)
@@ -409,6 +575,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
// `{ SavedImageId, IsCheered }` per requested id, in order.
//
// The client actually POSTs this (see below); the GET form is kept because it is the
// same lookup and costs one line, and a URL of ids is the easier thing to hand a
// browser or a curl.
.get(
'/api/images/v5/cheered/bulk',
describeRoute({
@@ -426,18 +596,96 @@ export const imageRoutes = new Hono<App>({ strict: false })
401: UNAUTHORIZED_RESPONSE,
},
}),
cheerLookup
)
// The same lookup as a POST, which is the form the client sends: the ids ride in a
// form-urlencoded body of repeated `id` fields (`id=651&id=570&…`) rather than the query
// string, because a photo grid asks about a full page at once — around a hundred ids,
// more than belongs in a URL. Same auth, same answer, same order.
.post(
'/api/images/v5/cheered/bulk',
describeRoute({
tags: ['Images'],
summary: 'Which photos the caller has cheered (bulk POST)',
description:
'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' +
'fills in the cheer buttons on a photo grid from this. The ids are a form body of ' +
'repeated `id` fields (`id=651&id=570&…`), which is how the client sends a page of ' +
'~100 at once; the query string is read too, so the GET form of this path answers ' +
'identically.',
security: AUTHED,
requestBody: form(CheeredBulkRequest, 'The image ids, as repeated `id` fields'),
responses: {
200: json(CheeredEntry.array(), 'One entry per requested id, in order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
cheerLookup
)
// Who may tag the caller in photos. The preference lives in the player-settings bag
// (`playerPhotoTaggingSetting`), not in a store of its own — these two routes exist
// because the client asks for it by name rather than through `/playersettings`.
//
// A bare JSON integer, not an envelope, and an opaque one: the value is an enum ordinal
// the client defines, stored and served back untouched, so it round-trips whatever the
// client means by it. A player who has never set one reads 0.
.get(
'/api/players/v1/playerPhotoTaggingSetting',
describeRoute({
tags: ['Images'],
summary: 'The callers photo-tagging preference',
description:
'Who may tag the caller in photos, as a bare JSON integer (the enum ordinal the ' +
'client defines — stored and served back untouched). `0` until the player sets one. ' +
'Stored as one key in the player-settings bag the `playersettings` worker owns.',
security: AUTHED,
responses: {
200: json(PhotoTaggingSettingResponse, 'The callers setting; 0 if never set'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids =
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
)
return c.json(await readPhotoTaggingSetting(c.env, id))
}
)
// Set the caller's photo-tagging preference. Answers the stored value, as the reference
// does — the client re-renders the toggle from the response rather than from what it
// sent.
//
// A body with no readable `Setting` leaves the stored preference ALONE and answers it,
// rather than writing the 0 an unbound .NET model would have carried: the value is
// opaque here, so a guess is indistinguishable from a real choice once it's stored.
.put(
'/api/players/v1/playerPhotoTaggingSetting',
describeRoute({
tags: ['Images'],
summary: 'Set the callers photo-tagging preference',
description:
'Stores `Setting` as the callers photo-tagging preference and answers the stored ' +
'value (a bare integer), which is what the client re-renders the toggle from. The ' +
'write merges into the player-settings bag, so the players other settings are left ' +
'alone. `Setting` is also read from a form body, and from a `setting` spelling; a ' +
'body carrying no readable value is a no-op that answers the current setting.',
security: AUTHED,
requestBody: jsonBody(PhotoTaggingSettingRequest, 'The preference to store'),
responses: {
200: json(PhotoTaggingSettingResponse, 'The setting the caller now has'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const setting = await readPostedSetting(c)
if (setting === undefined) return c.json(await readPhotoTaggingSetting(c.env, id))
await writePhotoTaggingSetting(c.env, id, setting)
return c.json(setting)
}
)
+97 -14
View File
@@ -14,6 +14,7 @@ import {
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
VoteToKickReason,
} from '../openapi'
import { createReport } from '../reports-db'
import { createWarning } from '../warnings-db'
@@ -57,15 +58,44 @@ const asFloat = (v: string | undefined): number | null => {
return Number.isNaN(n) ? null : n
}
/**
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
* and 6 for the game-conduct reasons, which are kick-worthy without being a policy
* violation of their own.
*/
const VOTE_TO_KICK_REASONS = [
{ Reason: 'Discriminatory language', ReportCategory: 102 },
{ Reason: 'Discriminatory behavior', ReportCategory: 102 },
{ Reason: 'Threats or encouraging suicide', ReportCategory: 102 },
{ Reason: 'Toxic behavior', ReportCategory: 102 },
{ Reason: 'Sexual behavior in public', ReportCategory: 101 },
{ Reason: 'Sexual language in public', ReportCategory: 101 },
{ Reason: 'Non-consensual sexual behavior', ReportCategory: 101 },
{ Reason: 'Player in walls or floor', ReportCategory: 103 },
{ Reason: 'Friendly fire', ReportCategory: 103 },
{ Reason: 'Microphone spam', ReportCategory: 103 },
{ Reason: 'Abusing bugs or exploits', ReportCategory: 103 },
{ Reason: 'Spawn camping', ReportCategory: 103 },
{ Reason: 'Inactive in games (AFK)', ReportCategory: 6 },
{ Reason: 'Prefab swapping', ReportCategory: 6 },
{ Reason: 'Not following game rules', ReportCategory: 6 },
] as const
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer.
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
// `Message` is null, not an empty string — the client distinguishes "no message"
// from a blank one.
.get(
// but this endpoint is not wired to them, so it's always the "not blocked" answer
// the reference server's stub `ReturnModerationBlockDetails()`.
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
// `Message` is null, not the empty string that stub sends — the client distinguishes
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
// DTO but left unset there, so they go out with their C# defaults.
// The newer client POSTs this with no body despite it being a pure read; it answers
// GET too, so the path is reachable from either build.
.on(
['GET', 'POST'],
'/api/PlayerReporting/v1/moderationBlockDetails',
describeRoute({
tags: ['Moderation'],
@@ -73,11 +103,13 @@ export const moderationRoutes = new Hono<App>({ strict: false })
description:
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer. Two details matter ' +
'to the client: ' +
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
'`Message` is null rather than an empty string — the client distinguishes “no ' +
'message” from a blank one.',
'not wired to them, so it is always the “not blocked” answer, following the ' +
'reference servers stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
'is a real category, and `Message` is null rather than the empty string that stub ' +
'sends — the client distinguishes “no message” from a blank one. ' +
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
'it with no body.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
}),
(c) =>
@@ -93,18 +125,69 @@ export const moderationRoutes = new Hono<App>({ strict: false })
TimeoutStartedAt: null,
})
)
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
// the client renders them in the order they arrive — and the list is grouped by the
// `ReportCategory` the resulting report is filed under, hate first, then sexual
// content, then griefing, then the game-conduct reasons. Fixed and the same for
// everyone, but auth-gated all the same, as the reference is.
.get(
'/api/PlayerReporting/v1/voteToKickReasons',
describeRoute({
tags: ['Moderation'],
summary: 'Vote-to-kick reasons',
description:
'The reasons offered when starting a vote-to-kick. Not hydrated yet, so the list ' +
'is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
'The reasons offered when starting a vote-to-kick, each with the `ReportCategory` ' +
'the report is filed under if the vote carries: 102 hate, 101 sexual content, 103 ' +
'griefing, 6 game conduct. A fixed list, in the order the client renders it.',
security: AUTHED,
responses: {
200: json(VoteToKickReason.array(), 'The reasons, in render order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(VOTE_TO_KICK_REASONS)
}
)
// The client asking whether IT should run its referee moderation — the in-client
// review flow a player with referee standing gets shown. Deliberately `false` for
// everyone: this is an archival server, and the referee program is one of the live
// moderation systems it does not run. Answering true would put the client into a flow
// with no cases behind it. A POST despite being a pure read, which is how the client
// asks.
.post(
'/api/PlayerReporting/v1/referee',
describeRoute({
tags: ['Moderation'],
summary: 'Whether the caller is a referee',
description:
'A bare JSON `false` — no envelope. The game client asks this to decide whether to ' +
'run its referee moderation flow. Always false: the referee program is switched ' +
'off here rather than unimplemented, since this server is archival.',
responses: { 200: json(BareBoolean, 'Always `false` — the program is off') },
}),
(c) => c.json(false)
)
// The referee's own case files — the reviews assigned to them. Empty for the same
// reason the flag above is false: the program is off, so no case is ever assigned. A
// caller reaching this at all has gone past that flag, so the empty list is a second
// line of defence rather than the normal path. A GET, unlike its POSTing neighbours
// in this flow.
.get(
'/api/referee/files',
describeRoute({
tags: ['Moderation'],
summary: 'Referee case files',
description:
'The moderation cases assigned to the caller as a referee. Always empty — the ' +
'referee program is switched off here (see `/api/PlayerReporting/v1/referee`), so ' +
'nothing is ever assigned.',
responses: { 200: json(JsonArray, 'An empty list — no cases are ever assigned') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/vtkreasons.json
)
.post(
'/api/PlayerReporting/v1/hile',
describeRoute({
+21
View File
@@ -224,3 +224,24 @@ export const progressionRoutes = new Hono<App>({ strict: false })
return c.json([])
}
)
// The progression events running right now — the limited-time XP events the client shows
// a banner and a progress track for.
//
// STUB: an empty list, which the client reads as "no event on" and skips the event UI
// entirely. That is the honest answer (nothing here runs events) and the safe one: a
// fabricated event would draw a track that never fills. No auth — whether an event is
// running is the same fact for everybody, and the client asks while loading.
.get(
'/api/progressionEvents/active',
describeRoute({
tags: ['Progression'],
summary: 'Progression events currently running (stub)',
description:
'The limited-time XP events in progress. Always an empty list — nothing on this ' +
'server runs one — which the client reads as “no event” and skips the event UI, ' +
'where a 404 would stall the load. No auth: it is the same answer for every player.',
responses: { 200: json(JsonArray, 'Empty — no event is running') },
}),
(c) => c.json([])
)
+33
View File
@@ -4,6 +4,7 @@ import { describeRoute } from 'hono-openapi'
import {
acceptFriendRequest,
addFriend,
countOnlineFriends,
getAccountsByIds,
getMutualFriendIds,
getRelationshipsForPlayer,
@@ -23,6 +24,7 @@ import {
AUTHED,
ErrorResponse,
form,
FriendOnlineCountResponse,
intQuery,
json,
JsonArray,
@@ -622,6 +624,37 @@ export const socialRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json([])
)
// How many of the caller's friends are online — the friends panel's header count.
// Answered from the friend graph joined to live presence, so it agrees with the
// friends the panel then lists. Auth-gated: the count is the CALLER's own.
.post(
'/api/messages/v1/friendOnlineStatus',
describeRoute({
tags: ['Social'],
summary: 'How many friends are online',
description:
'The callers `Friend` relationships joined to live `presence`. Only unexpired ' +
'presence counts, and friends in the lobby (no room instance) count too — they ' +
'are signed in, just not in a room.\n\n' +
'A friends `statusVisibility` is not consulted: nothing else in the stack filters ' +
'presence on it, so hiding people here would disagree with the list the client ' +
'renders underneath the count.\n\n' +
'A POST that takes no body — the player is the bearer token.',
security: AUTHED,
responses: {
200: json(FriendOnlineCountResponse, 'The callers online-friend count'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: true,
value: { FriendsOnlineCount: await countOnlineFriends(c.env.DB, id) },
})
}
)
.get(
'/api/messages/v1/favoriteFriendOnlineStatus',
describeRoute({