Files
recflare/apps/api/src/routes/config.ts
T
2026-08-31 23:43:19 -04:00

206 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
import { validateAndGetVersion } from '@repo/jwt'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
import {
AmplitudeConfig,
ApiConfigV2,
AzureSpeechConfig,
BacktraceConfig,
IslandedVersions,
json,
JsonObject,
StatsigUserProperties,
VersionCheck,
} from '../openapi'
import type { App } from '../context'
// ---- Config / version ------------------------------------------------------
export const configRoutes = new Hono<App>({ strict: false })
.get(
'/api/config/v1/amplitude',
describeRoute({
tags: ['Config'],
summary: 'Analytics keys',
description:
'The Amplitude / RudderStack / StatSig keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are blank and RudderStack and ' +
'StatSig are off — but the client needs the object to finish loading.',
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
}),
(c) =>
c.json({
AmplitudeKey: '',
UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
})
)
.get(
'/api/config/v1/azurespeech',
describeRoute({
tags: ['Config'],
summary: 'Speech-to-text config',
description:
'Azure Speech credentials for the clients voice transcription. `Enabled` is false ' +
'here, so the key and region are never used.',
responses: { 200: json(AzureSpeechConfig, 'Speech config, disabled') },
}),
(c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
)
.get(
'/api/config/v1/backtrace',
describeRoute({
tags: ['Config'],
summary: 'Crash reporter config',
description:
'Budget, sampling and log-capture settings for the clients Backtrace crash ' +
'reporter. Nothing on this server receives the reports.',
responses: { 200: json(BacktraceConfig, 'Crash reporter settings') },
}),
(c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex: '^.*$',
VersionRegex: '.*',
})
)
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
// config is static.
.get(
'/api/config/v2',
describeRoute({
tags: ['Config'],
summary: 'The main client config blob',
description:
'The large feature-switch / endpoint config the client reads at startup. Served ' +
'from a static asset, except `ShareBaseUrl`, which is templated from the ' +
'deploy-time base domain so share links point at this deployment.',
responses: { 200: json(ApiConfigV2, 'The client config') },
}),
(c) => c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
)
.get(
'/api/versioncheck/v4',
describeRoute({
tags: ['Config'],
summary: 'Client version check',
description:
'Whether the client build is current. Compares the clients `?v=` build against ' +
'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: 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([])
)
// Two catalogs, one per client generation: the 2023 build and the 2025 build read
// different keys out of this, and the 2025 one carries entries (`Screens.*`, the
// creative-door queries) the older catalog never had.
//
// Which one a caller gets is decided by the token's `rn.ver` claim — the build the
// client posted at login — since the request itself carries no version. Anything NEWER
// than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order
// as strings, the same comparison `match` makes for cross-build joins.
//
// A request with no readable token version gets the 2023 catalog, the same body this
// route has always served: unauthenticated is not evidence of a newer client, and this
// stack targets `GAME_VERSION`. Like the version gate on `featuredrooms`, the claim is
// unverified — a client that lies about its build only misconfigures itself.
.get(
'/api/gameconfigs/v1/all',
describeRoute({
tags: ['Config'],
summary: 'Per-game configuration',
description:
'An opaque static catalog of per-game settings, served verbatim. There are two: a ' +
'build NEWER than `20230414` gets the 2025 catalog, which carries keys the older one ' +
'never had; that build and anything older get the 2023 catalog. Builds are ' +
'date-stamped, so they compare as strings. The build is read from the tokens ' +
'`rn.ver` claim — the request carries no version of its own — so auth is optional ' +
'here and only selects the catalog; a request without a readable token version gets ' +
'the 2023 one.',
responses: { 200: json(JsonObject, 'The game config catalog for the callers build') },
}),
async (c) => {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const newerThanTarget = version !== null && version > GAME_VERSION
return c.json(newerThanTarget ? gameConfigsV1All2025 : 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(
'/voice/config',
describeRoute({
tags: ['Config'],
summary: 'Voice chat config',
description:
'Fetched by the client while setting up voice. We have no reference shape for it, ' +
'so it stays an empty object until the client is observed needing a field.',
responses: { 200: json(JsonObject, 'An empty object') },
}),
(c) => c.json({})
)