[events] fix Rec Rooms failure to actually adhere to API versioning

This commit is contained in:
Devin Zuczek
2026-08-25 13:13:46 -04:00
parent 1d8399f64a
commit 599730379c
4 changed files with 131 additions and 23 deletions
+33 -9
View File
@@ -174,13 +174,28 @@ interface EventRow {
} }
/** /**
* The event as the `v2` envelope carries it: {@link PlayerEventBase} plus `Tags`, a plain * A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
* array of tag NAMES. (The stored tags are `{ tag, type }` pairs, which is what the v1 * `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
* read's lowercase `tags` serves.) Defined on top of the base rather than beside it, so the * tag, and the client parses each in exactly one place.
* feed and the envelope cannot drift apart on the fields they share. */
export interface PlayerEventEnvelopeTag {
Tag: string
Type: number
}
/**
* The event as the `v2` envelope carries it: {@link PlayerEventBase} plus `Tags`. Defined
* on top of the base rather than beside it, so the feed and the envelope cannot drift
* apart on the fields they share.
*
* `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
* changed it under the same unversioned path rather than minting a `v3`: the 2023 build
* parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either
* one to the other build leaves the event's tag chips empty — the decoder drops what it
* can't read rather than erroring. {@link toEventResult} picks; nothing else should.
*/ */
export interface PlayerEventEnvelope extends PlayerEventBase { export interface PlayerEventEnvelope extends PlayerEventBase {
Tags: string[] Tags: string[] | PlayerEventEnvelopeTag[]
} }
/** /**
@@ -199,14 +214,23 @@ export interface PlayerEventResult {
} }
/** /**
* Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored tag * Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored
* names — pass what `getEventTags` returns, so the answer reflects what was actually * tags — pass what `getEventTags` returns, so the answer reflects what was actually
* written rather than what was asked for. * written rather than what was asked for.
*
* `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
* {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It
* changes nothing else — `TagModifyResult.Tags` is a name list to both builds.
*/ */
export function toEventResult(event: PlayerEvent, tags: EventTag[] = []): PlayerEventResult { export function toEventResult(
event: PlayerEvent,
tags: EventTag[] = [],
legacyTags = false
): PlayerEventResult {
const names = tags.map((t) => t.tag) const names = tags.map((t) => t.tag)
const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
return { return {
PlayerEvent: { Tags: names, ...toEventBase(event) }, PlayerEvent: { Tags: carried, ...toEventBase(event) },
Result: 0, Result: 0,
TagModifyResult: { Result: 0, Tags: names }, TagModifyResult: { Result: 0, Tags: names },
} }
+13 -3
View File
@@ -704,11 +704,21 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName:
/** /**
* The event as the v2 envelope carries it: the stored record MINUS `State`, PLUS `Tags` * The event as the v2 envelope carries it: the stored record MINUS `State`, PLUS `Tags`
* (tag names, not the `{ tag, type }` pairs the v1 read's lowercase `tags` serves) and * and `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no
* `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no image. * image.
*
* `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
* minting a new path, so a build newer than `20230414` gets the tag NAMES and every older
* one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither
* is the lowercase `{ tag, type }` the v1 read serves.
*/ */
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({ export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
Tags: z.array(z.string()).describe('The events tag names'), Tags: z
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
.describe(
'The events tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' +
'that build and older'
),
}) })
/** /**
+28 -7
View File
@@ -1,8 +1,9 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' import { describeRoute } from 'hono-openapi'
import { Accessibility } from '@repo/domain' import { Accessibility, GAME_VERSION } from '@repo/domain'
import { logger } from '@repo/hono-helpers' import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported // The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies. // as a value — the enum has no runtime dependencies.
@@ -145,6 +146,26 @@ async function notifyInvited(
} }
} }
/**
* Wrap an event in the v2 envelope for THIS caller's build.
*
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build
* comes off the token's `rn.ver` claim — the request carries no version of its own — and
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they
* order as strings.
*
* 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
return toEventResult(event, tags, !isModernBuild)
}
/** /**
* The shared front half of the single-field event edits (`PUT …/v2/{id}/{field}`): * The shared front half of the single-field event edits (`PUT …/v2/{id}/{field}`):
* authenticate, load the event, check the caller created it, then apply whatever patch * authenticate, load the event, check the caller created it, then apply whatever patch
@@ -177,7 +198,7 @@ function editEventField(
if (input === null) return c.body(null, 400) if (input === null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input) const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out. // updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(toEventResult(updated!, await getEventTags(c.env.DB, eventId))) return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
} }
} }
@@ -499,7 +520,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
const updated = await setEventResponse(c.env.DB, eventId, id, type) const updated = await setEventResponse(c.env.DB, eventId, id, type)
if (updated === null) return c.body(null, 404) if (updated === null) return c.body(null, 404)
return c.json(toEventResult(updated, await getEventTags(c.env.DB, eventId))) return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId)))
} }
) )
@@ -630,7 +651,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
const result = await inviteToEvent(c.env.DB, eventId, invited) const result = await inviteToEvent(c.env.DB, eventId, invited)
// inviteToEvent only returns null when the row vanished, which the read above rules out. // inviteToEvent only returns null when the row vanished, which the read above rules out.
await notifyInvited(c, result!.event, result!.added) await notifyInvited(c, result!.event, result!.added)
return c.json(toEventResult(result!.event, await getEventTags(c.env.DB, eventId))) return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
} }
) )
@@ -683,7 +704,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
await notifyEventCreated(c, event, input.tags ?? []) await notifyEventCreated(c, event, input.tags ?? [])
// Read the tags back rather than echoing what was posted: the envelope reports what // Read the tags back rather than echoing what was posted: the envelope reports what
// the event now carries, which is what the client redraws its chips from. // the event now carries, which is what the client redraws its chips from.
return c.json(toEventResult(event, await getEventTags(c.env.DB, event.PlayerEventId))) return c.json(await eventResult(c, event, await getEventTags(c.env.DB, event.PlayerEventId)))
} }
) )
@@ -766,7 +787,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
const eventId = Number.parseInt(c.req.param('eventId'), 10) const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId) const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.body(null, 404) if (event === null) return c.body(null, 404)
return c.json(toEventResult(event, await getEventTags(c.env.DB, eventId))) return c.json(await eventResult(c, event, await getEventTags(c.env.DB, eventId)))
} }
) )
@@ -818,7 +839,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (eventInputRejection(input, existing) !== null) return c.body(null, 400) if (eventInputRejection(input, existing) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input) const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out. // updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(toEventResult(updated!, await getEventTags(c.env.DB, eventId))) return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
} }
) )
+57 -4
View File
@@ -3871,15 +3871,65 @@ describe('player events', () => {
const body = (await res.json()) as PlayerEventResult const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0) expect(body.Result).toBe(0)
expect(body.PlayerEvent.Name).toBe('Enveloped') expect(body.PlayerEvent.Name).toBe('Enveloped')
// The tags ride inline on the event AND in TagModifyResult, as NAMES — not the // The tags ride inline on the event AND in TagModifyResult. Inline they take the
// `{ tag, type }` pairs the v1 read's lowercase `tags` serves. // caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs
expect(body.PlayerEvent.Tags).toEqual(['music']) // (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult
// is names to every build.
expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }])
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] }) expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
// No `State`, and the broadcast instance is present and null. // No `State`, and the broadcast instance is present and null.
expect(body.PlayerEvent).not.toHaveProperty('State') expect(body.PlayerEvent).not.toHaveProperty('State')
expect(body.PlayerEvent.BroadcastingRoomInstanceId).toBeNull() expect(body.PlayerEvent.BroadcastingRoomInstanceId).toBeNull()
}) })
test('the v2 envelope shapes PlayerEvent.Tags per the callers build', async () => {
// Rec Room reshaped this field without minting a new path, so one endpoint owes two
// shapes: the 2023 build parses `{ Tag, Type }` pairs, the 2025 build bare names.
// Serving either to the wrong build empties the event's chips instead of erroring.
// A tag of this test's own: the `#tag` search tests assert exact result sets, and the
// four events below would join any set they share a tag with.
const PAIRS = [{ Tag: 'buildversions', Type: 0 }]
const NAMES = ['buildversions']
const created = async (version?: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, {
method: 'POST',
headers: {
...(await bearer('42', undefined, version)),
'content-type': 'application/json',
},
body: JSON.stringify({ Name: 'Versioned', RoomId: 3, Tags: NAMES }),
})
expect(res.status).toBe(200)
return (await res.json()) as PlayerEventResult
}
// Newer than 20230414 is the 2025 client; that build, an older one, and a token naming
// no build at all are all the 2023 client. Builds are date-stamped, so they compare as
// strings.
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES)
expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS)
expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS)
const legacy = await created()
expect(legacy.PlayerEvent.Tags).toEqual(PAIRS)
// Only the inline field moves: TagModifyResult carries names to both builds.
expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
// The gate is on the ENVELOPE, not on the create: the read and the field edits answer
// the same shape, so a client that made an event and one opening it cold agree.
const eventId = legacy.PlayerEvent.PlayerEventId
const read = async (version?: string) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/${eventId}`, {
headers: await bearer('42', undefined, version),
})
).json()) as PlayerEventResult
).PlayerEvent.Tags
expect(await read('20250718.01')).toEqual(NAMES)
expect(await read()).toEqual(PAIRS)
})
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => { test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
const written = await post('/api/playerevents/v2', { const written = await post('/api/playerevents/v2', {
Name: 'ReadBack', Name: 'ReadBack',
@@ -4758,7 +4808,10 @@ describe('player events', () => {
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes. // A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
const tagged = await edited(await putJson(path, ['tag1', '#Class'])) const tagged = await edited(await putJson(path, ['tag1', '#Class']))
expect(tagged.Tags).toEqual(['class', 'tag1']) expect(tagged.Tags).toEqual([
{ Tag: 'class', Type: 0 },
{ Tag: 'tag1', Type: 0 },
])
// The envelope's TagModifyResult reports the same set the client redraws chips from. // The envelope's TagModifyResult reports the same set the client redraws chips from.
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] }) expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })