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
+19
View File
@@ -0,0 +1,19 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
/**
* Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value with
* `await env.JWT_SECRET.get()`; every worker binds the same store, so tokens signed by
* `auth` verify here.
*/
JWT_SECRET: SecretsStoreSecret
}
/** Variables can be extended */
export type Variables = SharedHonoVariables
export interface App extends HonoApp {
Bindings: Env
Variables: Variables
}
@@ -0,0 +1,108 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import type { Context } from 'hono'
import type { App } from './context'
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is missing,
* the token is invalid, or the `sub` claim isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* The notification categories `GET /config/categories` serves — a STUB standing in for the
* real list until something here actually defines categories and stores preferences against
* them.
*
* `CategoryId` is the client's own id for the category, `Importance` its ranking (0 being
* the lowest observed), and `IsMuteable` whether the player may switch it off at all. The
* stub marker lives in `Description` because that is what the client displays; keep it
* there — a category that silently does nothing is worse than one that says it does nothing.
*/
const NOTIFICATION_CATEGORIES = [
{
CategoryId: 2,
Importance: 0,
Name: 'Friends',
Description: 'Friend requests and friend activity [STUB — recflare sends no notifications yet]',
IsMuteable: true,
},
]
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
.get('/', async (c) => {
return c.text('hello, world!')
})
// Whether a player receives gameplay invites — the switch the client checks before
// offering to invite someone. Always true: nothing here stores per-player notification
// preferences, and true is the answer that leaves inviting working. A bare JSON boolean,
// not an envelope.
//
// `{id}` is the account being asked about and is accepted but not read: the answer is the
// same for everyone, so there is nothing to look up. The token is still validated first,
// as the reference does, which means a caller can ask about any id but must be someone.
.get('/accounts/:id{[0-9]+}/receives/GameplayInvites', async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
return c.json(true)
})
// The notification categories a player can be shown toggles for — the "what may we notify
// you about" list. A bare array of PascalCase categories, no envelope. No auth: the list
// is server-side config, the same for every player, and a caller's own preferences are
// the per-account routes above.
//
// STUB. Nothing here defines categories or stores a preference against one, so this is
// one hand-written entry standing in for the real list — the toggle it draws does
// nothing. The `Description` says so IN THE TEXT rather than only in this comment: it is
// the field the client renders, so the stub is visible in-game instead of looking like
// a real (and broken) setting. `Name` is left clean in case the client keys off it.
.get('/config/categories', async (c) => {
return c.json(NOTIFICATION_CATEGORIES)
})
// The caller's own notification preferences — which categories they have muted, by
// CategoryId (the ids `/config/categories` above hands out).
//
// STUB: nothing here stores a preference, so nobody has muted anything and the list is
// empty. Empty is also the right stub value rather than a made-up id: a muted category
// the player never muted would show as an off switch they can't explain, and the ids
// would have to agree with the category list to mean anything at all.
//
// Auth-gated — this one IS per-player, unlike the category config above. A `{ }` object
// rather than a bare array: the shape has room for the other preferences the reference
// carries here.
.get('/preferences', async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
return c.json({ MutedCategories: [] })
})
export default app
@@ -0,0 +1,114 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
it('response with hello world', async () => {
const res = await SELF.fetch(ORIGIN)
expect(res.status).toBe(200)
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
})
it('reports that a player receives gameplay invites', async () => {
const res = await SELF.fetch(`${ORIGIN}/accounts/205/receives/GameplayInvites`, {
headers: await bearer('205'),
})
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
// The whole body is the boolean — not `{ value: true }`, not an envelope.
expect(await res.text()).toBe('true')
})
it('answers the same for any account id, since nothing is stored per player', async () => {
// A caller may ask about someone else; the answer doesn't depend on the id.
const other = await SELF.fetch(`${ORIGIN}/accounts/999999/receives/GameplayInvites`, {
headers: await bearer('205'),
})
expect(await other.text()).toBe('true')
// The id is digits-only, like the reference's `ulong id`.
expect((await SELF.fetch(`${ORIGIN}/accounts/abc/receives/GameplayInvites`)).status).toBe(404)
})
it('401s the gameplay-invites check without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/accounts/205/receives/GameplayInvites`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
it('serves the stub notification categories', async () => {
// A bare array of PascalCase categories, and no auth — the list is server-side config
// rather than anything per-player.
const res = await SELF.fetch(`${ORIGIN}/config/categories`)
expect(res.status).toBe(200)
const categories = (await res.json()) as Array<{
CategoryId: number
Importance: number
Name: string
Description: string
IsMuteable: boolean
}>
expect(categories).toHaveLength(1)
expect(categories[0]).toMatchObject({
CategoryId: 2,
Importance: 0,
Name: 'Friends',
IsMuteable: true,
})
// The stub marker is in the DISPLAYED text, so a category that does nothing says so
// in-game rather than looking like a real setting. Keep it there while this is a stub.
expect(categories[0].Description).toContain('STUB')
})
it('serves the callers notification preferences', async () => {
const res = await SELF.fetch(`${ORIGIN}/preferences`, { headers: await bearer('205') })
expect(res.status).toBe(200)
// Nothing stores preferences, so nobody has muted anything. An object, not a bare array —
// the shape has room for the other preferences the reference carries here.
expect(await res.json()).toEqual({ MutedCategories: [] })
})
it('401s the preferences read without a bearer token', async () => {
// Per-player, unlike /config/categories, so this one needs a token.
const res = await SELF.fetch(`${ORIGIN}/preferences`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})