From dda18b0d2c875aa34e16e21ef0b2370029cf9064 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 14 Aug 2026 00:57:56 -0400 Subject: [PATCH] stubs --- apps/econ/src/econ.app.ts | 38 +++++++++++++ apps/econ/src/test/integration/api.test.ts | 24 ++++++++ apps/match/src/match.app.ts | 19 ++++--- apps/match/src/openapi.ts | 4 +- apps/match/src/test/integration/api.test.ts | 19 ++++--- apps/rooms/src/openapi.ts | 8 +++ apps/rooms/src/rooms.app.ts | 45 +++++++++++++++ apps/rooms/src/test/integration/api.test.ts | 61 ++++++++++++++++++++- 8 files changed, 198 insertions(+), 20 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index afdbf72..61c30f6 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1752,6 +1752,44 @@ const app = new Hono({ strict: false }) 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 room’s inventory', 'Empty stub so the client doesn’t 404'), + (c) => c.json([]) + ) + .get( + '/econ/roomInventory/room/:roomId/player', + listRoute('The caller’s inventory in a room', 'Empty stub'), + (c) => c.json([]) + ) + .get( + '/econ/roomInventoryItemTags/room/:roomId', + listRoute('A room’s inventory item tags', 'Empty stub'), + (c) => c.json([]) + ) + .get('/econ/roomOffer/room/:roomId', listRoute('A room’s 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 room’s gift-drop shops', 'Empty stub'), + (c) => c.json([]) + ) + // Unlocked consumables. [Authorize]. The consumables the player has bought (from // `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. diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index d69749f..239c251 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -635,6 +635,24 @@ describe('econ endpoints', () => { 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 () => { const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`) expect(anon.status).toBe(401) @@ -2110,6 +2128,12 @@ describe('econ endpoints', () => { 'GET /api/storefronts/v3/giftdropstore/{id}', 'GET /api/storefronts/v4/balance/{currencyType}', '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/avatar/v2/gifts/consume', 'POST /api/avatar/v2/set', diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index a941b9e..d755acb 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -106,14 +106,15 @@ const NULL_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 - * environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every + * Hardcoded temporarily — move them to wrangler vars before they need to differ per + * 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). */ const PHOTON_APPS = { - photonRealtimeAppId: '', - photonVoiceAppId: '', - photonChatAppId: '', + photonRealtimeAppId: 'rf-8f322bdb', + photonVoiceAppId: 'rf-6b4682e1', + photonChatAppId: 'rf-55fae86e', photonRegion: 'us', } as const @@ -1768,8 +1769,12 @@ const app = new Hono() photonAuthToken, ...PHOTON_APPS, photonRoomId, - voiceConnectionInfo: null, - voiceServerId: null, + // Empty strings rather than null: there's no separate voice server either + // 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, }, error: null, diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index f592621..6d2fc10 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -206,8 +206,8 @@ export const ConnectionInfo = z.object({ photonChatAppId: z.string().describe('Photon Chat application id'), photonRegion: z.string().describe('Region id, matching a room instance’s `photonRegion`'), photonRoomId: z.string().describe('The caller’s current instance; empty when they’re in none'), - voiceConnectionInfo: z.null().describe('Null — no separate voice server'), - voiceServerId: z.null().describe('Null — no separate voice server'), + voiceConnectionInfo: z.literal('').describe('Empty — no separate voice server'), + voiceServerId: z.literal('').describe('Empty — no separate voice server'), experiments: ConnectionExperiments, }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 8991829..00f65b6 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -935,16 +935,18 @@ describe('auth-gated endpoints', () => { value: { // A signed JWT, not an opaque id — three base64url segments. photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), - photonRealtimeAppId: '', - photonVoiceAppId: '', - photonChatAppId: '', + photonRealtimeAppId: 'rf-8f322bdb', + photonVoiceAppId: 'rf-6b4682e1', + photonChatAppId: 'rf-55fae86e', // Matches the region every room instance is stamped with. photonRegion: 'us', // 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. photonRoomId: matchmaked.roomInstance.photonRoomId, - voiceConnectionInfo: null, - voiceServerId: null, + // Empty strings, not nulls — unlike the presence payload's connection fields, + // which stay null (they never carry credentials). + voiceConnectionInfo: '', + voiceServerId: '', experiments: { networkTransformSyncInterval: 10, shouldUseUnreliableOnChange: false, @@ -976,9 +978,10 @@ describe('auth-gated endpoints', () => { 'rn.env': string } expect(claims.sub).toBe('961') - // Scoped to the realtime app the same response hands out — a placeholder empty - // string until PHOTON_APPS moves to wrangler vars, so assert the two agree rather - // than pinning the placeholder itself. + // Scoped to the realtime app the same response hands out. Asserted as agreement + // rather than a pinned literal: PHOTON_APPS is hardcoded until it moves to wrangler + // 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.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)) // The client is built against prod regardless of which environment we run in. diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 5b06834..34fb817 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -675,3 +675,11 @@ export const PhotonAccessTokenDto = z.object({ export const PlayerDataDto = z.object({ 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') diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index eae31ea..45cd70c 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -19,6 +19,7 @@ import { getFeaturedRooms, getHotRooms, getInteraction, + getOrCreateDormRoom, getPresence, getPublicRoomsByCreator, getRecommendedRooms, @@ -96,6 +97,7 @@ import { RoomBanEnvelope, RoomDto, RoomEnvelope, + RoomExperiencePlayer, roomIdParam, RoomLookup, RoomResultEnvelope, @@ -845,6 +847,31 @@ const app = new Hono() 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 caller’s dorm', + description: [ + 'The caller’s 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 caller’s 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 — // returns a bare array (empty when the account owns no public rooms). .get( @@ -2611,6 +2638,24 @@ const app = new Hono() (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 caller’s 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 // include/unityAsset* query params. .get( diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index db62004..84c352e 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -89,6 +89,18 @@ beforeAll(async () => { // 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) + // 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 // check the caller is a friend of the player whose history they're asking for. await env.DB.prepare( @@ -129,6 +141,14 @@ describe('rooms endpoints', () => { 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 () => { const res = await SELF.fetch(`${ORIGIN}/rooms/99999`) expect(res.status).toBe(404) @@ -181,6 +201,41 @@ describe('rooms endpoints', () => { expect(other).toEqual([]) }) + it('GET /dormroom/me serves the caller’s 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, // 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 @@ -580,9 +635,7 @@ describe('rooms endpoints', () => { it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => { type Feed = { Results: Array<{ Name: string }>; TotalResults: number } const feed = async (): Promise => - (await ( - await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`) - ).json()) as Feed + (await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)).json()) as Feed const names = async (): Promise => (await feed()).Results.map((r) => r.Name) // 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}', 'GET /', 'GET /XXXfeaturedrooms/current', + 'GET /dormroom/me', 'GET /photon_access_token', 'GET /rooms', 'GET /rooms/base', @@ -2926,6 +2980,7 @@ describe('rooms endpoints', () => { 'GET /rooms/visitedby/{playerId}', 'GET /rooms/{roomId}', 'GET /rooms/{roomId}/bans', + 'GET /rooms/{roomId}/experience/player', 'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/similar',