more stubs

This commit is contained in:
Devin Zuczek
2026-08-15 13:54:19 -04:00
parent 8ea0caa1e5
commit 11b037a2f1
67 changed files with 38566 additions and 2652 deletions
+2
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
@@ -62,6 +63,7 @@ const app = new Hono<App>({ strict: false })
.route('/', inventoryRoutes)
.route('/', roomRoutes)
.route('/', imageRoutes)
.route('/', accountRoutes)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
+11
View File
@@ -417,6 +417,17 @@ export const GenerateGiftRequest = z.object({
Xp: z.string().optional(),
})
/**
* `POST /api/customAvatarItems/v1/bulk` form body. A repeated form field, not a JSON
* array: the reference binds `[FromForm] List<string>`, so the client posts
* `customAvatarItemIds=a&customAvatarItemIds=b`.
*/
export const BulkCustomAvatarItemsRequest = z.object({
customAvatarItemIds: z
.array(z.string())
.describe('The ids to resolve; repeat the field once per id'),
})
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
export const CustomAvatarItemsPage = z.object({
Results: JsonArray,
+27
View File
@@ -0,0 +1,27 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { json, JsonArray, stringParam } from '../openapi'
import type { App } from '../context'
// ---- Account ---------------------------------------------------------------
// The identity service's account-scoped reads. Nothing here is backed by storage — the
// client calls it while loading the account, and an empty list is a complete answer for a
// server that links no external channels to an account.
export const accountRoutes = new Hono<App>({ strict: false }).get(
'/iam/me/channels/:type',
describeRoute({
tags: ['Account'],
summary: 'The callers channels of a type',
description:
'The channels of the given `{type}` linked to the callers account. This server ' +
'links none, so the list is always empty — a real answer rather than a placeholder ' +
'for one, since the client renders "nothing linked" from it. `{type}` is accepted ' +
'but not inspected, and the route is not auth-gated: the answer is the same for ' +
'every caller and every type.',
parameters: [stringParam('type', 'The channel type. Accepted but not inspected.')],
responses: { 200: json(JsonArray, 'Always an empty list') },
}),
(c) => c.json([])
)
+37
View File
@@ -34,6 +34,7 @@ import {
import {
AUTHED,
BareBoolean,
BulkCustomAvatarItemsRequest,
CustomAvatarItemsPage,
ErrorResponse,
form,
@@ -224,6 +225,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
(c) => c.json([])
)
// A batch lookup of custom avatar items by id. The reference filters a static catalog
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
// ships empty too), so every id misses and the array is empty.
//
// Auth-gated, and the token is checked before anything else, as the reference does.
.post(
'/api/customAvatarItems/v1/bulk',
describeRoute({
tags: ['Avatar'],
summary: 'Custom avatar items in bulk',
description:
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
'answers this route with the filtered array alone.\n\n' +
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
'client reads the items it got back rather than the ids it asked for. Nothing ' +
'stores custom items here, so every id misses and this is always `[]` — which is ' +
'why the posted ids are not parsed.',
security: AUTHED,
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
responses: {
200: json(JsonArray, 'The matching items — always empty here'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
.get(
+19
View File
@@ -139,6 +139,25 @@ export const configRoutes = new Hono<App>({ strict: false })
(c) => c.json(gameConfigsV1All)
)
// The property bag the client would attach to its Statsig user. Analytics are off here
// (see the placeholder keys `/api/config/v1/amplitude` serves), so there is nothing to
// segment on and the bag is empty — the client reads that as "no overrides" and carries
// on, which is why this answers `{}` rather than 404ing.
.get(
'/statsigUserProperties',
describeRoute({
tags: ['Config'],
summary: 'Statsig user properties',
description:
'The custom properties the client would attach to its Statsig user for experiment ' +
'targeting. This server runs no experiments and collects no analytics, so the bag ' +
'is always empty — the client reads `{}` as “no overrides”. Not auth-gated: there ' +
'is nothing per-account to leak in an empty object.',
responses: { 200: json(JsonObject, 'An empty object') },
}),
(c) => c.json({})
)
// Voice chat config. The client fetches it to set up voice.
// No reference shape, so return an empty object until the client needs fields.
.get(
+58
View File
@@ -488,6 +488,44 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
// the sibling custom-item reads serve. Nothing stores custom items, so every id
// misses, and a miss is an absent entry rather than an error.
test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(await bearer()),
},
// Repeated form field, as `[FromForm] List<string>` binds it.
body: new URLSearchParams([
['customAvatarItemIds', 'a'],
['customAvatarItemIds', 'b'],
]),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// The ids are never parsed (nothing could match), so a missing body is still a 200
// rather than the 400 a body-reading handler would produce.
test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/customAvatarItems/v1/bulk is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
})
expect(res.status).toBe(401)
})
test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`,
@@ -634,6 +672,12 @@ describe('public endpoints', () => {
expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('GET /statsigUserProperties returns an empty object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/statsigUserProperties`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
test('GET /voice/config returns an object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/voice/config`)
expect(res.status).toBe(200)
@@ -1592,6 +1636,17 @@ describe('public endpoints', () => {
})
})
describe('account', () => {
test.each(['email', 'phone', 'anything'])(
'GET /iam/me/channels/%s is an empty list',
async (type) => {
const res = await exports.default.fetch(`${ORIGIN}/iam/me/channels/${type}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
})
describe('auth-gated endpoints', () => {
test('401 without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
@@ -3816,8 +3871,10 @@ describe('openapi', () => {
'GET /api/rooms/v1/filters',
'GET /api/versioncheck/islandedversions',
'GET /api/versioncheck/v4',
'GET /iam/me/channels/{type}',
'GET /outfits/me',
'GET /outfits/me/saved',
'GET /statsigUserProperties',
'GET /voice/config',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
@@ -3826,6 +3883,7 @@ describe('openapi', () => {
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
'POST /api/customAvatarItems/v1/bulk',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
File diff suppressed because one or more lines are too long