mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[2026] relax versioncheck to major versions
This commit is contained in:
@@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
AUTHED,
|
||||
@@ -163,7 +163,7 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// 20260323.14 has native low-energy and no-energy companion support. Advertising
|
||||
// The 20260323 build (20260323.14) has native low-energy and no-energy companion support. Advertising
|
||||
// full energy selects a conversation backend we do not provide. Grant access with
|
||||
// zero energy and the client's recognized no-credit result instead. A successful
|
||||
// access response still opens a realtime session even when its energy is zero.
|
||||
@@ -174,7 +174,7 @@ const app = new Hono<App>()
|
||||
tags: ['Roomie AI', '2025'],
|
||||
summary: 'The caller’s Roomie AI energy budget',
|
||||
description: [
|
||||
'20260323.14 receives the recognized no-credit result and zero energy for its native',
|
||||
'The 20260323 build receives the recognized no-credit result and zero energy for its native',
|
||||
'companion mode. Its DTO has no recharge field. Older clients retain the full',
|
||||
'int32-max budget and null recharge time. No model session is created.',
|
||||
'',
|
||||
@@ -191,8 +191,9 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (version === '20260323.14') {
|
||||
// The build, not the version: every point release of 20260323 has the companion mode.
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (build === '20260323') {
|
||||
return c.json({
|
||||
success: false,
|
||||
error_id: 'AI.InsufficientRoomieAICreditNoSub',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
|
||||
import { validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
|
||||
@@ -140,8 +140,9 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
// 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.
|
||||
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718`), so they order as
|
||||
// strings, the same comparison `match` makes for cross-build joins — compared as BUILDS,
|
||||
// so a same-day point release of the target (`20230414.01`) stays on the 2023 catalog.
|
||||
//
|
||||
// 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
|
||||
@@ -163,8 +164,8 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
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
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const newerThanTarget = build !== null && build > GAME_VERSION
|
||||
return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
import { validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
@@ -306,15 +306,16 @@ async function pushEventInvitation(
|
||||
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
|
||||
* `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
|
||||
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
|
||||
* order as strings. `TagModifyResult.Tags` is a name list to every build — only the
|
||||
* inline field moves.
|
||||
* order as strings — compared as BUILDS, with the client's point release dropped, so a
|
||||
* same-day rebuild of the target stays on its side. `TagModifyResult.Tags` is a name list
|
||||
* to every build — only the inline field moves.
|
||||
*
|
||||
* Like the other version gates here the claim is unverified — a client that lies about its
|
||||
* build only empties its own tag chips.
|
||||
*/
|
||||
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const isModernBuild = version !== null && version > GAME_VERSION
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const isModernBuild = build !== null && build > GAME_VERSION
|
||||
return toEventResult(event, tags, isModernBuild)
|
||||
}
|
||||
|
||||
|
||||
@@ -301,6 +301,18 @@ describe('public endpoints', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 ignores the point release: every .NN of a supported build is current', async () => {
|
||||
// The list holds bare dates; the client reports `YYYYMMDD.NN`, and the `.NN` is a
|
||||
// same-day rebuild of the same build. All of these are the beta build.
|
||||
for (const version of ['20250718.01', '20250718.2', '20250718', '20250718.99']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
|
||||
expect(await res.json(), version).toMatchObject({ VersionStatus: 0 })
|
||||
}
|
||||
// ...and a point release of a build that ISN'T supported is still not.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101.01`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
@@ -331,6 +343,9 @@ describe('public endpoints', () => {
|
||||
|
||||
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to the target build', async () => {
|
||||
expect(await gameConfigKeys(GAME_VERSION)).not.toContain(KEY_2025_ONLY)
|
||||
// A same-day point release of the target is the target build: compared as a build,
|
||||
// not as a string that happens to sort after `20230414`.
|
||||
expect(await gameConfigKeys(`${GAME_VERSION}.01`)).not.toContain(KEY_2025_ONLY)
|
||||
})
|
||||
|
||||
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to an older build', async () => {
|
||||
|
||||
@@ -978,7 +978,7 @@ const app = new Hono<App>()
|
||||
'refused may belong to a housemate of the banned player rather than to them.',
|
||||
'',
|
||||
'**Rollout gate.** A grant whose `ver` names a build under staged rollout',
|
||||
'(`ROLLOUT_GAME_VERSIONS`, currently `20260323.14`) is refused as `invalid_grant` "this build is limited to',
|
||||
'(`ROLLOUT_GAME_VERSIONS`, currently `20260323` — every point release of it) is refused as `invalid_grant` "this build is limited to',
|
||||
'Discord supporters" unless the account’s Discord link (made by the website’s',
|
||||
'benefits claim) holds one of the roles in the operator’s `DISCORD_ROLLOUT_ROLE_IDS`.',
|
||||
'With none configured the gate is closed to everyone. `create_account` on such a',
|
||||
|
||||
@@ -1352,6 +1352,18 @@ describe('auth worker routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('every point release of the gated build is gated', async () => {
|
||||
// The gate is on the BUILD (the bare date): the client's `.NN` is a same-day
|
||||
// rebuild, so `.14`, `.2` and the date alone are all the rollout build.
|
||||
await withRoleIds(undefined, async () => {
|
||||
for (const version of ['20260323', '20260323.2', '20260323.14']) {
|
||||
const res = await login('gate_outsider', version)
|
||||
expect(res.status, version).toBe(400)
|
||||
expect(res.json.error_description, version).toBe(REFUSED)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('the credential is checked first, so the gate cannot probe for accounts', async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
const res = await postToken(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { mergePlayerSettings as mergeStoredPlayerSettings, readPlayerSettings } from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
import { censorSwears } from '../../api/src/sanitize'
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
@@ -41,9 +41,9 @@ import {
|
||||
ChatThreadType,
|
||||
createThread,
|
||||
getOrCreateThreadWithMembers,
|
||||
getPartyThreadForPlayer,
|
||||
getThreadForPlayer,
|
||||
getThreadMemberIds,
|
||||
getPartyThreadForPlayer,
|
||||
getThreadMeta,
|
||||
getThreadsForPlayer,
|
||||
isThreadMember,
|
||||
@@ -903,8 +903,8 @@ const app = new Hono<App>()
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
// Path 1: already in a party. One query, no settings read.
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const listResponse = version !== null && version >= '20260323'
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const listResponse = build !== null && build >= '20260323'
|
||||
const noParty = () => (listResponse ? c.json([]) : c.json({}))
|
||||
let thread = await getPartyThreadForPlayer(c.env.DB, id)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetBuild, validateAndGetPlus } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
getCustomAvatarItems,
|
||||
@@ -260,16 +260,17 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
/**
|
||||
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
|
||||
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
|
||||
* version to order by. `null` when there is no valid token, when it carries no `rn.ver` (an
|
||||
* older token, issued before the claim did), or when the claim isn't a build at all.
|
||||
* version to order by (`validateAndGetBuild` drops it). `null` when there is no valid
|
||||
* token, when it carries no `rn.ver` (an older token, issued before the claim did), or
|
||||
* when the claim isn't a build at all.
|
||||
*
|
||||
* Unverified — a client can claim any build — which is fine for what it gates here: a build
|
||||
* lying about itself only changes which storefront its own player is shown.
|
||||
*/
|
||||
async function authedBuild(c: Context<App>): Promise<number | null> {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const version = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (version === null) return null
|
||||
const build = Number.parseInt(version.split('.')[0] ?? '', 10)
|
||||
const build = Number.parseInt(version, 10)
|
||||
return Number.isInteger(build) ? build : null
|
||||
}
|
||||
|
||||
|
||||
+25
-17
@@ -14,6 +14,7 @@ import {
|
||||
deletePresence,
|
||||
deleteRoomInvite,
|
||||
GAME_VERSION,
|
||||
gameBuild,
|
||||
getAccount,
|
||||
getClubSummary,
|
||||
getExpiredPresenceAccountIds,
|
||||
@@ -387,16 +388,18 @@ async function callerVersion(c: Context<App>): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The build to matchmake the caller as — {@link callerVersion} with the server's own
|
||||
* `GAME_VERSION` standing in when the token doesn't say (the same fallback presence
|
||||
* uses, so a player's instance and their reported build agree).
|
||||
* The BUILD to matchmake the caller as — {@link callerVersion} reduced to its date
|
||||
* ({@link gameBuild}: `20250718.01` → `20250718`), with the server's own `GAME_VERSION`
|
||||
* standing in when the token doesn't say (the same fallback presence uses, so a player's
|
||||
* instance and their reported build agree). A point release is the same build: a
|
||||
* `20250718.2` client lands in the `20250718` session, beside the `.01` who opened it.
|
||||
*
|
||||
* Unlike presence, this can never be left unset: it is the key an instance is created
|
||||
* under and searched by, and an empty one would pool every unknown-build player into a
|
||||
* shared "" bucket — exactly the mixed session the stamp exists to prevent.
|
||||
*/
|
||||
async function callerGameVersion(c: Context<App>): Promise<string> {
|
||||
return (await callerVersion(c)) ?? GAME_VERSION
|
||||
return gameBuild(await callerVersion(c)) ?? GAME_VERSION
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -711,16 +714,20 @@ const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom
|
||||
* `UpdateRequired`, the one code that says "your client can't go there" and the honest
|
||||
* answer. There is no code for the other direction ("they must update"), so a caller on
|
||||
* the newer build gets the opaque NoSuchRoom every other unjoinable thing answers.
|
||||
* Builds are date-stamped (`20230414`, `20250718.01`), so they order as strings; an
|
||||
* instance carrying no version at all (written before the stamp existed) is nobody's
|
||||
* Both sides are compared as BUILDS ({@link gameBuild}) — either may arrive as a full
|
||||
* client version (a presence row's `appVersion` is verbatim) and a point release is the
|
||||
* same build. Builds are date-stamped (`20230414`, `20250718`), so they order as strings;
|
||||
* an instance carrying no version at all (written before the stamp existed) is nobody's
|
||||
* build and refuses both ways.
|
||||
*/
|
||||
function crossBuildRefusal(
|
||||
callerVersion: string,
|
||||
instanceVersion: string
|
||||
): MatchmakingErrorCode | null {
|
||||
if (callerVersion === instanceVersion) return null
|
||||
return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
|
||||
const caller = gameBuild(callerVersion) ?? ''
|
||||
const instance = gameBuild(instanceVersion) ?? ''
|
||||
if (caller === instance) return null
|
||||
return instance > caller ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -854,12 +861,12 @@ function nextLiveMessageId(): number {
|
||||
const GAME_INVITE_V2_MIN_BUILD = 20230414
|
||||
|
||||
/**
|
||||
* The date portion of a client build (`20250718.01` → `20250718`) as a number, or null when
|
||||
* there's nothing parseable to compare. Builds are `<yyyymmdd>[.<n>]`, so the leading date
|
||||
* orders them; the point release after it never decides this gate.
|
||||
* A client build ({@link gameBuild}: `20250718.01` → `20250718`) as a number, or null when
|
||||
* there's nothing parseable to compare. The leading date orders builds; the point release
|
||||
* after it never decides a gate.
|
||||
*/
|
||||
function buildNumber(version: string | null): number | null {
|
||||
const date = Number.parseInt(version?.split('.')[0] ?? '', 10)
|
||||
const date = Number.parseInt(gameBuild(version) ?? '', 10)
|
||||
return Number.isNaN(date) ? null : date
|
||||
}
|
||||
|
||||
@@ -1563,10 +1570,11 @@ async function resolveRoomInstance(
|
||||
const currentInstanceId = reusesInstance
|
||||
? (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
|
||||
: undefined
|
||||
// The same build, with GAME_VERSION standing in for a token that names none. It scopes
|
||||
// the search below and is stamped on the instance when one is created, which is what
|
||||
// keeps a session to a single client version.
|
||||
const gameVersion = tokenVersion ?? GAME_VERSION
|
||||
// The same BUILD (the date, with the client's point release dropped), with GAME_VERSION
|
||||
// standing in for a token that names none. It scopes the search below and is stamped on
|
||||
// the instance when one is created, which is what keeps a session to a single client
|
||||
// build — and lets every point release of that build share it.
|
||||
const gameVersion = gameBuild(tokenVersion) ?? GAME_VERSION
|
||||
// Reuse an existing joinable instance *of the same subroom and the same build* —
|
||||
// subrooms are separate places, so joining one must never land you in another, and
|
||||
// neither must a session running a different version of the room. A published room
|
||||
@@ -3114,7 +3122,7 @@ const app = new Hono<App>()
|
||||
'…). Both are empty when the pool is unset or the caller is in no instance. A',
|
||||
'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the',
|
||||
'same way, with ids `dev-1`, `dev-2`, …; a caller whose token’s `rn.ver` is a build',
|
||||
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323.14`) out of',
|
||||
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323`) out of',
|
||||
'`TACHYON_HOST_PORT_ROLLOUT`, with ids `rollout-1`, `rollout-2`, …. Sandbox wins when',
|
||||
'both apply, and either special pool left unset falls back to the live one.',
|
||||
'`experiments` carries the client’s networking flags.',
|
||||
|
||||
@@ -1351,6 +1351,38 @@ describe('auth-gated endpoints', () => {
|
||||
expect(priv2.roomInstance.roomInstanceId).not.toBe(priv.roomInstance.roomInstanceId)
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId pools every point release of a build into one instance', async () => {
|
||||
const matchmake = async (sub: string, version: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub, version),
|
||||
})
|
||||
).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } }
|
||||
|
||||
// The `.NN` after the date is a same-day rebuild of the same build, so a `.01` client,
|
||||
// a `.2` client and one reporting the bare date all belong in one session.
|
||||
const first = await matchmake('9170', '20250718.01')
|
||||
const point = await matchmake('9171', '20250718.2')
|
||||
const bare = await matchmake('9172', '20250718')
|
||||
expect(point.roomInstance.roomInstanceId).toBe(first.roomInstance.roomInstanceId)
|
||||
expect(bare.roomInstance.roomInstanceId).toBe(first.roomInstance.roomInstanceId)
|
||||
|
||||
// The instance is stamped with the BUILD, not the version that opened it — the key
|
||||
// the lookups match on. (The same shape migrations/0028 backfills older rows into.)
|
||||
const row = await env.DB.prepare('SELECT game_version FROM room_instance WHERE id = ?1')
|
||||
.bind(first.roomInstance.roomInstanceId)
|
||||
.first<{ game_version: string }>()
|
||||
expect(row?.game_version).toBe('20250718')
|
||||
|
||||
// The point release doesn't leak into presence: that still reports the version the
|
||||
// client posted, which is what describes the player to their friends.
|
||||
const presence = await env.DB.prepare(
|
||||
'SELECT data FROM presence WHERE account_id = 9171'
|
||||
).first<{ data: string }>()
|
||||
expect(JSON.parse(presence?.data ?? '{}').appVersion).toBe('20250718.2')
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId only pools players on the same client build', async () => {
|
||||
const matchmake = async (sub: string, version: string) =>
|
||||
(await (
|
||||
@@ -2651,9 +2683,11 @@ describe('auth-gated endpoints', () => {
|
||||
const older = await spawn('915', '20250424.01')
|
||||
expect(await join(older, '20250718.01')).toEqual(refused(20))
|
||||
|
||||
// Same build → in they go.
|
||||
// Same build → in they go — and a point release IS the same build: the instance holds
|
||||
// the bare date, and the caller's `.2` reduces to it.
|
||||
const same = await spawn('916', '20250718.01')
|
||||
expect(await join(same, '20250718.01')).toMatchObject({ errorCode: 0 })
|
||||
expect(await join(same, '20250718.2')).toMatchObject({ errorCode: 0 })
|
||||
})
|
||||
|
||||
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- A room instance's `gameVersion` is now a BUILD — the bare `YYYYMMDD` — rather than the
|
||||
-- client's full version. The `.NN` after the date is a same-day point release of the
|
||||
-- same build: it changes nothing a session keys on, so `20250718.01` and `20250718.2`
|
||||
-- belong in one Photon room, and `SUPPORTED_GAME_VERSIONS` lists dates alone.
|
||||
--
|
||||
-- Matchmaking now stamps the build (createRoomInstance runs the value through
|
||||
-- `gameBuild`) and every lookup normalizes its argument the same way, so a row still
|
||||
-- carrying `20250718.01` would match NOBODY from here on: a 20250718 player's search is
|
||||
-- `game_version = '20250718'`. Strip the point release off every live row so the sessions
|
||||
-- open at the moment of the deploy stay joinable. Rows with no `.` (already bare, or the
|
||||
-- NULL of a row older than the stamp) are left exactly as they are.
|
||||
--
|
||||
-- `game_version` is a virtual column over the `data` blob (0012), so the blob is what's
|
||||
-- rewritten; the column follows.
|
||||
|
||||
UPDATE room_instance
|
||||
SET data = json_set(data, '$.gameVersion', substr(game_version, 1, instr(game_version, '.') - 1))
|
||||
WHERE game_version LIKE '%.%';
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
withNotFound,
|
||||
withOnError,
|
||||
} from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetBuild, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
@@ -470,16 +470,17 @@ async function authedAccountId(c: Context<App>): Promise<number | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The client build this request's token was minted for (`rn.ver`), or null when there's no
|
||||
* valid token. The claim is the build the CLIENT posted at login, not this server's
|
||||
* GAME_VERSION, so it identifies what the player is actually running.
|
||||
* The client BUILD this request's token was minted for (`rn.ver` as its bare `YYYYMMDD`,
|
||||
* so a `20250718.01` client reads `20250718`), or null when there's no valid token. The
|
||||
* claim is the build the CLIENT posted at login, not this server's GAME_VERSION, so it
|
||||
* identifies what the player is actually running.
|
||||
*
|
||||
* It is unverified — a client can claim any build — which is fine for the one thing it is
|
||||
* used for here: keeping a payload away from a build it breaks. Lying about your version to
|
||||
* opt IN to a broken room list only breaks your own client.
|
||||
*/
|
||||
async function authedGameVersion(c: Context<App>): Promise<string | null> {
|
||||
return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
return validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,9 +494,11 @@ async function authedGameVersion(c: Context<App>): Promise<string | null> {
|
||||
*
|
||||
* An allow-list rather than a "this build or newer" comparison, deliberately: the thing
|
||||
* being asserted is that a build was CHECKED, and a version string that sorts high (a
|
||||
* debug build, say) must not opt itself in. Add a build here once it's been tried.
|
||||
* debug build, say) must not opt itself in. Add a build here once it's been tried — as
|
||||
* the bare date, which is what {@link authedGameVersion} yields; every point release of
|
||||
* it (`20250718.01`, `20250718.2`) is the same build.
|
||||
*/
|
||||
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718.01'])
|
||||
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718'])
|
||||
|
||||
/**
|
||||
* Operator-granted elevated roles — the ones the auth worker stamps from an account's
|
||||
@@ -1522,7 +1525,7 @@ const app = new Hono<App>()
|
||||
'',
|
||||
'Restricted by CLIENT BUILD. Serving this to the 2023 client breaks its other room',
|
||||
'listings (NREs, apparently from the featured-room load corrupting its room cache), so',
|
||||
'only the builds known to render it — `20250718.01` today — get the group; anything',
|
||||
'only the builds known to render it — `20250718` today — get the group; anything',
|
||||
'else gets a 404, the same answer it got while the route was parked. The build is read',
|
||||
'from the token’s `rn.ver` claim, which is why this needs a token at all: nothing in',
|
||||
'the group itself is per-player.',
|
||||
|
||||
@@ -1274,6 +1274,15 @@ describe('rooms endpoints', () => {
|
||||
expect(body.Rooms.every((r) => typeof r.RoomName === 'string')).toBe(true)
|
||||
// The dorm (RoomId 1) is non-public, so it's never featured.
|
||||
expect(body.Rooms.some((r) => r.RoomId === 1)).toBe(false)
|
||||
|
||||
// The allow-list holds the BUILD (the bare date), so every point release of it is
|
||||
// served: a `.2` rebuild of the 2025 client renders the group the same as `.01`.
|
||||
for (const version of ['20250718.2', '20250718']) {
|
||||
const point = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
|
||||
headers: await bearer('1', undefined, version),
|
||||
})
|
||||
expect(point.status, version).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /featuredrooms/current serves at most 10 rooms', async () => {
|
||||
@@ -6336,6 +6345,53 @@ describe('studio room bundles', () => {
|
||||
UnitySubAssets: [],
|
||||
})
|
||||
})
|
||||
|
||||
// A FIRST-PARTY room's bundle — captured from the official game and loaded by hand
|
||||
// under its `unity/<roomId>/` layout — shares `unity_asset` with Studio builds but is
|
||||
// not UGC, and must not be folded onto the save: the 2025 client handed it inline
|
||||
// takes it instead of asking the bulk lookup, cannot name it, and loads nothing (every
|
||||
// imported room broke when the splice moved from the Studio-only table to `unity_asset`).
|
||||
// The bulk lookup is where that client resolves it, and still serves it.
|
||||
it('leaves a first-party bundle to the bulk lookup rather than the room read', async () => {
|
||||
await reset()
|
||||
const firstParty = 'unity/9901/8iaf2yemao2d1zurblo875cn2.assetbundle'
|
||||
await putUnityAsset(env.DB, {
|
||||
UnityAssetId: ASSET,
|
||||
Target: 0,
|
||||
Version: 3,
|
||||
Filename: firstParty,
|
||||
Hash: winHash,
|
||||
})
|
||||
|
||||
type Loaded = {
|
||||
SubRooms: Array<{ CurrentSave: { UnitySubAssets: unknown[]; SubRoomDataSaveId: number } }>
|
||||
}
|
||||
for (const query of ['', '?unityAssetTarget=0']) {
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/9901${query}`)).json()) as Loaded
|
||||
expect(room.SubRooms[0]!.CurrentSave.UnitySubAssets, query).toEqual([])
|
||||
}
|
||||
|
||||
const saveId = ((await (await SELF.fetch(`${ORIGIN}/rooms/9901`)).json()) as Loaded)
|
||||
.SubRooms[0]!.CurrentSave.SubRoomDataSaveId
|
||||
const detail = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/${saveId}`, {
|
||||
headers: await bearer('1'),
|
||||
})
|
||||
expect(await detail.json()).toMatchObject({
|
||||
UnityAsset: null,
|
||||
UnityAssetHash: null,
|
||||
UnitySubAssets: [],
|
||||
})
|
||||
|
||||
const bulk = await SELF.fetch(`${ORIGIN}/unity_assets/baked/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ target: '0', version: '3', id: ASSET }).toString(),
|
||||
})
|
||||
expect(bulk.status).toBe(200)
|
||||
expect(await bulk.json()).toEqual([
|
||||
{ UnityAssetId: ASSET, Target: 0, Version: 3, Filename: firstParty, Hash: winHash },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// Room and subroom names take letters, digits and underscores, at most 32 (see
|
||||
|
||||
Reference in New Issue
Block a user