[api] fix a few 2023 compat issues

This commit is contained in:
Devin Zuczek
2026-09-15 11:22:33 -04:00
parent 3299f05940
commit 71bb29b8c7
14 changed files with 360 additions and 130 deletions
+71 -34
View File
@@ -28,6 +28,7 @@ import {
BannerImageRequest,
BioRequest,
BioResponse,
BulkIdsRequest,
CreateAccountRequest,
CreateAccountResult,
DisplayNameRequest,
@@ -51,6 +52,7 @@ import {
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
import type { Context } from 'hono'
import type { DescribeRouteOptions } from 'hono-openapi'
import type { Account } from '@repo/domain'
import type { App } from './context'
@@ -102,6 +104,69 @@ async function formField(c: Context<App>, name: string): Promise<string> {
return typeof value === 'string' ? value : ''
}
/**
* Read a REPEATED string field from a form-urlencoded / multipart body (`id=1&id=2`).
* `parseBody` keeps only the last value for a duplicated key unless it's told to collect
* them all, which is the whole point here.
*/
async function formFields(c: Context<App>, name: string): Promise<string[]> {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const value = body[name]
const values = Array.isArray(value) ? value : [value]
return values.filter((v): v is string => typeof v === 'string')
}
/**
* OpenAPI spec for the two `/account/bulk` registrations, which differ only in where the
* ids ride.
*/
function bulkRouteSpec(source: 'query' | 'body'): DescribeRouteOptions {
return {
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` values (query params and/or a form-urlencoded body) and',
'comma-separated lists. Every requested id appears in the response — ids with no',
'stored row get a synthesized default.',
].join(' '),
...(source === 'query'
? {
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of ids',
schema: { type: 'array', items: { type: 'string' } },
},
],
}
: { requestBody: form(BulkIdsRequest, 'Repeated `id` fields') }),
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}
}
/**
* Look up many accounts at once. Ids come from repeated `id` query params AND, on the
* POST form, repeated `id` body fields; either may carry a comma-separated list. Deduped
* (first occurrence wins) so a repeated id can't double a row or waste a bound parameter.
*/
async function bulkAccounts(c: Context<App>) {
const raw = [...(c.req.queries('id') ?? []), ...(await formFields(c, 'id'))]
const ids = [
...new Set(
raw
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => !Number.isNaN(n))
),
]
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
}
/**
* Project a stored account into the public account DTO — the client's camelCase
* shape, excluding private fields like `email` (surfaced only by /account/me).
@@ -286,40 +351,12 @@ const app = new Hono<App>()
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get(
'/account/bulk',
describeRoute({
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
'id appears in the response — ids with no stored row get a synthesized default.',
].join(' '),
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of ids',
schema: { type: 'array', items: { type: 'string' } },
},
],
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}),
async (c) => {
// Reads repeated `id` query params; also accept a comma-separated list.
const ids =
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
}
)
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
// The 2023 client asks for its friends list as a POST with the ids in a
// form-urlencoded body — the same `id=1&id=2&…` it would put in a query string,
// moved off the URL because a few hundred friends overflow it. GET is the same
// lookup and stays for callers (and docs) that prefer it.
.post('/account/bulk', describeRoute(bulkRouteSpec('body')), bulkAccounts)
.get(
'/account/:id/bio',
+10
View File
@@ -212,6 +212,16 @@ export const UsernameRequest = z.object({
),
})
/**
* `POST /account/bulk` form body. `id` repeats once per requested account (the client
* sends its whole friends list this way); each value may also be a comma-separated list.
*/
export const BulkIdsRequest = z.object({
id: z
.union([z.string(), z.string().array()])
.describe('Repeatable; each value may be a comma-separated list of ids'),
})
export const EmailRequest = z.object({
email: z
.string()
@@ -97,6 +97,48 @@ describe('public endpoints', () => {
expect(accounts[1].username).toBe('Player2')
})
test('POST /account/bulk reads the ids from a form body', async () => {
// The 2023 client asks for its friends list as a POST — the ids are in a
// form-urlencoded body, not the query string. GET-only left it a 404.
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'id=1&id=2&id=3',
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
expect(accounts[0].username).toBe('Coach')
})
test('POST /account/bulk answers more ids than D1 will bind at once', async () => {
// D1 caps a statement at 100 bound parameters; a real friends list runs past that,
// and an unchunked `IN (…)` 500s. Requesting a stored id in the LAST chunk proves
// the later statements ran, not just the first.
const ids = [...Array.from({ length: 250 }, (_, i) => 1000 + i), 1]
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: ids.map((id) => `id=${id}`).join('&'),
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual(ids)
expect(accounts.at(-1)?.username).toBe('Coach')
})
test('POST /account/bulk returns one entry per id, deduped', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
// Negative ids are real in the client's list and synthesize like any other.
body: 'id=1&id=-1&id=1',
})
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, -1])
expect(accounts[1].username).toBe('Player-1')
})
test('GET /account/search prefix-matches usernames, returns public DTOs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/search?name=coa`)
expect(res.status).toBe(200)
@@ -652,6 +694,7 @@ describe('auth-gated endpoints', () => {
'GET /accountprivacysettings/{id}',
'GET /emojiConfig/whitelistedEmojis',
'GET /parentalcontrol/me',
'POST /account/bulk',
'POST /account/create',
'POST /account/me/email',
'POST /account/me/phone',
+29 -15
View File
@@ -26,6 +26,8 @@
* don't clash with the other workers' migrations that share the database).
*/
import { bindPlaceholders, chunkForBinds } from '@repo/domain'
/** Schema DDL (mirror of migrations/0013_reputation.sql + 0014, folded into one CREATE). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reputation (
@@ -186,15 +188,22 @@ export async function getCheerCredits(
): Promise<Map<number, number>> {
const credits = new Map<number, number>()
if (playerIds.length === 0) return credits
const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ')
const { results } = await db
.prepare(
`SELECT player_id, cheers_left FROM player_cheer
WHERE created > ?1 AND player_id IN (${placeholders})`
// The window cutoff is `?1`, so the ids start at `?2` and each chunk is one short of
// D1's bind cap — a bulk read over a friends list runs well past it.
const pages = await Promise.all(
chunkForBinds(playerIds, 1).map((chunk) =>
db
.prepare(
`SELECT player_id, cheers_left FROM player_cheer
WHERE created > ?1 AND player_id IN (${bindPlaceholders(chunk, 1)})`
)
.bind(windowCutoff(now), ...chunk)
.all<{ player_id: number; cheers_left: number }>()
)
.bind(windowCutoff(now), ...playerIds)
.all<{ player_id: number; cheers_left: number }>()
for (const row of results) credits.set(row.player_id, row.cheers_left)
)
for (const row of pages.flatMap((page) => page.results)) {
credits.set(row.player_id, row.cheers_left)
}
return credits
}
@@ -219,15 +228,20 @@ export async function getReputations(
now: Date = new Date()
): Promise<Reputation[]> {
if (accountIds.length === 0) return []
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
const [{ results }, credits] = await Promise.all([
db
.prepare(`SELECT * FROM reputation WHERE account_id IN (${placeholders})`)
.bind(...accountIds)
.all<ReputationRow>(),
const [pages, credits] = await Promise.all([
// Chunked: the friends list behind `/api/playerReputation/v2/bulk` runs past D1's
// bind cap, and an unchunked statement fails the whole lookup rather than part of it.
Promise.all(
chunkForBinds(accountIds).map((chunk) =>
db
.prepare(`SELECT * FROM reputation WHERE account_id IN (${bindPlaceholders(chunk)})`)
.bind(...chunk)
.all<ReputationRow>()
)
),
getCheerCredits(db, accountIds, now),
])
const stored = new Map(results.map((r) => [r.account_id, r]))
const stored = new Map(pages.flatMap((page) => page.results).map((r) => [r.account_id, r]))
return accountIds.map((id) => {
const credit = credits.get(id) ?? DAILY_CHEER_CREDIT
const row = stored.get(id)
+22
View File
@@ -844,6 +844,28 @@ describe('public endpoints', () => {
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
})
test('the bulk profile reads answer more ids than D1 will bind at once', async () => {
// Both of these are asked about a whole friends list at once, which runs past D1's
// 100-bound-parameter cap; unchunked, the statement fails and the endpoint 500s
// rather than returning a short list.
const ids = Array.from({ length: 250 }, (_, i) => 6000 + i)
const query = ids.map((id) => `id=${id}`).join('&')
const progression = await exports.default.fetch(
`${ORIGIN}/api/players/v2/progression/bulk?${query}`
)
expect(progression.status).toBe(200)
const levels = (await progression.json()) as Array<{ PlayerId: number }>
expect(levels.map((p) => p.PlayerId)).toEqual(ids)
const reputation = await exports.default.fetch(
`${ORIGIN}/api/playerReputation/v2/bulk?${query}`
)
expect(reputation.status).toBe(200)
const reps = (await reputation.json()) as Array<{ AccountId: number }>
expect(reps.map((r) => r.AccountId)).toEqual(ids)
})
test('progression reads back the XP game rewards banked, levelled up', async () => {
// The two workers share this table; `econ` writes it when a game reward is claimed (5 XP
// at a time). Granted in one lump here to exercise a multi-level climb: 25 XP from level
+65 -38
View File
@@ -12,13 +12,13 @@ import {
deleteEmptyRoomInstances,
deleteExpiredPresence,
deletePresence,
deleteRoomInvite,
GAME_VERSION,
getAccount,
getClubSummary,
getExpiredPresenceInstanceIds,
getFriendIds,
getJoinableInstance,
deleteRoomInvite,
getLatestRoomInviteBetween,
getMostActiveClubhouses,
getOrCreateDormRoom,
@@ -28,8 +28,8 @@ import {
getRoomByName,
getRoomInstance,
getRoomInstancesByRoom,
getRoomInvite,
getRoomInstanceSummariesByRoom,
getRoomInvite,
getStoredRoomInstance,
InviteMode,
isClubMember,
@@ -64,6 +64,7 @@ import {
AUTHED,
AvoidJuniorsRequest,
AvoidJuniorsResponse,
BatchPlayerIdsRequest,
ConnectionInfoResponse,
CorrelationIdRequest,
EMPTY_OK,
@@ -91,6 +92,7 @@ import {
} from './openapi'
import type { Context } from 'hono'
import type { DescribeRouteOptions } from 'hono-openapi'
import type { Room, StoredPresence } from '@repo/domain'
import type { App, Env } from './context'
@@ -991,6 +993,62 @@ async function readRequestFields(c: Context<App>): Promise<Record<string, unknow
return await c.req.parseBody({ all: true }).catch(() => empty)
}
/**
* OpenAPI spec for the two `/player` registrations, which differ only in where the ids ride.
*/
function batchPlayerRouteSpec(source: 'query' | 'body'): DescribeRouteOptions {
return {
tags: ['Presence'],
summary: 'Batch player presence lookup',
description: [
'Returns each requested player’s presence. `id` is repeated once per player',
'(`?id=2&id=155&id=153`, or the same fields in a form-urlencoded body) — one value',
'each, not comma-separated. With no ids, serves a single default (online) player.',
].join(' '),
...(source === 'query'
? {
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeated once per player id (`?id=2&id=155`); not comma-separated',
schema: { type: 'array', items: { type: 'string' } },
},
],
}
: { requestBody: form(BatchPlayerIdsRequest, 'Repeated `id` fields') }),
responses: { 200: json(PlayerDto.array(), 'One entry per requested player') },
}
}
/**
* Returns each requested player's presence. Ids come from repeated `id` query params AND,
* on the POST form, repeated `id` body fields; with none, serves the static
* getplayer.json default. Deduped (first occurrence wins) so a repeated id can't double
* an entry or waste a bound parameter.
*/
async function batchPlayers(c: Context<App>) {
// `readRequestFields` serves the v2 client's JSON too, where an id is a real number
// rather than a form field's string — coerce rather than filtering those away.
const body = c.req.method === 'POST' ? await readRequestFields(c) : {}
const raw = body['id'] ?? body['Id']
const ids = [
...new Set(
[...(c.req.queries('id') ?? []), ...(Array.isArray(raw) ? raw : [raw])]
.filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
.map((v) => Number.parseInt(String(v).trim(), 10))
.filter((n) => !Number.isNaN(n))
),
]
if (ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
// One query per 99-id chunk (D1 `WHERE account_id IN (…)`), rather than a point read
// per id as the KV store required.
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId))))
}
/**
* Look a field up case-insensitively — the client's casing isn't guaranteed across its
* surfaces — taking the first value when the form encoding repeated it.
@@ -1742,42 +1800,11 @@ const app = new Hono<App>()
async (c) => c.json(await getMostActiveClubhouses(c.env.DB))
)
.get(
'/player',
describeRoute({
tags: ['Presence'],
summary: 'Batch player presence lookup',
description: [
'Returns each requested player’s presence. `id` is a repeated query param',
'(`?id=2&id=155&id=153`) — one value each, not comma-separated. With no ids, serves',
'a single default (online) player.',
].join(' '),
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeated once per player id (`?id=2&id=155`); not comma-separated',
schema: { type: 'array', items: { type: 'string' } },
},
],
responses: { 200: json(PlayerDto.array(), 'One entry per requested player') },
}),
async (c) => {
// Returns each requested player's presence. Reads the repeated `id` query
// param(s); with none it serves the static getplayer.json default.
const ids = c.req
.queries('id')
?.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
// point read per id as the KV store required.
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId))))
}
)
.get('/player', describeRoute(batchPlayerRouteSpec('query')), batchPlayers)
// The 2023 client asks about its whole friends list at once and posts the ids in a
// form-urlencoded body rather than hanging a few hundred of them off the URL. Same
// lookup as the GET, which stays for callers that prefer a query string.
.post('/player', describeRoute(batchPlayerRouteSpec('body')), batchPlayers)
.post(
'/player/heartbeat',
+9
View File
@@ -160,6 +160,15 @@ export const MatchmakeResponse = z.object({
.describe('Echoes the request’s CorrelationId; all-zero GUID when it sent none'),
})
/**
* `POST /player` form body. `id` repeats once per player (the client asks about its whole
* friends list at once, too many ids to hang off a URL). One id per value — unlike
* `accounts`' bulk lookup, these are not comma-separated.
*/
export const BatchPlayerIdsRequest = z.object({
id: z.union([z.string(), z.string().array()]).describe('Repeated once per player id'),
})
/**
* `GET /player/avoidjuniors` — a BARE JSON boolean (`true`/`false`), not an envelope and
* not a `{ value }` wrapper. The whole body is the preference.
@@ -469,6 +469,41 @@ describe('public endpoints', () => {
expect(players.every((p) => p.isOnline === false)).toBe(true)
})
test('POST /player reads the ids from a form body', async () => {
// The 2023 client asks about its friends list as a POST — the ids are in a
// form-urlencoded body, not the query string. GET-only left it a 404.
const res = await exports.default.fetch(`${ORIGIN}/player`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'id=1070&id=1380&id=1070',
})
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
// One entry per id, deduped, in request order.
expect(players.map((p) => p.playerId)).toEqual([1070, 1380])
})
test('POST /player answers more ids than D1 will bind at once', async () => {
// D1 caps a statement at 100 bound parameters and the expiry check takes one of
// them, so a real friends list overruns an unchunked `IN (…)` and 500s.
const ids = Array.from({ length: 250 }, (_, i) => 4000 + i)
const res = await exports.default.fetch(`${ORIGIN}/player`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: ids.map((id) => `id=${id}`).join('&'),
})
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number }>
expect(players.map((p) => p.playerId)).toEqual(ids)
})
test('POST /player without an id returns the default payload', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player`, { method: 'POST' })
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
})
test('GET /player without an id returns the default payload', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player`)
expect(res.status).toBe(200)
@@ -3280,6 +3315,7 @@ describe('auth-gated endpoints', () => {
'POST /matchmake/v2/player/{playerId}',
'POST /matchmake/v2/room/{roomId}',
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
'POST /player',
'POST /player/exclusivelogin',
'POST /player/heartbeat',
'POST /player/login',