mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
206 lines
7.7 KiB
TypeScript
206 lines
7.7 KiB
TypeScript
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 client’s 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 client’s 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 client’s `?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 token’s ' +
|
||
'`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 caller’s 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({})
|
||
)
|