diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index a5da1cf..2a813bc 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -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): Promise { - 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) { - return c.body(null, 401) -} - -/** Reads the `Ids` form field into a list of integer ids. */ -async function parseFormIds(c: Context): Promise { - const body = await c.req.parseBody().catch(() => ({}) as Record) - 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): 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({ strict: false }) .use( @@ -116,461 +43,17 @@ const app = new Hono({ 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) - 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) - 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) - // 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 = {} - if (typeof body.imgMeta === 'string') { - try { - const parsed = JSON.parse(body.imgMeta) - if (parsed && typeof parsed === 'object') meta = parsed as Record - } 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 - 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>) : [] - 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 diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts new file mode 100644 index 0000000..56b6c6e --- /dev/null +++ b/apps/api/src/http.ts @@ -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): Promise { + 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) { + return c.body(null, 401) +} + +/** Reads the `Ids` form field into a list of integer ids. */ +export async function parseFormIds(c: Context): Promise { + const body = await c.req.parseBody().catch(() => ({}) as Record) + 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): number[] { + return ( + c.req + .queries('id') + ?.flatMap((v) => v.split(',')) + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)) ?? [] + ) +} diff --git a/apps/api/src/routes/accounts.ts b/apps/api/src/routes/accounts.ts new file mode 100644 index 0000000..4797343 --- /dev/null +++ b/apps/api/src/routes/accounts.ts @@ -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({ 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([]) + }) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts new file mode 100644 index 0000000..a7ddf8c --- /dev/null +++ b/apps/api/src/routes/avatar.ts @@ -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({ 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) + 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) + 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([])) diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts new file mode 100644 index 0000000..5a2fcd0 --- /dev/null +++ b/apps/api/src/routes/config.ts @@ -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({ 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({})) diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts new file mode 100644 index 0000000..8ee588b --- /dev/null +++ b/apps/api/src/routes/gameplay.ts @@ -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({ 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 }) + ) diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts new file mode 100644 index 0000000..135568d --- /dev/null +++ b/apps/api/src/routes/images.ts @@ -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({ 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) + // 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 = {} + if (typeof body.imgMeta === 'string') { + try { + const parsed = JSON.parse(body.imgMeta) + if (parsed && typeof parsed === 'object') meta = parsed as Record + } 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 }) + }) diff --git a/apps/api/src/routes/inventory.ts b/apps/api/src/routes/inventory.ts new file mode 100644 index 0000000..0610b23 --- /dev/null +++ b/apps/api/src/routes/inventory.ts @@ -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({ 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 + }) diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts new file mode 100644 index 0000000..1e813fe --- /dev/null +++ b/apps/api/src/routes/moderation.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono' + +import type { App } from '../context' + +// ---- Player reporting ------------------------------------------------------ +export const moderationRoutes = new Hono({ 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)) diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts new file mode 100644 index 0000000..ea38c7c --- /dev/null +++ b/apps/api/src/routes/progression.ts @@ -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({ 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([]) + }) diff --git a/apps/api/src/routes/rooms.ts b/apps/api/src/routes/rooms.ts new file mode 100644 index 0000000..da11e56 --- /dev/null +++ b/apps/api/src/routes/rooms.ts @@ -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({ 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 + 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>) : [] + const hasRole = roles.some( + (r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0) + ) + return c.json(hasRole) + }) diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts new file mode 100644 index 0000000..c0246ba --- /dev/null +++ b/apps/api/src/routes/social.ts @@ -0,0 +1,9 @@ +import { Hono } from 'hono' + +import type { App } from '../context' + +// ---- Social ---------------------------------------------------------------- +export const socialRoutes = new Hono({ 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([])) diff --git a/apps/api/src/routes/storefronts.ts b/apps/api/src/routes/storefronts.ts new file mode 100644 index 0000000..ec0de6f --- /dev/null +++ b/apps/api/src/routes/storefronts.ts @@ -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({ 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) + })