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
+185 -2
View File
@@ -10,6 +10,7 @@ import { getThreadMessages } from './message-db'
import {
AUTHED,
ChatMessageDto,
ChatPrivacySettings,
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
@@ -20,6 +21,8 @@ import {
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
PartyInviteSettings,
PartyThread,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
@@ -85,14 +88,34 @@ async function formMessageCount(c: Context<App>, fallback: number): Promise<numb
}
/**
* What a chat action reports back to the client alongside its payload — the reference's
* ChatResult. Only success and "bad arguments" are reachable here.
* What a chat action reports back to the client — the reference's ChatResult, usually
* alongside a payload but sometimes (the DM privacy check) as the whole body. Only these
* four of the enum's twenty values are reachable here; `ChatResult` in openapi.ts records
* the rest, including the 15/16 privacy refusals nothing on this server can answer.
*/
const CHAT_SUCCESS = 0
const CHAT_INVALID_ARGUMENTS = 1
const CHAT_MEMBERSHIP_NOT_FOUND = 3
const CHAT_PLAYER_ALREADY_ON_THREAD = 4
/**
* How long a party invite link stays usable, in minutes (`GET /settings/partyinvite`).
* The reference's value. Nothing here stores invite links, so this is what the client
* counts down with rather than a lifetime this server enforces.
*/
const PARTY_INVITE_LIFETIME_MINUTES = 60
/**
* Who may start a chat with a player — the client's `ChatPrivacy` enum, served numerically
* like every other enum on this build. `Friends` is what a fresh account reports, and what
* every account reports here: nothing stores a per-player setting yet.
*/
const ChatPrivacy = {
Friends: 0,
Favorites: 1,
NoOne: 2,
} as const
/** The hub is a single global Durable Object instance, as every worker addresses it. */
const HUB_INSTANCE = 'global'
@@ -426,6 +449,166 @@ const app = new Hono<App>()
}
)
// How long a party invite link lives. Server-side config the client reads to stamp its
// own invite links, not per-player state — one hour, which is the reference's value.
//
// A bare single-key object: `{ InviteLinkLifetimeInMinutes }` and nothing else, no
// `{ success, error, value }` envelope. Nothing here expires links (there is no invite
// link store), so this is the number the client shows and counts down with rather than
// a lifetime this server enforces.
.get(
'/settings/partyinvite',
describeRoute({
tags: ['Threads'],
summary: 'Party invite settings',
description: [
'How long a party invite link stays usable, in minutes, as a bare single-key object —',
'no envelope. 60 here, the references value. Nothing on this server stores or expires',
'invite links, so the client is the only thing that acts on it.',
].join(' '),
security: AUTHED,
responses: {
200: json(PartyInviteSettings, 'The invite-link lifetime'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({ InviteLinkLifetimeInMinutes: PARTY_INVITE_LIFETIME_MINUTES })
}
)
// The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is
// unknown — it hasn't been observed off a live client — so this answers an empty
// object, which parses as "no party" rather than failing the client's deserializer the
// way a 404 or a bare array would. `maxCount` and `mode` are accepted and ignored.
// Replace the body once the real shape is captured.
.get(
'/thread/party',
describeRoute({
tags: ['Threads'],
summary: 'The callers party thread (stub)',
description: [
'STUB — the response shape has not been observed off a live client, so this answers an',
'empty object `{}`, which parses as "no party" rather than failing the clients',
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
'ignored. Replace the body once the real shape is captured.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'maxCount',
in: 'query',
required: false,
description: 'Page size the client sends (1). Ignored by the stub',
schema: { type: 'integer' },
},
{
name: 'mode',
in: 'query',
required: false,
description: 'Unknown mode selector the client sends (0). Ignored by the stub',
schema: { type: 'integer' },
},
],
responses: {
200: json(PartyThread, 'Always `{}` — the stub carries no party'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({})
}
)
// The caller's chat privacy settings — who may DM them, and who may pull them into a
// group chat. Both report `Friends`, which is the reference's default and the safer of
// the two directions to be wrong in: it describes a player as more private than the
// server actually enforces, rather than less.
//
// REPORTED, NOT ENFORCED. Nothing here stores a per-player setting or checks one — the
// DM check below allows every message regardless — so this is what the client renders on
// its privacy screen. Wire the two together if this ever becomes real: a screen that says
// "Friends" while anyone can message you is worse than one that says nothing.
//
// `playerId` comes off the TOKEN, not a query param: the answer is about the caller.
.get(
'/thread/chatPrivacySetting',
describeRoute({
tags: ['Threads'],
summary: 'The callers chat privacy settings',
description: [
'Who may direct-message the caller and who may add them to a group chat, as the',
'`ChatPrivacy` enum by NUMBER (0 Friends · 1 Favorites · 2 NoOne). Both are `Friends`',
'here — nothing stores a per-player setting — and nothing enforces them either: the',
'DM check allows every message. `playerId` is the caller, read from the token.',
].join(' '),
security: AUTHED,
responses: {
200: json(ChatPrivacySettings, 'The callers settings — always Friends/Friends'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({
playerId: id,
directMessagePrivacySetting: ChatPrivacy.Friends,
groupChatPrivacySetting: ChatPrivacy.Friends,
})
}
)
// May the caller DM this player? Asked before the client opens a new direct message, so
// it can grey the button out rather than let the send fail. Always 0 (Success): nothing
// here stores the who-can-message-me privacy setting the name refers to, so there is no
// setting to refuse on.
//
// The body is a bare ChatResult INTEGER — the client instantiates its response wrapper
// with the ChatResult enum, not a bool, so `true` decodes as nothing. The refusals this
// endpoint would otherwise answer are 15 (blocked by the caller's own privacy setting)
// and 16 (blocked by the other player's); everything else in the enum belongs to the
// thread actions. It is served numerically: this client build carries no by-name enum
// formatter.
.get(
'/thread/checkCanSendDirectMessageWithPrivacySetting',
describeRoute({
tags: ['Threads'],
summary: 'May the caller DM this player?',
description: [
'Whether the caller may open a direct message with `receivingPlayerId`, as a bare',
'ChatResult integer — 0 (Success) means allowed; a real refusal would be 15 (the',
'callers own privacy setting) or 16 (the other players). Always 0 here: this server',
'stores no who-can-message-me privacy setting, so there is nothing to refuse on.',
'`receivingPlayerId` is accepted and ignored; the answer is the same for every player,',
'and the client asks again for the next one.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'receivingPlayerId',
in: 'query',
required: false,
description: 'The player the caller wants to message. Accepted and ignored.',
schema: { type: 'integer' },
},
],
responses: {
200: json(ChatResult, 'Always 0 (Success) — the DM is allowed'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json(CHAT_SUCCESS)
}
)
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
// create: the thread whose membership is exactly `ids` plus the caller, opened only
// if they don't already share one. Returning a fresh empty thread each call would
+57 -4
View File
@@ -110,13 +110,29 @@ export const ChatThreadWithMessagesDto = z.object({
})
/**
* The bare ChatResult integer several actions answer with (HTTP 200 either way): 0
* success, 1 invalid arguments, 3 membership not found (which doubles as "no such
* thread"), 4 player already on the thread.
* The bare ChatResult integer several actions answer with (HTTP 200 either way). The
* client's enum in full, recovered from the build — it is served NUMERICALLY, there being
* no by-name formatter on the client side:
*
* 0 Success · 1 InvalidArguments · 2 ThreadNotFound · 3 MembershipNotFound ·
* 4 PlayerAlreadyOnThread · 5 CannotMessagePlayer · 6 InvalidCharacters ·
* 7 RecentlyLeftThread · 8 ThreadTooLarge · 9 InsufficientPermission ·
* 10 TooManyAffiliationThreads · 11 UnderModeration · 12 MessageNotFound ·
* 13 InvalidThreadJoinType · 14 PlayerBanned ·
* 15 CannotMessagePlayerDueToLocalPrivacySetting ·
* 16 CannotMessagePlayerDueToRemotePrivacySetting ·
* 17 SuccessWithPartialPlayersAddedToThreadDueToPrivacySetting ·
* 18 CannotAddPlayersToThreadDueToPrivacySetting ·
* 19 CannotConvertDirectMessageChatToGroupChatDueToPrivacySetting
*
* Only 0, 1, 3 and 4 are reachable on this server; the rest are recorded so a route that
* needs one answers the number the client actually branches on.
*/
export const ChatResult = z
.int()
.describe('0 success · 1 invalid arguments · 3 membership not found · 4 already on thread')
.describe(
'ChatResult, numeric: 0 Success · 1 InvalidArguments · 2 ThreadNotFound · 3 MembershipNotFound · 4 PlayerAlreadyOnThread · 5 CannotMessagePlayer · 6 InvalidCharacters · 7 RecentlyLeftThread · 8 ThreadTooLarge · 9 InsufficientPermission · 10 TooManyAffiliationThreads · 11 UnderModeration · 12 MessageNotFound · 13 InvalidThreadJoinType · 14 PlayerBanned · 15 CannotMessagePlayerDueToLocalPrivacySetting · 16 CannotMessagePlayerDueToRemotePrivacySetting · 17 SuccessWithPartialPlayersAddedToThreadDueToPrivacySetting · 18 CannotAddPlayersToThreadDueToPrivacySetting · 19 CannotConvertDirectMessageChatToGroupChatDueToPrivacySetting'
)
/**
* `POST /thread` — the reference's wrapper: the created (or resolved) thread plus the
@@ -138,6 +154,43 @@ export const SendMessageResponse = z.object({
chatThread: ChatThreadWithMessagesDto.nullable(),
})
/**
* `GET /settings/partyinvite` — how long a party invite link stays usable, in minutes. A
* bare single-key object, not an envelope: the whole body is this one setting.
*/
export const PartyInviteSettings = z.object({
InviteLinkLifetimeInMinutes: z
.int()
.describe('Minutes a party invite link stays valid before it lapses'),
})
/**
* `GET /thread/chatPrivacySetting` — who may start a chat with the caller. camelCase, unlike
* the PascalCase thread DTOs, and the two settings are the `ChatPrivacy` enum served
* NUMERICALLY (0 Friends · 1 Favorites · 2 NoOne): this client build carries no by-name enum
* formatter, so a string would decode as nothing.
*
* Reported, not enforced. Nothing on this server stores a per-player privacy setting or
* checks one — `GET /thread/checkCanSendDirectMessageWithPrivacySetting` allows every DM —
* so these are the values the client renders its privacy screen from.
*/
export const ChatPrivacySettings = z.object({
playerId: z.int().describe('The caller — read from the token, not from the query'),
directMessagePrivacySetting: z
.int()
.describe('Who may DM the caller: 0 Friends · 1 Favorites · 2 NoOne'),
groupChatPrivacySetting: z
.int()
.describe('Who may add the caller to a group chat: 0 Friends · 1 Favorites · 2 NoOne'),
})
/**
* `GET /thread/party` — STUB. The real shape hasn't been observed off a live client, so
* the route answers an empty object and this schema says so rather than guessing at
* fields. Fill both in together once the real response is captured.
*/
export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown')
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
service: z.literal('chat'),
+100 -9
View File
@@ -376,6 +376,95 @@ describe('thread storage', () => {
})
})
// The party thread is a stub until its real shape is observed off a live client; these
// pin down only what the stub promises — auth, and an object body rather than a 404.
describe('GET /settings/partyinvite', () => {
it('answers the invite-link lifetime as a bare single-key object', async () => {
const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`, {
headers: await bearer(885001),
})
expect(res.status).toBe(200)
// One key, no `{ success, error, value }` envelope around it.
expect(await res.json()).toEqual({ InviteLinkLifetimeInMinutes: 60 })
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`)
expect(res.status).toBe(401)
})
})
describe('GET /thread/party', () => {
it('answers an empty object', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
headers: await bearer(883001),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
expect(res.status).toBe(401)
})
})
describe('GET /thread/chatPrivacySetting', () => {
it('reports Friends for both settings, keyed to the caller', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
headers: await bearer(886001),
})
expect(res.status).toBe(200)
// camelCase, and the enum by NUMBER (0 = Friends) — this build has no by-name enum
// formatter, so a string would decode as nothing.
expect(await res.json()).toEqual({
playerId: 886001,
directMessagePrivacySetting: 0,
groupChatPrivacySetting: 0,
})
})
it('reads playerId off the token, not a query param', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting?playerId=999999`, {
headers: await bearer(886002),
})
expect(((await res.json()) as { playerId: number }).playerId).toBe(886002)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`)
expect(res.status).toBe(401)
})
})
describe('GET /thread/checkCanSendDirectMessageWithPrivacySetting', () => {
const path = `${ORIGIN}/thread/checkCanSendDirectMessageWithPrivacySetting`
it('always allows the DM, as a bare ChatResult integer', async () => {
const res = await SELF.fetch(`${path}?receivingPlayerId=205`, {
headers: await bearer(884001),
})
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
// The whole body is the ChatResult — 0 is Success. NOT a boolean: the client
// instantiates its response wrapper with the enum, so `true` would decode as nothing.
expect(await res.text()).toBe('0')
})
it('answers the same for any player, and with no player named', async () => {
// `receivingPlayerId` is ignored — nothing here stores a privacy setting to refuse on.
for (const query of ['?receivingPlayerId=205', '?receivingPlayerId=999999', '']) {
const res = await SELF.fetch(`${path}${query}`, { headers: await bearer(884002) })
expect(await res.text()).toBe('0')
}
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${path}?receivingPlayerId=205`)
expect(res.status).toBe(401)
})
})
describe('POST /thread/withmembers', () => {
async function withMembers(caller: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
@@ -760,9 +849,9 @@ describe('ChatMessageReceived push', () => {
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
@@ -984,9 +1073,7 @@ describe('POST /thread/:id', () => {
it('pushes ChatMessageReceived to every member', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
getByName(name: string): {
takeSent(): Promise<
Array<{ playerId: number; notificationType: NotificationType }>
>
takeSent(): Promise<Array<{ playerId: number; notificationType: NotificationType }>>
}
}
const caller = 889005
@@ -996,9 +1083,9 @@ describe('POST /thread/:id', () => {
await send(caller, `/thread/${chatThreadId}`)
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
})
it('reports invalid arguments for blank contents without storing anything', async () => {
@@ -1301,7 +1388,11 @@ describe('openapi', () => {
expect([...documented].sort()).toEqual([
'DELETE /thread/{id}/leave',
'GET /',
'GET /settings/partyinvite',
'GET /thread',
'GET /thread/chatPrivacySetting',
'GET /thread/checkCanSendDirectMessageWithPrivacySetting',
'GET /thread/party',
'GET /thread/{id}',
'GET /thread/{id}/message',
'POST /thread',