This commit is contained in:
Devin Zuczek
2026-08-14 00:57:56 -04:00
committed by devin
parent 7430116339
commit dda18b0d2c
8 changed files with 198 additions and 20 deletions
+38
View File
@@ -1752,6 +1752,44 @@ const app = new Hono<App>({ strict: false })
c.json([]) c.json([])
) )
// The room-economy surface the client asks for on entering a room: the room's own
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
// stored yet, so every one is an empty list — the client reads that as "this room
// sells nothing" and renders no shop, where a 404 stalls the room load instead.
//
// The `/player` and `purchaseCounts` variants are caller-scoped but deliberately
// unauthed, matching the `roomConsumable/.../me` stub above: an empty list is the
// same answer for every caller, so there's nothing to protect until something
// writes here. Gate them when they start returning real data.
.get(
'/econ/roomInventory/room/:roomId',
listRoute('A rooms inventory', 'Empty stub so the client doesnt 404'),
(c) => c.json([])
)
.get(
'/econ/roomInventory/room/:roomId/player',
listRoute('The callers inventory in a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomInventoryItemTags/room/:roomId',
listRoute('A rooms inventory item tags', 'Empty stub'),
(c) => c.json([])
)
.get('/econ/roomOffer/room/:roomId', listRoute('A rooms offers', 'Empty stub'), (c) =>
c.json([])
)
.get(
'/econ/roomOffer/room/:roomId/purchaseCounts',
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomGiftDropShops/room/:roomId',
listRoute('A rooms gift-drop shops', 'Empty stub'),
(c) => c.json([])
)
// Unlocked consumables. [Authorize]. The consumables the player has bought (from // Unlocked consumables. [Authorize]. The consumables the player has bought (from
// `buyItem`, stored in the `consumable` table), grouped by item into the client's // `buyItem`, stored in the `consumable` table), grouped by item into the client's
// unlocked-consumable DTO. A player who has bought none gets an empty list. // unlocked-consumable DTO. A player who has bought none gets an empty list.
@@ -635,6 +635,24 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([]) expect(await res.json()).toEqual([])
}) })
// The room-economy stubs. One table-driven test: they're the same empty-list answer,
// and what's worth pinning is that every path the client asks for on room entry is
// registered — an unregistered one 404s and stalls the room load.
test('the room-economy endpoints all return []', async () => {
for (const path of [
'/econ/roomInventory/room/92',
'/econ/roomInventory/room/92/player',
'/econ/roomInventoryItemTags/room/92',
'/econ/roomOffer/room/92',
'/econ/roomOffer/room/92/purchaseCounts',
'/econ/roomGiftDropShops/room/92',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
expect(res.status, path).toBe(200)
expect(await res.json(), path).toEqual([])
}
})
test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => { test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`) const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
expect(anon.status).toBe(401) expect(anon.status).toBe(401)
@@ -2110,6 +2128,12 @@ describe('econ endpoints', () => {
'GET /api/storefronts/v3/giftdropstore/{id}', 'GET /api/storefronts/v3/giftdropstore/{id}',
'GET /api/storefronts/v4/balance/{currencyType}', 'GET /api/storefronts/v4/balance/{currencyType}',
'GET /econ/customAvatarItems/v1/owned', 'GET /econ/customAvatarItems/v1/owned',
'GET /econ/roomGiftDropShops/room/{roomId}',
'GET /econ/roomInventory/room/{roomId}',
'GET /econ/roomInventory/room/{roomId}/player',
'GET /econ/roomInventoryItemTags/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}/purchaseCounts',
'POST /api/CampusCard/v1/UpdateAndGetSubscription', 'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/avatar/v2/gifts/consume', 'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set', 'POST /api/avatar/v2/set',
+12 -7
View File
@@ -106,14 +106,15 @@ const NULL_CONNECTION_INFO = {
/** /**
* The Photon applications the client connects to (`GET /player/connection-info`). * The Photon applications the client connects to (`GET /player/connection-info`).
* Temporary placeholders — move them to wrangler vars before they need to differ per * Hardcoded temporarily — move them to wrangler vars before they need to differ per
* environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every * environment (they are per-deployment ids, not secrets: the client receives all three
* in the clear). `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS). * instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
*/ */
const PHOTON_APPS = { const PHOTON_APPS = {
photonRealtimeAppId: '', photonRealtimeAppId: 'rf-8f322bdb',
photonVoiceAppId: '', photonVoiceAppId: 'rf-6b4682e1',
photonChatAppId: '', photonChatAppId: 'rf-55fae86e',
photonRegion: 'us', photonRegion: 'us',
} as const } as const
@@ -1768,8 +1769,12 @@ const app = new Hono<App>()
photonAuthToken, photonAuthToken,
...PHOTON_APPS, ...PHOTON_APPS,
photonRoomId, photonRoomId,
voiceConnectionInfo: null, // Empty strings rather than null: there's no separate voice server either
voiceServerId: null, // way, and the client's decoder is likelier to accept a missing-value string
// than a null on a string field. The presence payload's
// NULL_CONNECTION_INFO keeps its nulls — that one never carries credentials.
voiceConnectionInfo: '',
voiceServerId: '',
experiments: PHOTON_EXPERIMENTS, experiments: PHOTON_EXPERIMENTS,
}, },
error: null, error: null,
+2 -2
View File
@@ -206,8 +206,8 @@ export const ConnectionInfo = z.object({
photonChatAppId: z.string().describe('Photon Chat application id'), photonChatAppId: z.string().describe('Photon Chat application id'),
photonRegion: z.string().describe('Region id, matching a room instances `photonRegion`'), photonRegion: z.string().describe('Region id, matching a room instances `photonRegion`'),
photonRoomId: z.string().describe('The callers current instance; empty when theyre in none'), photonRoomId: z.string().describe('The callers current instance; empty when theyre in none'),
voiceConnectionInfo: z.null().describe('Null — no separate voice server'), voiceConnectionInfo: z.literal('').describe('Empty — no separate voice server'),
voiceServerId: z.null().describe('Null — no separate voice server'), voiceServerId: z.literal('').describe('Empty — no separate voice server'),
experiments: ConnectionExperiments, experiments: ConnectionExperiments,
}) })
+11 -8
View File
@@ -935,16 +935,18 @@ describe('auth-gated endpoints', () => {
value: { value: {
// A signed JWT, not an opaque id — three base64url segments. // A signed JWT, not an opaque id — three base64url segments.
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
photonRealtimeAppId: '', photonRealtimeAppId: 'rf-8f322bdb',
photonVoiceAppId: '', photonVoiceAppId: 'rf-6b4682e1',
photonChatAppId: '', photonChatAppId: 'rf-55fae86e',
// Matches the region every room instance is stamped with. // Matches the region every room instance is stamped with.
photonRegion: 'us', photonRegion: 'us',
// The room the client is told to join has to be the one matchmaking placed // The room the client is told to join has to be the one matchmaking placed
// them in, or they end up alone in a room of their own. // them in, or they end up alone in a room of their own.
photonRoomId: matchmaked.roomInstance.photonRoomId, photonRoomId: matchmaked.roomInstance.photonRoomId,
voiceConnectionInfo: null, // Empty strings, not nulls — unlike the presence payload's connection fields,
voiceServerId: null, // which stay null (they never carry credentials).
voiceConnectionInfo: '',
voiceServerId: '',
experiments: { experiments: {
networkTransformSyncInterval: 10, networkTransformSyncInterval: 10,
shouldUseUnreliableOnChange: false, shouldUseUnreliableOnChange: false,
@@ -976,9 +978,10 @@ describe('auth-gated endpoints', () => {
'rn.env': string 'rn.env': string
} }
expect(claims.sub).toBe('961') expect(claims.sub).toBe('961')
// Scoped to the realtime app the same response hands out — a placeholder empty // Scoped to the realtime app the same response hands out. Asserted as agreement
// string until PHOTON_APPS moves to wrangler vars, so assert the two agree rather // rather than a pinned literal: PHOTON_APPS is hardcoded until it moves to wrangler
// than pinning the placeholder itself. // vars, and a token minted for a different app than the client is handed is the bug
// worth catching here.
expect(claims.aud).toBe(body.value.photonRealtimeAppId) expect(claims.aud).toBe(body.value.photonRealtimeAppId)
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)) expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
// The client is built against prod regardless of which environment we run in. // The client is built against prod regardless of which environment we run in.
+8
View File
@@ -675,3 +675,11 @@ export const PhotonAccessTokenDto = z.object({
export const PlayerDataDto = z.object({ export const PlayerDataDto = z.object({
Data: z.string().describe('Always empty — no per-room player data is stored'), Data: z.string().describe('Always empty — no per-room player data is stored'),
}) })
/**
* `GET /rooms/{roomId}/experience/player` — the caller's per-room experience/progression
* entries. Stubbed empty; the element shape is unknown until something stores one.
*/
export const RoomExperiencePlayer = z
.array(z.unknown())
.describe('Always empty — no per-room experience is tracked')
+45
View File
@@ -19,6 +19,7 @@ import {
getFeaturedRooms, getFeaturedRooms,
getHotRooms, getHotRooms,
getInteraction, getInteraction,
getOrCreateDormRoom,
getPresence, getPresence,
getPublicRoomsByCreator, getPublicRoomsByCreator,
getRecommendedRooms, getRecommendedRooms,
@@ -96,6 +97,7 @@ import {
RoomBanEnvelope, RoomBanEnvelope,
RoomDto, RoomDto,
RoomEnvelope, RoomEnvelope,
RoomExperiencePlayer,
roomIdParam, roomIdParam,
RoomLookup, RoomLookup,
RoomResultEnvelope, RoomResultEnvelope,
@@ -845,6 +847,31 @@ const app = new Hono<App>()
ownedRooms ownedRooms
) )
// The caller's own dorm, in the same shape `GET /rooms/{roomId}` serves — the client
// renders it with the same code path. Gets-or-creates, exactly as entering a dorm
// does (`match`), so a player who has never been to their dorm gets one here rather
// than a 404; the id is stable from then on.
.get(
'/dormroom/me',
describeRoute({
tags: ['My rooms'],
summary: 'The callers dorm',
description: [
'The callers personal dorm room, as `GET /rooms/{roomId}` would serve it —',
'`SubRooms` re-attached, same DTO. The dorm is provisioned on first access (cloned',
'from the seeded template dorm), so this returns a room for any authed caller and',
'never 404s; calling it repeatedly returns the same dorm.',
].join(' '),
security: AUTHED,
responses: { 200: json(RoomDto, 'The callers dorm'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json(await getOrCreateDormRoom(c.env.DB, accountId))
}
)
// Public: the rooms a given account owns that are publicly viewable. No auth — // Public: the rooms a given account owns that are publicly viewable. No auth —
// returns a bare array (empty when the account owns no public rooms). // returns a bare array (empty when the account owns no public rooms).
.get( .get(
@@ -2611,6 +2638,24 @@ const app = new Hono<App>()
(c) => c.json({ Data: '' }) (c) => c.json({ Data: '' })
) )
// The caller's per-room experience/progression. Stub → empty list.
.get(
'/rooms/:roomId{[0-9]+}/experience/player',
describeRoute({
tags: ['Rooms'],
summary: 'The callers per-room experience',
description: [
'Per-room experience/progression for the calling player. Nothing tracks any yet, so',
'this is an empty list — which the client reads as “no progress in this room”, where',
'a 404 would stall the room load. No auth, matching `playerdata/me`: the answer is',
'the same for every caller until something writes here.',
].join(' '),
parameters: [roomIdParam],
responses: { 200: json(RoomExperiencePlayer, 'An empty list') },
}),
(c) => c.json([])
)
// Single room by id. 404 when the room isn't in D1. Ignores the // Single room by id. 404 when the room isn't in D1. Ignores the
// include/unityAsset* query params. // include/unityAsset* query params.
.get( .get(
+58 -3
View File
@@ -89,6 +89,18 @@ beforeAll(async () => {
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill). // Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>) for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Accounts table (owned by the auth worker) — provisioning a dorm reads the username
// to name the room. Seed the player `dormroom/me` provisions a fresh dorm for.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS account (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 999, username: 'Dormer' }))
.run()
// Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to // Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to
// check the caller is a friend of the player whose history they're asking for. // check the caller is a friend of the player whose history they're asking for.
await env.DB.prepare( await env.DB.prepare(
@@ -129,6 +141,14 @@ describe('rooms endpoints', () => {
expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163') expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
}) })
// Stub. Registered (not 404) matters more than the body: the client asks for this on
// room entry, and an unregistered path stalls the load rather than erroring visibly.
it('GET /rooms/:id/experience/player returns [] for any room', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/92/experience/player`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('GET /rooms/:id 404s for a room not in D1', async () => { it('GET /rooms/:id 404s for a room not in D1', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/99999`) const res = await SELF.fetch(`${ORIGIN}/rooms/99999`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
@@ -181,6 +201,41 @@ describe('rooms endpoints', () => {
expect(other).toEqual([]) expect(other).toEqual([])
}) })
it('GET /dormroom/me serves the callers own dorm in the room shape', async () => {
// No token → 401. Without this the endpoint would hand out (and provision) a dorm
// for whichever account a fallback picked.
const noAuth = await SELF.fetch(`${ORIGIN}/dormroom/me`)
expect(noAuth.status).toBe(401)
// Account 1 owns the seeded dorm (RoomId 1), served exactly as GET /rooms/1 does —
// same DTO, SubRooms re-attached.
const res = await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('1') })
expect(res.status).toBe(200)
const dorm = (await res.json()) as {
RoomId: number
IsDorm: boolean
CreatorAccountId: number
SubRooms: Array<{ UnitySceneId: string }>
}
expect(dorm).toMatchObject({ RoomId: 1, IsDorm: true, CreatorAccountId: 1 })
expect(dorm.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
expect(dorm).toEqual(await (await SELF.fetch(`${ORIGIN}/rooms/1`)).json())
// A player who has never entered their dorm gets one provisioned rather than a
// 404, and it belongs to THEM — not the template dorm they were cloned from.
const fresh = (await (
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
).json()) as { RoomId: number; IsDorm: boolean; CreatorAccountId: number }
expect(fresh).toMatchObject({ IsDorm: true, CreatorAccountId: 999 })
expect(fresh.RoomId).not.toBe(1)
// Idempotent: the second call is the same dorm, not a second one.
const again = (await (
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
).json()) as { RoomId: number }
expect(again.RoomId).toBe(fresh.RoomId)
})
// The website's "My rooms" list is a browser calling this worker from another origin, // The website's "My rooms" list is a browser calling this worker from another origin,
// so a response without CORS headers is one the browser throws away — and the page // so a response without CORS headers is one the browser throws away — and the page
// can't tell that apart from the server being down. Pinned on the preflight too: the // can't tell that apart from the server being down. Pinned on the preflight too: the
@@ -580,9 +635,7 @@ describe('rooms endpoints', () => {
it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => { it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => {
type Feed = { Results: Array<{ Name: string }>; TotalResults: number } type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
const feed = async (): Promise<Feed> => const feed = async (): Promise<Feed> =>
(await ( (await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)).json()) as Feed
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)
).json()) as Feed
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name) const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
// No room carries a `community` tag, and every seeded room belongs to Coach // No room carries a `community` tag, and every seeded room belongs to Coach
@@ -2911,6 +2964,7 @@ describe('rooms endpoints', () => {
'DELETE /rooms/{roomId}/subrooms/{subRoomId}', 'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
'GET /', 'GET /',
'GET /XXXfeaturedrooms/current', 'GET /XXXfeaturedrooms/current',
'GET /dormroom/me',
'GET /photon_access_token', 'GET /photon_access_token',
'GET /rooms', 'GET /rooms',
'GET /rooms/base', 'GET /rooms/base',
@@ -2926,6 +2980,7 @@ describe('rooms endpoints', () => {
'GET /rooms/visitedby/{playerId}', 'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}', 'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans', 'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/experience/player',
'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/similar',