mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[events] fix Rec Rooms failure to actually adhere to API versioning
This commit is contained in:
@@ -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
@@ -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'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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'] })
|
||||
|
||||
Reference in New Issue
Block a user