optimize routes

This commit is contained in:
Devin Zuczek
2026-07-07 01:22:05 -04:00
parent 605ba02e6e
commit 6a49fa19ac
13 changed files with 636 additions and 543 deletions
+26 -543
View File
@@ -3,23 +3,18 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import apiConfigV2 from '../static/api-config-v2.json'
import gameConfigsV1All from '../static/gameconfigs-v1-all.json'
import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json'
import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json'
import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json'
import { defaultSettings } from './default-settings'
import {
createImage,
getImageByName,
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
} from './images-db'
import { validateAndGetAccountId } from './jwt'
import { getRoomById } from './rooms-db'
import { accountRoutes } from './routes/accounts'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
import { gameplayRoutes } from './routes/gameplay'
import { imageRoutes } from './routes/images'
import { inventoryRoutes } from './routes/inventory'
import { moderationRoutes } from './routes/moderation'
import { progressionRoutes } from './routes/progression'
import { roomRoutes } from './routes/rooms'
import { socialRoutes } from './routes/social'
import { storefrontRoutes } from './routes/storefronts'
import type { Context } from 'hono'
import type { App } from './context'
/**
@@ -28,79 +23,11 @@ import type { App } from './context'
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*
* Placeholder responses for file-backed endpoints are marked `TODO: hydrate`.
*
* Routes are grouped into per-domain controllers under `./routes` and mounted
* at `/` below. Shared request helpers live in `./http`.
*/
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
const SavedImageType = {
None: 0,
ShareCamera: 1,
OutfitThumbnail: 2,
RoomThumbnail: 3,
ProfileThumbnail: 4,
InventionThumbnail: 5,
} as const
/**
* Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check. Returns `null` when the header is missing,
* the token is invalid, or the `sub` claim isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
const authHeader = c.req.header('Authorization') ?? ''
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length)
const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get())
if (!accountId) return null
const id = Number.parseInt(accountId, 10)
return Number.isNaN(id) ? null : id
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/** Reads the `Ids` form field into a list of integer ids. */
async function parseFormIds(c: Context<App>): Promise<number[]> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const ids = body.Ids
if (typeof ids !== 'string') return []
return ids
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
}
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
function queryIds(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
)
}
/** Default reputation for an account — the fallback used with no DB. */
function defaultReputation(id: number) {
return {
AccountId: id,
Noteriety: 0,
CheerGeneral: 0,
CheerHelpful: 0,
CheerCreative: 0,
CheerGreatHost: 0,
CheerSportsman: 0,
CheerCredit: 20,
SelectedCheer: null,
}
}
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`) match either form.
const app = new Hono<App>({ strict: false })
.use(
@@ -116,461 +43,17 @@ const app = new Hono<App>({ strict: false })
.onError(withOnError())
.notFound(withNotFound())
// ---- Config / version ----------------------------------------------------
.get('/api/config/v1/amplitude', (c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
})
)
.get('/api/config/v1/azurespeech', (c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
)
.get('/api/config/v1/backtrace', (c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 0.025,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex:
"^Cannot set the parent of the GameObject .* while its new parent|^\\\\>\\\\x2010x\\\\:\\\\x20|\\\\'LabelTheme\\\\' contains missing PaletteTheme reference on",
VersionRegex: '.*',
})
)
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
// config is static.
.get('/api/config/v2', (c) =>
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
)
.get('/api/versioncheck/v4', (c) =>
c.json({
VersionStatus: 0,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
)
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
// ---- Social ---------------------------------------------------------------
.get('/api/relationships/v2/get', (c) => c.json([]))
.get('/api/messages/v2/get', (c) => c.json([]))
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
// ---- Reputation / progression --------------------------------------------
.get('/api/playerReputation/v1/:id', (c) =>
c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
)
.get('/api/players/v1/progression/:id', (c) => {
const id = Number.parseInt(c.req.param('id'), 10)
return c.json({ PlayerId: id, Level: 1, XP: 0 })
})
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
// Synthesize a default reputation per requested id (the intended behavior;
// the DB-less fallback reads a static JSON file instead).
.post('/api/playerReputation/v2/bulk', async (c) => {
const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation))
})
// The 2023 client calls this as a GET with repeated `id` query params.
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
.post('/api/players/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
.post('/api/players/v2/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
// The 2023 client calls this as a GET with repeated `id` query params.
// Return a default progression per requested id.
.get('/api/players/v2/progression/bulk', (c) =>
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
)
.post('/api/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
// ---- Avatar gifts ---------------------------------------------------------
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
// live in the `econ` worker, which the client calls on the econ host — not here.
// Only the gift generate/consume actions remain on this worker.
.post('/api/avatar/v2/gifts/generate', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
// No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
return c.json({
Id: 0, // TODO: real id once gifts are persisted
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
Currency: currency,
Xp: xp,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: 0,
GiftContext: giftContext,
GiftRarity: 20,
Message: message,
})
})
.post('/api/avatar/v2/gifts/consume', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
if (giftId === 0) return c.json({ success: false, error: 'Invalid gift ID' }, 400)
// No DB → gift can never be found.
return c.json({ success: false, error: 'Gift not found' }, 404)
})
// Custom avatar item gates — real Rec Room client endpoints with no backing
// implementation yet. Each returns a bare JSON boolean; we enable them. Flip
// to `false` to disable the corresponding flow.
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
.get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) =>
c.json({ Results: [], TotalResults: 0 })
)
// 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('/voice/config', (c) => c.json({}))
// ---- 2023 client loading-path endpoints ------------------------------------
// NUX checklist + saved inventions — empty lists with no DB.
.get('/api/checklist/v1/current', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
})
.get('/api/inventions/v2/mine', (c) => c.json([]))
// Text sanitization (display names, room names, chat). `v1` echoes the input
// value back; `isPure` reports the text is clean. The client sanitizes text
// during load/display, so a 404 here can stall room entry.
.post('/api/sanitize/v1', async (c) => {
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
return c.json(typeof body.Value === 'string' ? body.Value : '')
})
.post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true }))
// Keepsakes (room mementos). Shapes from the 2025 reference; categories isn't
// in any reference, so it's stubbed empty. The client fetches these on room
// entry — a 404 stalls the load.
.get('/api/keepsakes/globalconfig', (c) =>
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
)
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
.get('/api/keepsakes/categories', (c) => c.json([]))
// ---- Player reporting -----------------------------------------------------
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
c.json({
ReportCategory: 0,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: '',
PlayerIdReporter: null,
IsBan: false,
})
)
.get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json
.post('/api/PlayerReporting/v1/hile', (c) => c.json(false))
// ---- Settings -------------------------------------------------------------
.get('/api/settings/v2', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: load stored settings; seed defaults on first access.
return c.json(defaultSettings(id))
})
.post('/api/settings/v2/set', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: replace stored settings for `id`.
return c.body(null, 200)
})
// ---- Inventory ------------------------------------------------------------
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
.get('/api/consumables/v2/getUnlocked', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query ConsumableItems
})
// ---- Objectives / events / rewards ---------------------------------------
.get('/api/objectives/v1/myprogress', (c) => c.json({})) // TODO: hydrate from JSON/tempmyprogress.json
.post('/api/objectives/v1/updateobjective', (c) => c.body(null, 200))
.get('/api/gamerewards/v1/pending', (c) => c.json([]))
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
.get('/api/challenge/v2/getCurrent', (c) => c.json({})) // TODO: hydrate from JSON/weeklychallenge.json
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
// GameSight attribution/analytics event sink. Accept and ack without persisting.
.post('/api/gamesight/event', (c) => c.body(null, 200))
// ---- Subscription ---------------------------------------------------------
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
)
// ---- Storefronts ----------------------------------------------------------
.get('/api/storefronts/v4/balance/2', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query TokenBalances
})
.get('/api/storefronts/v1/p2p/betaEnabled', (c) => c.json(false))
.get('/api/storefronts/v3/giftdropstore/3', (c) => c.json(storefrontGiftDrop3))
.get('/api/storefronts/v3/giftdropstore/300', (c) => c.json(storefrontGiftDrop300))
.get('/api/storefronts/v3/giftdropstore/2', (c) => c.json(storefrontGiftDrop2))
.post('/api/storefronts/v2/buyItem', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// No StorefrontItems binding → item can never be found.
return c.json({ error: 'Item not found' }, 404)
})
// ---- Accounts -------------------------------------------------------------
.get('/api/accounts/v1/getBio', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query PlayerBios
})
.post('/api/accounts/v1/forplatformids', async (c) => {
await parseFormIds(c) // reads `Ids` then looks up CachedLogins
return c.json([])
})
// ---- Room keys / quick play ----------------------------------------------
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
.get('/api/roomkeys/v1/room', (c) => c.json([]))
.get('/api/quickPlay/v1/getandclear', (c) =>
c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
)
// ---- Images ---------------------------------------------------------------
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
.post('/api/images/v4/uploadsaved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The client posts the file as `image`; accept `file` too for safety.
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch {
// Malformed imgMeta — treat as an untyped upload (still stored).
}
}
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// Store the upload in the shared image bucket under a random key. The `img`
// worker serves it back by that key, which is the returned ImageName.
const name = crypto.randomUUID().replace(/-/g, '') + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE accounts SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
}
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name })
})
// A room's photo feed — the public images taken in that room. `sort` orders the
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
})
// A player's photos — the public images that player has taken, newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
})
// A player's photos with a sort option. `sort` orders the list (1 = most
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
})
// A player's photo feed — the public images they took plus ones they're tagged
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
})
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get('/api/images/v6', async (c) => {
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
})
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed
// for now — accepted but not persisted; cheer storage is still TBD.
.post('/api/images/v1/cheer', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: record the cheer against the image once cheer storage is designed.
await c.req.json().catch(() => null)
return c.json({ success: true })
})
// ---- Rooms ----------------------------------------------------------------
// Room search filters. The client deserializes this into an object (not an
// array) — shape from the 2025 reference.
.get('/api/rooms/v1/filters', (c) =>
c.json({
PinnedFilters: [
'recroomoriginal',
'community',
'featured',
'quest',
'pvp',
'hangout',
'game',
'art',
'store',
'tutorial',
'fandom',
'performance',
'action',
'horror',
],
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
})
)
// Verify the caller holds at least `role` in a room. Params come from the form
// body (falling back to the query string). Returns a bare `true`/`false`: the
// room creator always passes; otherwise the caller needs a Roles entry with
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
.post('/api/rooms/v1/verifyRole', async (c) => {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const param = (name: string): string => {
const form = body[name]
if (typeof form === 'string' && form !== '') return form
return c.req.query(name) ?? ''
}
const roomId = Number.parseInt(param('roomId'), 10)
const role = Number.parseInt(param('role'), 10)
const accountId = await authedId(c)
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.json(false)
// The creator always passes.
if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
const hasRole = roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
})
// ---- Controllers ----------------------------------------------------------
.route('/', configRoutes)
.route('/', socialRoutes)
.route('/', progressionRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', moderationRoutes)
.route('/', inventoryRoutes)
.route('/', storefrontRoutes)
.route('/', accountRoutes)
.route('/', roomRoutes)
.route('/', imageRoutes)
export default app
+49
View File
@@ -0,0 +1,49 @@
import { validateAndGetAccountId } from './jwt'
import type { Context } from 'hono'
import type { App } from './context'
/**
* Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check. Returns `null` when the header is missing,
* the token is invalid, or the `sub` claim isn't an integer.
*/
export async function authedId(c: Context<App>): Promise<number | null> {
const authHeader = c.req.header('Authorization') ?? ''
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length)
const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get())
if (!accountId) return null
const id = Number.parseInt(accountId, 10)
return Number.isNaN(id) ? null : id
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/** Reads the `Ids` form field into a list of integer ids. */
export async function parseFormIds(c: Context<App>): Promise<number[]> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const ids = body.Ids
if (typeof ids !== 'string') return []
return ids
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
}
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
export function queryIds(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
)
}
+17
View File
@@ -0,0 +1,17 @@
import { Hono } from 'hono'
import { authedId, parseFormIds, unauthorized } from '../http'
import type { App } from '../context'
// ---- Accounts --------------------------------------------------------------
export const accountRoutes = new Hono<App>({ strict: false })
.get('/api/accounts/v1/getBio', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query PlayerBios
})
.post('/api/accounts/v1/forplatformids', async (c) => {
await parseFormIds(c) // reads `Ids` then looks up CachedLogins
return c.json([])
})
+72
View File
@@ -0,0 +1,72 @@
import { Hono } from 'hono'
import { authedId, unauthorized } from '../http'
import type { App } from '../context'
// ---- Avatar gifts ----------------------------------------------------------
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
// live in the `econ` worker, which the client calls on the econ host — not here.
// Only the gift generate/consume actions remain on this worker.
export const avatarRoutes = new Hono<App>({ strict: false })
.post('/api/avatar/v2/gifts/generate', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
// No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
return c.json({
Id: 0, // TODO: real id once gifts are persisted
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
Currency: currency,
Xp: xp,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: 0,
GiftContext: giftContext,
GiftRarity: 20,
Message: message,
})
})
.post('/api/avatar/v2/gifts/consume', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
if (giftId === 0) return c.json({ success: false, error: 'Invalid gift ID' }, 400)
// No DB → gift can never be found.
return c.json({ success: false, error: 'Gift not found' }, 404)
})
// Custom avatar item gates — real Rec Room client endpoints with no backing
// implementation yet. Each returns a bare JSON boolean; we enable them. Flip
// to `false` to disable the corresponding flow.
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
.get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) =>
c.json({ Results: [], TotalResults: 0 })
)
// Saved inventions — empty list with no DB.
.get('/api/inventions/v2/mine', (c) => c.json([]))
+56
View File
@@ -0,0 +1,56 @@
import { Hono } from 'hono'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
import type { App } from '../context'
// ---- Config / version ------------------------------------------------------
export const configRoutes = new Hono<App>({ strict: false })
.get('/api/config/v1/amplitude', (c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
})
)
.get('/api/config/v1/azurespeech', (c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
)
.get('/api/config/v1/backtrace', (c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 0.025,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex:
"^Cannot set the parent of the GameObject .* while its new parent|^\\\\>\\\\x2010x\\\\:\\\\x20|\\\\'LabelTheme\\\\' contains missing PaletteTheme reference on",
VersionRegex: '.*',
})
)
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
// config is static.
.get('/api/config/v2', (c) =>
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
)
.get('/api/versioncheck/v4', (c) =>
c.json({
VersionStatus: 0,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
)
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
// 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('/voice/config', (c) => c.json({}))
+51
View File
@@ -0,0 +1,51 @@
import { Hono } from 'hono'
import { authedId, unauthorized } from '../http'
import type { App } from '../context'
// ---- 2023 client loading-path endpoints ------------------------------------
// NUX checklist, text sanitization, keepsakes, objectives/events/rewards, and
// the misc analytics/subscription sinks the client hits during load.
export const gameplayRoutes = new Hono<App>({ strict: false })
// NUX checklist — empty list with no DB.
.get('/api/checklist/v1/current', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
})
// Text sanitization (display names, room names, chat). `v1` echoes the input
// value back; `isPure` reports the text is clean. The client sanitizes text
// during load/display, so a 404 here can stall room entry.
.post('/api/sanitize/v1', async (c) => {
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
return c.json(typeof body.Value === 'string' ? body.Value : '')
})
.post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true }))
// Keepsakes (room mementos). Shapes from the 2025 reference; categories isn't
// in any reference, so it's stubbed empty. The client fetches these on room
// entry — a 404 stalls the load.
.get('/api/keepsakes/globalconfig', (c) =>
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
)
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
.get('/api/keepsakes/categories', (c) => c.json([]))
// ---- Objectives / events / rewards ---------------------------------------
.get('/api/objectives/v1/myprogress', (c) => c.json({})) // TODO: hydrate from JSON/tempmyprogress.json
.post('/api/objectives/v1/updateobjective', (c) => c.body(null, 200))
.get('/api/gamerewards/v1/pending', (c) => c.json([]))
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
.get('/api/challenge/v2/getCurrent', (c) => c.json({})) // TODO: hydrate from JSON/weeklychallenge.json
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
// GameSight attribution/analytics event sink. Accept and ack without persisting.
.post('/api/gamesight/event', (c) => c.body(null, 200))
// ---- Subscription ---------------------------------------------------------
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
)
+153
View File
@@ -0,0 +1,153 @@
import { Hono } from 'hono'
import {
createImage,
getImageByName,
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
} from '../images-db'
import { authedId, unauthorized } from '../http'
import type { App } from '../context'
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
const SavedImageType = {
None: 0,
ShareCamera: 1,
OutfitThumbnail: 2,
RoomThumbnail: 3,
ProfileThumbnail: 4,
InventionThumbnail: 5,
} as const
// ---- Images ----------------------------------------------------------------
export const imageRoutes = new Hono<App>({ strict: false })
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
.post('/api/images/v4/uploadsaved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The client posts the file as `image`; accept `file` too for safety.
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch {
// Malformed imgMeta — treat as an untyped upload (still stored).
}
}
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// Store the upload in the shared image bucket under a random key. The `img`
// worker serves it back by that key, which is the returned ImageName.
const name = crypto.randomUUID().replace(/-/g, '') + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE accounts SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
}
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name })
})
// A room's photo feed — the public images taken in that room. `sort` orders the
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
})
// A player's photos — the public images that player has taken, newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
})
// A player's photos with a sort option. `sort` orders the list (1 = most
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
})
// A player's photo feed — the public images they took plus ones they're tagged
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
})
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get('/api/images/v6', async (c) => {
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
})
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed
// for now — accepted but not persisted; cheer storage is still TBD.
.post('/api/images/v1/cheer', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: record the cheer against the image once cheer storage is designed.
await c.req.json().catch(() => null)
return c.json({ success: true })
})
+30
View File
@@ -0,0 +1,30 @@
import { Hono } from 'hono'
import { defaultSettings } from '../default-settings'
import { authedId, unauthorized } from '../http'
import type { App } from '../context'
// ---- Settings / inventory --------------------------------------------------
export const inventoryRoutes = new Hono<App>({ strict: false })
// ---- Settings -------------------------------------------------------------
.get('/api/settings/v2', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: load stored settings; seed defaults on first access.
return c.json(defaultSettings(id))
})
.post('/api/settings/v2/set', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: replace stored settings for `id`.
return c.body(null, 200)
})
// ---- Inventory ------------------------------------------------------------
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
.get('/api/consumables/v2/getUnlocked', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query ConsumableItems
})
+19
View File
@@ -0,0 +1,19 @@
import { Hono } from 'hono'
import type { App } from '../context'
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
c.json({
ReportCategory: 0,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: '',
PlayerIdReporter: null,
IsBan: false,
})
)
.get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json
.post('/api/PlayerReporting/v1/hile', (c) => c.json(false))
+57
View File
@@ -0,0 +1,57 @@
import { Hono } from 'hono'
import { parseFormIds, queryIds } from '../http'
import type { App } from '../context'
/** Default reputation for an account — the fallback used with no DB. */
function defaultReputation(id: number) {
return {
AccountId: id,
Noteriety: 0,
CheerGeneral: 0,
CheerHelpful: 0,
CheerCreative: 0,
CheerGreatHost: 0,
CheerSportsman: 0,
CheerCredit: 20,
SelectedCheer: null,
}
}
// ---- Reputation / progression ----------------------------------------------
export const progressionRoutes = new Hono<App>({ strict: false })
.get('/api/playerReputation/v1/:id', (c) =>
c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
)
.get('/api/players/v1/progression/:id', (c) => {
const id = Number.parseInt(c.req.param('id'), 10)
return c.json({ PlayerId: id, Level: 1, XP: 0 })
})
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
// Synthesize a default reputation per requested id (the intended behavior;
// the DB-less fallback reads a static JSON file instead).
.post('/api/playerReputation/v2/bulk', async (c) => {
const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation))
})
// The 2023 client calls this as a GET with repeated `id` query params.
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
.post('/api/players/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
.post('/api/players/v2/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
// The 2023 client calls this as a GET with repeated `id` query params.
// Return a default progression per requested id.
.get('/api/players/v2/progression/bulk', (c) =>
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
)
.post('/api/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
+71
View File
@@ -0,0 +1,71 @@
import { Hono } from 'hono'
import { authedId } from '../http'
import { getRoomById } from '../rooms-db'
import type { App } from '../context'
// ---- Room keys / quick play / rooms ----------------------------------------
export const roomRoutes = new Hono<App>({ strict: false })
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
.get('/api/roomkeys/v1/room', (c) => c.json([]))
.get('/api/quickPlay/v1/getandclear', (c) =>
c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
)
// Room search filters. The client deserializes this into an object (not an
// array) — shape from the 2025 reference.
.get('/api/rooms/v1/filters', (c) =>
c.json({
PinnedFilters: [
'recroomoriginal',
'community',
'featured',
'quest',
'pvp',
'hangout',
'game',
'art',
'store',
'tutorial',
'fandom',
'performance',
'action',
'horror',
],
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
})
)
// Verify the caller holds at least `role` in a room. Params come from the form
// body (falling back to the query string). Returns a bare `true`/`false`: the
// room creator always passes; otherwise the caller needs a Roles entry with
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
.post('/api/rooms/v1/verifyRole', async (c) => {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const param = (name: string): string => {
const form = body[name]
if (typeof form === 'string' && form !== '') return form
return c.req.query(name) ?? ''
}
const roomId = Number.parseInt(param('roomId'), 10)
const role = Number.parseInt(param('role'), 10)
const accountId = await authedId(c)
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.json(false)
// The creator always passes.
if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
const hasRole = roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
})
+9
View File
@@ -0,0 +1,9 @@
import { Hono } from 'hono'
import type { App } from '../context'
// ---- Social ----------------------------------------------------------------
export const socialRoutes = new Hono<App>({ strict: false })
.get('/api/relationships/v2/get', (c) => c.json([]))
.get('/api/messages/v2/get', (c) => c.json([]))
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
+26
View File
@@ -0,0 +1,26 @@
import { Hono } from 'hono'
import storefrontGiftDrop2 from '../../static/storefronts-v3-giftdropstore-2.json'
import storefrontGiftDrop3 from '../../static/storefronts-v3-giftdropstore-3.json'
import storefrontGiftDrop300 from '../../static/storefronts-v3-giftdropstore-300.json'
import { authedId, unauthorized } from '../http'
import type { App } from '../context'
// ---- Storefronts -----------------------------------------------------------
export const storefrontRoutes = new Hono<App>({ strict: false })
.get('/api/storefronts/v4/balance/2', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query TokenBalances
})
.get('/api/storefronts/v1/p2p/betaEnabled', (c) => c.json(false))
.get('/api/storefronts/v3/giftdropstore/3', (c) => c.json(storefrontGiftDrop3))
.get('/api/storefronts/v3/giftdropstore/300', (c) => c.json(storefrontGiftDrop300))
.get('/api/storefronts/v3/giftdropstore/2', (c) => c.json(storefrontGiftDrop2))
.post('/api/storefronts/v2/buyItem', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// No StorefrontItems binding → item can never be found.
return c.json({ error: 'Item not found' }, 404)
})