From 599730379cf141bfd6a41f2272f9a6737f10f417 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 13:13:46 -0400 Subject: [PATCH] [events] fix Rec Rooms failure to actually adhere to API versioning --- apps/api/src/events-db.ts | 42 ++++++++++++---- apps/api/src/openapi.ts | 16 ++++-- apps/api/src/routes/events.ts | 35 ++++++++++--- apps/api/src/test/integration/api.test.ts | 61 +++++++++++++++++++++-- 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index 0eee8ba..a09887d 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -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 }, } diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 9573a00..c40aaec 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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 event’s tag names'), + Tags: z + .union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))]) + .describe( + 'The event’s tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' + + 'that build and older' + ), }) /** diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 38e6594..914ad2c 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -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, 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({ 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({ 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({ 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({ 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({ 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))) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 9593a54..2108cbc 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 caller’s 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'] })