[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
* array of tag NAMES. (The stored tags are `{ tag, type }` pairs, which is what the v1
* read's lowercase `tags` serves.) Defined on top of the base rather than beside it, so the
* feed and the envelope cannot drift apart on the fields they share.
* A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
* `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
* tag, and the client parses each in exactly one place.
*/
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 {
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
* names — pass what `getEventTags` returns, so the answer reflects what was actually
* Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored
* tags — pass what `getEventTags` returns, so the answer reflects what was actually
* 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 carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
return {
PlayerEvent: { Tags: names, ...toEventBase(event) },
PlayerEvent: { Tags: carried, ...toEventBase(event) },
Result: 0,
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`
* (tag names, not the `{ tag, type }` pairs the v1 read's lowercase `tags` serves) and
* `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no image.
* and `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no
* 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({
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 { describeRoute } from 'hono-openapi'
import { Accessibility } from '@repo/domain'
import { Accessibility, GAME_VERSION } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } 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.
@@ -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}`):
* 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)
const updated = await updateEvent(c.env.DB, eventId, input)
// 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)
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)
// inviteToEvent only returns null when the row vanished, which the read above rules out.
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 ?? [])
// 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.
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 event = await getEventById(c.env.DB, eventId)
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)
const updated = await updateEvent(c.env.DB, eventId, input)
// 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
expect(body.Result).toBe(0)
expect(body.PlayerEvent.Name).toBe('Enveloped')
// The tags ride inline on the event AND in TagModifyResult, as NAMES — not the
// `{ tag, type }` pairs the v1 read's lowercase `tags` serves.
expect(body.PlayerEvent.Tags).toEqual(['music'])
// The tags ride inline on the event AND in TagModifyResult. Inline they take the
// caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs
// (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'] })
// No `State`, and the broadcast instance is present and null.
expect(body.PlayerEvent).not.toHaveProperty('State')
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 () => {
const written = await post('/api/playerevents/v2', {
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.
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.
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })