From b582e13dbac771700f3f6f0dc149fb3d23c409f6 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 20 Jun 2026 14:06:48 +0200 Subject: [PATCH 1/5] fix: correct default app URL in sitemap generator --- src/app/sitemap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 14316e79..d4e17cef 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -5,7 +5,7 @@ import { getApprovedPcListingsForSitemap, } from '@/server/db/seo-queries' -const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://emuready.com' +const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.emuready.com' export default async function sitemap(): Promise { const staticPages: MetadataRoute.Sitemap = [ From 9eade60e4b53d5ea2a7d80c9b81670ed2d7e3a12 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 20 Jun 2026 14:10:13 +0200 Subject: [PATCH 2/5] fix: replace polling intervals with window focus refetch in listing editors --- src/app/listings/[id]/components/EditListingButton.tsx | 3 +-- src/app/pc-listings/[id]/components/EditPcListingButton.tsx | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/listings/[id]/components/EditListingButton.tsx b/src/app/listings/[id]/components/EditListingButton.tsx index 6136263d..fa6404a3 100644 --- a/src/app/listings/[id]/components/EditListingButton.tsx +++ b/src/app/listings/[id]/components/EditListingButton.tsx @@ -4,7 +4,6 @@ import { useUser } from '@clerk/nextjs' import { Edit3, Clock } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui' -import { POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import EditListingModal from './EditListingModal' @@ -21,7 +20,7 @@ function EditListingButton(props: Props) { { id: props.listingId }, { enabled: !!user?.id, - refetchInterval: POLLING_INTERVALS.SHORT, + refetchOnWindowFocus: true, }, ) diff --git a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx index 1871602c..1201e91c 100644 --- a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx +++ b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx @@ -4,7 +4,6 @@ import { useUser } from '@clerk/nextjs' import { Edit3, Clock } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui' -import { POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import EditPcListingModal from './EditPcListingModal' @@ -21,7 +20,7 @@ function EditPcListingButton(props: Props) { { id: props.pcListingId }, { enabled: !!user?.id, - refetchInterval: POLLING_INTERVALS.SHORT, + refetchOnWindowFocus: true, }, ) From aa5a8c7f339e2dc57cc2ba86915e6554aa597272 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 30 Jun 2026 10:11:26 +0200 Subject: [PATCH 3/5] refactor: add unit tests for mobile context, proxy handling, Steam batch lookup, and trpc caching --- docs/MOBILE_API.md | 8 +- next.config.ts | 18 - public/api-docs/mobile-openapi.json | 748 +++++++++++++++++- .../components/ApprovalCountBadge.test.tsx | 132 +--- .../admin/components/ApprovalCountBadge.tsx | 23 +- .../custom-field-templates/page.test.tsx | 10 +- .../components/BatchSteamLookup.tsx | 126 +-- src/app/api/mobile/trpc/[trpc]/route.ts | 48 +- src/app/api/trpc/[trpc]/route.ts | 15 + src/lib/api.tsx | 2 + src/lib/cors.test.ts | 34 + src/lib/cors.ts | 22 +- src/proxy.test.ts | 73 ++ src/proxy.ts | 25 +- src/schemas/mobile.ts | 226 +++++- src/scripts/api/generate-api-docs.ts | 64 +- src/server/api/mobileContext.test.ts | 59 ++ src/server/api/mobileContext.ts | 5 +- src/server/api/routers/admin/titleIdTools.ts | 12 + src/server/api/routers/mobile/catalog.ts | 2 +- src/server/api/routers/mobile/games.ts | 225 +----- src/server/api/trpc-cache.test.ts | 156 ++++ src/server/api/trpc-cache.ts | 88 +++ src/server/services/catalog.service.test.ts | 2 +- src/server/services/catalog.service.ts | 8 +- .../steam-batch-lookup.service.test.ts | 196 +++++ .../services/steam-batch-lookup.service.ts | 118 +++ src/server/utils/cache/instances.ts | 3 +- 28 files changed, 1973 insertions(+), 475 deletions(-) create mode 100644 src/proxy.test.ts create mode 100644 src/server/api/mobileContext.test.ts create mode 100644 src/server/api/trpc-cache.test.ts create mode 100644 src/server/api/trpc-cache.ts create mode 100644 src/server/services/steam-batch-lookup.service.test.ts create mode 100644 src/server/services/steam-batch-lookup.service.ts diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 2abcdaca..8aee0644 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -1,6 +1,6 @@ # EmuReady Public Integration API (mobile-compatible tRPC) -*Auto-generated on: 2026-06-15T12:16:27.168Z* +*Auto-generated on: 2026-06-21T17:28:57.453Z* ## Summary - **Total Endpoints**: 113 @@ -12,7 +12,7 @@ `/api/mobile/trpc` ## Authentication -Protected endpoints require Bearer token authentication using Clerk JWT. +Protected endpoints require Bearer token authentication using Clerk JWT. Public integration requests can also include an issued API key in `x-api-key`. ## Interactive Documentation - **Swagger UI**: [/docs/api/swagger](https://emuready.com/docs/api/swagger) @@ -33,7 +33,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 2. **getDeviceCompatibility** - **Method**: GET - **Path**: `/catalog.getDeviceCompatibility` -- **Description**: Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 10 minutes to reduce server load. +- **Description**: Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 15 minutes to reduce server load. - **Tags**: catalog @@ -208,7 +208,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 27. **batchBySteamAppIds** - **Method**: GET - **Path**: `/games.batchBySteamAppIds` -- **Description**: Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 5 minutes to optimize repeated queries +- **Description**: Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 15 minutes to optimize repeated queries - **Tags**: games diff --git a/next.config.ts b/next.config.ts index affdc29e..2d2dbc6d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -280,24 +280,6 @@ const nextConfig: NextConfig = { source: '/favicon/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=86400, must-revalidate' }], }, - { - source: '/api/mobile/:path*', - headers: [ - { key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }, - { key: 'Access-Control-Allow-Origin', value: '*' }, - { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' }, - { - key: 'Access-Control-Allow-Headers', - value: 'Content-Type, Authorization, x-trpc-source', - }, - { key: 'Access-Control-Expose-Headers', value: 'x-trpc-source' }, - ], - }, - // tRPC endpoints are dynamic; prevent intermediary/proxy caching - { - source: '/api/trpc/:path*', - headers: [{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }], - }, { source: '/(.*)', headers: [ diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json index f7f24bab..fb75ee27 100644 --- a/public/api-docs/mobile-openapi.json +++ b/public/api-docs/mobile-openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "EmuReady Public Integration API (mobile-compatible tRPC)", - "description": "\n# EmuReady Public Integration tRPC API\n\nAPI documentation for the mobile-compatible public integration surface built with tRPC.\n\n## tRPC HTTP Method Conventions\n\nNOTE: the protected routes require authentication via Clerk JWT token in the Authorization header. This isn't implemented yet.\n\ntRPC uses HTTP method semantics with fetchRequestHandler:\n- **Queries** use **GET** requests with input as query parameter\n- **Mutations** use **POST** requests with input in request body\n\n### Schema References:\n\nAll input schemas are defined in the **components/schemas** section. When you see a parameter referencing a schema (e.g., GetEmulatorsSchema), check the schemas section for the complete structure with field types, validations, and defaults.\n\n### Usage Examples:\n\n```bash\n# Query: Get games with search and limit (GET with SuperJSON wrapped input)\n# Schema: See components/schemas/GetGamesSchema\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getGames?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get popular games (GET, no input required)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getPopularGames\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get listings with filters (GET with SuperJSON wrapped input)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getListings?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Mutation: Create listing (POST with request body)\ncurl -X POST \"https://www.emuready.com/api/mobile/trpc/listings.createListing\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\\n -d '{\"gameId\":\"uuid\",\"deviceId\":\"uuid\",\"emulatorId\":\"uuid\",\"performanceId\":\"uuid\"}'\n\n# Protected query with authentication (GET with SuperJSON wrapped input and auth header)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getUserListings?input=%7B%22json%22%3A%7B%22userId%22%3A%22uuid%22%7D%7D\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\"\n```\n\n### Important Notes:\n\n**For Queries (GET requests):**\n✅ Use GET method\n✅ Send input wrapped in SuperJSON format: `{\"json\":{\"field\":\"value\"}}`\n✅ URL-encode the entire JSON string\n✅ Many endpoints have defaults and don't require input\n✅ Input parameter format: `?input={\"json\":{\"field\":\"value\"}}` (URL-encoded)\n\n**For Mutations (POST requests):**\n✅ Use POST method\n✅ Send input as JSON in request body\n✅ Set Content-Type: application/json\n\n### Response Format:\nAll responses are wrapped in a tRPC result object:\n```json\n{\n \"result\": {\n \"data\": /* response data */\n }\n}\n```\n\n### Error Response Format:\n```json\n{\n \"error\": {\n \"json\": {\n \"message\": \"Error message\",\n \"code\": -32600,\n \"data\": {\n \"code\": \"BAD_REQUEST\",\n \"httpStatus\": 400,\n \"path\": \"games.getGames\"\n }\n }\n }\n}\n```\n\nThis API provides endpoints for:\n- Game emulation listings management\n- User authentication and profiles \n- Device and hardware information\n- Emulator data and compatibility\n- Community features (comments, votes)\n ", + "description": "\n# EmuReady Public Integration tRPC API\n\nAPI documentation for the mobile-compatible public integration surface built with tRPC.\n\n## tRPC HTTP Method Conventions\n\nProtected routes require authentication via Clerk JWT token in the Authorization header. Public integration requests can also include an issued API key in `x-api-key`; invalid explicit API keys are rejected.\n\ntRPC uses HTTP method semantics with fetchRequestHandler:\n- **Queries** use **GET** requests with input as query parameter\n- **Mutations** use **POST** requests with input in request body\n\n### Schema References:\n\nAll input schemas are defined in the **components/schemas** section. When you see a parameter referencing a schema (e.g., GetEmulatorsSchema), check the schemas section for the complete structure with field types, validations, and defaults.\n\n### Usage Examples:\n\n```bash\n# Query: Get games with search and limit (GET with SuperJSON wrapped input)\n# Schema: See components/schemas/GetGamesSchema\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.get?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get popular games (GET, no input required)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getPopularGames\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get listings with filters (GET with SuperJSON wrapped input)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.get?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Mutation: Create listing (POST with request body)\ncurl -X POST \"https://www.emuready.com/api/mobile/trpc/listings.createListing\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\\n -d '{\"gameId\":\"uuid\",\"deviceId\":\"uuid\",\"emulatorId\":\"uuid\",\"performanceId\":\"uuid\"}'\n\n# Protected query with authentication (GET with SuperJSON wrapped input and auth header)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getUserListings?input=%7B%22json%22%3A%7B%22userId%22%3A%22uuid%22%7D%7D\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\"\n```\n\n### Important Notes:\n\n**For Queries (GET requests):**\n✅ Use GET method\n✅ Send input wrapped in SuperJSON format: `{\"json\":{\"field\":\"value\"}}`\n✅ URL-encode the entire JSON string\n✅ Many endpoints have defaults and don't require input\n✅ Input parameter format: `?input={\"json\":{\"field\":\"value\"}}` (URL-encoded)\n\n**For Mutations (POST requests):**\n✅ Use POST method\n✅ Send input as JSON in request body\n✅ Set Content-Type: application/json\n\n### Response Format:\nAll responses are wrapped in a tRPC result object:\n```json\n{\n \"result\": {\n \"data\": /* response data */\n }\n}\n```\n\n### Error Response Format:\n```json\n{\n \"error\": {\n \"json\": {\n \"message\": \"Error message\",\n \"code\": -32600,\n \"data\": {\n \"code\": \"BAD_REQUEST\",\n \"httpStatus\": 400,\n \"path\": \"games.get\"\n }\n }\n }\n}\n```\n\nThis API provides endpoints for:\n- Game emulation listings management\n- User authentication and profiles \n- Device and hardware information\n- Emulator data and compatibility\n- Community features (comments, votes)\n ", "version": "1.0.0", "contact": { "name": "EmuReady API Support", @@ -65,7 +65,7 @@ }, "path": { "type": "string", - "description": "tRPC procedure path (e.g., \"games.getGames\")" + "description": "tRPC procedure path (e.g., \"games.get\")" }, "zodError": { "type": "object", @@ -163,10 +163,12 @@ }, "deviceModelName": { "type": "string", + "maxLength": 120, "description": "Device model name (e.g., \"Pocket 5\")" }, "deviceBrandName": { "type": "string", + "maxLength": 80, "description": "Device brand name (e.g., \"Retroid\")" }, "systemIds": { @@ -175,6 +177,7 @@ "type": "string", "format": "uuid" }, + "maxItems": 100, "description": "Filter results to specific system IDs" }, "includeEmulatorBreakdown": { @@ -185,6 +188,7 @@ "minListingCount": { "type": "number", "minimum": 0, + "maximum": 100, "default": 1, "description": "Minimum number of listings required to include a system" } @@ -451,6 +455,7 @@ "properties": { "search": { "type": "string", + "maxLength": 100, "description": "Search devices by name" }, "brandId": { @@ -479,7 +484,8 @@ "format": "uuid" }, "search": { - "type": "string" + "type": "string", + "maxLength": 100 }, "limit": { "type": "number", @@ -512,7 +518,8 @@ "type": "object", "properties": { "search": { - "type": "string" + "type": "string", + "maxLength": 100 }, "systemId": { "type": "string", @@ -546,7 +553,8 @@ "properties": { "query": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 100 } }, "required": [ @@ -572,7 +580,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 }, "maxResults": { "type": "number", @@ -591,7 +600,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 } }, "required": [ @@ -616,7 +626,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 }, "maxResults": { "type": "number", @@ -635,7 +646,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 } }, "required": [ @@ -660,7 +672,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 }, "maxResults": { "type": "number", @@ -679,7 +692,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 } }, "required": [ @@ -720,6 +734,7 @@ }, "emulatorName": { "type": "string", + "maxLength": 100, "description": "Filter listings by emulator name" }, "maxListingsPerGame": { @@ -745,12 +760,661 @@ ], "additionalProperties": false }, + "BatchBySteamAppIdsResponseSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "results": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "steamAppId": { + "type": "string" + }, + "game": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "normalizedTitle": { + "type": "string", + "nullable": true + }, + "systemId": { + "type": "string" + }, + "imageUrl": { + "type": "string", + "nullable": true + }, + "boxartUrl": { + "type": "string", + "nullable": true + }, + "bannerUrl": { + "type": "string", + "nullable": true + }, + "tgdbGameId": { + "type": "number", + "nullable": true + }, + "metadata": {}, + "isErotic": { + "type": "boolean" + }, + "ageRating": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "system": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "key": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "key" + ], + "additionalProperties": true + }, + "_count": { + "type": "object", + "properties": { + "listings": { + "type": "number" + } + }, + "required": [ + "listings" + ], + "additionalProperties": true + }, + "listings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "upvoteCount": { + "type": "number" + }, + "downvoteCount": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "successRate": { + "type": "number", + "nullable": true + }, + "deviceId": { + "type": "string" + }, + "gameId": { + "type": "string" + }, + "emulatorId": { + "type": "string" + }, + "performanceId": { + "type": "number" + }, + "device": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "soc": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "manufacturer": { + "type": "string", + "nullable": true + }, + "architecture": { + "type": "string", + "nullable": true + }, + "processNode": { + "type": "string", + "nullable": true + }, + "cpuCores": { + "type": "number", + "nullable": true + }, + "gpuModel": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "manufacturer", + "architecture", + "processNode", + "cpuCores", + "gpuModel" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "modelName", + "soc" + ], + "additionalProperties": true + }, + "emulator": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logo": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "logo" + ], + "additionalProperties": true + }, + "performance": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "label": { + "type": "string" + }, + "rank": { + "type": "number" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "label", + "rank", + "description" + ], + "additionalProperties": true + }, + "customFieldValues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "listingId": { + "type": "string" + }, + "customFieldDefinitionId": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ] + }, + "customFieldDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "type", + "label", + "name" + ], + "additionalProperties": true + } + }, + "required": [ + "id", + "listingId", + "customFieldDefinitionId", + "value", + "customFieldDefinition" + ], + "additionalProperties": true + } + } + }, + "required": [ + "id", + "notes", + "upvoteCount", + "downvoteCount", + "voteCount", + "successRate", + "deviceId", + "gameId", + "emulatorId", + "performanceId", + "device", + "emulator", + "performance", + "customFieldValues" + ], + "additionalProperties": true + } + } + }, + "required": [ + "id", + "title", + "systemId", + "imageUrl", + "boxartUrl", + "bannerUrl", + "tgdbGameId", + "isErotic", + "status", + "createdAt", + "system", + "_count", + "listings" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "matchStrategy": { + "type": "string", + "enum": [ + "metadata", + "exact", + "normalized", + "not_found" + ] + } + }, + "required": [ + "steamAppId", + "game", + "matchStrategy" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "game_id": { + "type": "string", + "nullable": true + }, + "steam_app_id": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "performance": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "label": { + "type": "string" + }, + "rank": { + "type": "number" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "label", + "rank", + "description" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "emulator": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logo": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "logo" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "device": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "soc": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "manufacturer": { + "type": "string", + "nullable": true + }, + "architecture": { + "type": "string", + "nullable": true + }, + "processNode": { + "type": "string", + "nullable": true + }, + "cpuCores": { + "type": "number", + "nullable": true + }, + "gpuModel": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "manufacturer", + "architecture", + "processNode", + "cpuCores", + "gpuModel" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "modelName", + "soc" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "listing": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "notes": { + "type": "string", + "nullable": true + }, + "upvoteCount": { + "type": "number" + }, + "downvoteCount": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "successRate": { + "type": "number", + "nullable": true + } + }, + "required": [ + "id", + "notes", + "upvoteCount", + "downvoteCount", + "voteCount", + "successRate" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "game_id", + "steam_app_id", + "title", + "performance", + "emulator", + "device", + "listing" + ], + "additionalProperties": true + } + ] + } + }, + "totalRequested": { + "type": "number" + }, + "totalFound": { + "type": "number" + }, + "totalNotFound": { + "type": "number" + } + }, + "required": [ + "success", + "results", + "totalRequested", + "totalFound", + "totalNotFound" + ], + "additionalProperties": true + }, "SearchSuggestionsSchema": { "type": "object", "properties": { "query": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 100 }, "limit": { "type": "number", @@ -1079,6 +1743,7 @@ }, "search": { "type": "string", + "maxLength": 100, "description": "Search listings by game name" } }, @@ -1831,7 +2496,8 @@ ] }, "search": { - "type": "string" + "type": "string", + "maxLength": 100 }, "minMemory": { "type": "number", @@ -3175,7 +3841,7 @@ "$ref": "#/components/schemas/DeleteMobileAccountSchema" }, "example": { - "confirmationText": "example" + "confirmationText": "DELETE" } } }, @@ -3308,8 +3974,8 @@ }, "/catalog.getDeviceCompatibility": { "get": { - "summary": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 10 minutes to reduce server load.", - "description": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 10 minutes to reduce server load.", + "summary": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 15 minutes to reduce server load.", + "description": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 15 minutes to reduce server load.", "tags": [ "catalog" ], @@ -6940,8 +7606,8 @@ }, "/games.batchBySteamAppIds": { "get": { - "summary": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 5 minutes to optimize repeated queries", - "description": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 5 minutes to optimize repeated queries", + "summary": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 15 minutes to optimize repeated queries", + "description": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 15 minutes to optimize repeated queries", "tags": [ "games" ], @@ -6971,8 +7637,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from games.batchBySteamAppIds" + "$ref": "#/components/schemas/BatchBySteamAppIdsResponseSchema" } } } @@ -6987,14 +7652,41 @@ "value": { "result": { "data": { - "message": "Response from games.batchBySteamAppIds", - "data": { - "id": "uuid-game", - "title": "Super Mario Bros", - "systemId": "uuid-system", - "imageUrl": "https://example.com/game.jpg", - "status": "APPROVED" - } + "success": true, + "results": [ + { + "game_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "steam_app_id": "220", + "title": "Half-Life 2", + "performance": { + "id": 1, + "label": "Perfect", + "rank": 1, + "description": null + }, + "emulator": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "GameHub", + "logo": null + }, + "device": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "Steam Deck", + "soc": null + }, + "listing": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "notes": "Runs well", + "upvoteCount": 4, + "downvoteCount": 1, + "voteCount": 5, + "successRate": 0.8 + } + } + ], + "totalRequested": 1, + "totalFound": 1, + "totalNotFound": 0 } } } diff --git a/src/app/admin/components/ApprovalCountBadge.test.tsx b/src/app/admin/components/ApprovalCountBadge.test.tsx index f59d0023..352b7622 100644 --- a/src/app/admin/components/ApprovalCountBadge.test.tsx +++ b/src/app/admin/components/ApprovalCountBadge.test.tsx @@ -1,57 +1,53 @@ import { render, screen } from '@testing-library/react' import { describe, it, expect, beforeEach, vi } from 'vitest' -import { api } from '@/lib/api' import { PERMISSIONS } from '@/utils/permission-system' import ApprovalCountBadge from './ApprovalCountBadge' +interface UserQueryResult { + data?: { + permissions?: string[] | null + } | null +} + +interface StatsQueryResult { + data?: { + pending: number + approved: number + rejected: number + total: number + } +} + +const apiMocks = vi.hoisted(() => ({ + userMeUseQuery: vi.fn<() => UserQueryResult>(), + gamesStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(), + listingsStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(), + pcListingsStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(), +})) + vi.mock('@/lib/api', () => ({ api: { - users: { me: { useQuery: vi.fn() } }, - games: { stats: { useQuery: vi.fn() } }, - listings: { stats: { useQuery: vi.fn() } }, - pcListings: { stats: { useQuery: vi.fn() } }, + users: { me: { useQuery: apiMocks.userMeUseQuery } }, + games: { stats: { useQuery: apiMocks.gamesStatsUseQuery } }, + listings: { stats: { useQuery: apiMocks.listingsStatsUseQuery } }, + pcListings: { stats: { useQuery: apiMocks.pcListingsStatsUseQuery } }, }, })) -const mockUserQuery = vi.mocked(api.users.me.useQuery) -const mockGamesStatsQuery = vi.mocked(api.games.stats.useQuery) -const mockListingsStatsQuery = vi.mocked(api.listings.stats.useQuery) -const mockPcListingsStatsQuery = vi.mocked(api.pcListings.stats.useQuery) - describe('ApprovalCountBadge', () => { beforeEach(() => { vi.clearAllMocks() }) it('renders badge when count is available and user has permission', () => { - mockUserQuery.mockReturnValue({ + apiMocks.userMeUseQuery.mockReturnValue({ data: { permissions: [PERMISSIONS.VIEW_STATISTICS] }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockGamesStatsQuery.mockReturnValue({ + }) + apiMocks.gamesStatsUseQuery.mockReturnValue({ data: { pending: 3, approved: 0, rejected: 0, total: 3 }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockPcListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) + }) + apiMocks.listingsStatsUseQuery.mockReturnValue({}) + apiMocks.pcListingsStatsUseQuery.mockReturnValue({}) render() @@ -60,34 +56,12 @@ describe('ApprovalCountBadge', () => { }) it('returns null when user lacks permission', () => { - mockUserQuery.mockReturnValue({ + apiMocks.userMeUseQuery.mockReturnValue({ data: { permissions: [] }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockGamesStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockPcListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) + }) + apiMocks.gamesStatsUseQuery.mockReturnValue({}) + apiMocks.listingsStatsUseQuery.mockReturnValue({}) + apiMocks.pcListingsStatsUseQuery.mockReturnValue({}) const { container } = render() @@ -95,34 +69,12 @@ describe('ApprovalCountBadge', () => { }) it('returns null for invalid href', () => { - mockUserQuery.mockReturnValue({ + apiMocks.userMeUseQuery.mockReturnValue({ data: { permissions: [PERMISSIONS.VIEW_STATISTICS] }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockGamesStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockPcListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) + }) + apiMocks.gamesStatsUseQuery.mockReturnValue({}) + apiMocks.listingsStatsUseQuery.mockReturnValue({}) + apiMocks.pcListingsStatsUseQuery.mockReturnValue({}) render() expect(screen.queryByRole('status')).not.toBeInTheDocument() }) diff --git a/src/app/admin/components/ApprovalCountBadge.tsx b/src/app/admin/components/ApprovalCountBadge.tsx index ea46c289..a60279f3 100644 --- a/src/app/admin/components/ApprovalCountBadge.tsx +++ b/src/app/admin/components/ApprovalCountBadge.tsx @@ -1,7 +1,7 @@ 'use client' import { Badge } from '@/components/ui' -import { CACHE_DURATIONS, POLLING_INTERVALS } from '@/data/constants' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -30,26 +30,23 @@ export default function ApprovalCountBadge(props: Props) { const gameStatsQuery = api.games.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/games/approvals', - refetchInterval: POLLING_INTERVALS.SHORT, - staleTime: CACHE_DURATIONS.VERY_SHORT, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: CACHE_DURATIONS.SHORT, + refetchOnMount: false, + refetchOnWindowFocus: false, }) const listingStatsQuery = api.listings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/approvals', - refetchInterval: POLLING_INTERVALS.SHORT, - staleTime: CACHE_DURATIONS.VERY_SHORT, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: CACHE_DURATIONS.SHORT, + refetchOnMount: false, + refetchOnWindowFocus: false, }) const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/pc-listing-approvals', - refetchInterval: POLLING_INTERVALS.SHORT, - staleTime: CACHE_DURATIONS.VERY_SHORT, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: CACHE_DURATIONS.SHORT, + refetchOnMount: false, + refetchOnWindowFocus: false, }) const statsMap = { diff --git a/src/app/admin/custom-field-templates/page.test.tsx b/src/app/admin/custom-field-templates/page.test.tsx index a53234f0..28eeffcf 100644 --- a/src/app/admin/custom-field-templates/page.test.tsx +++ b/src/app/admin/custom-field-templates/page.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { CustomFieldType } from '@orm' -import type CustomFieldTemplatesPageComponent from './page' +import CustomFieldTemplatesPage from './page' const apiMocks = vi.hoisted(() => ({ customFieldTemplatesGetUseQuery: vi.fn(), @@ -53,8 +53,6 @@ vi.mock('./components/CustomFieldTemplateFormModal', () => ({ default: () =>
, })) -let CustomFieldTemplatesPage: typeof CustomFieldTemplatesPageComponent - const templates = [ { id: 'template-performance', @@ -95,10 +93,6 @@ const templates = [ ] describe('CustomFieldTemplatesPage', () => { - beforeAll(async () => { - ;({ default: CustomFieldTemplatesPage } = await import('./page')) - }) - beforeEach(() => { vi.clearAllMocks() navigationMocks.searchParams = new URLSearchParams() diff --git a/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx b/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx index f9a513f8..5183c07f 100644 --- a/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx +++ b/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx @@ -14,6 +14,11 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner' import { api } from '@/lib/api' import toast from '@/lib/toast' import { cn } from '@/lib/utils' +import type { + BatchBySteamAppIdsResponse, + BatchGameResult, + MinimalGameResult, +} from '@/schemas/mobile' SyntaxHighlighter.registerLanguage('json', json) @@ -28,14 +33,52 @@ const SAMPLE_STEAM_APP_IDS = `220 80 240` -interface BatchResult { +type BatchResult = BatchBySteamAppIdsResponse['results'][number] + +interface BatchResultDisplay { steamAppId: string - game: { - id: string - title: string - _count: { listings: number } - } | null - matchStrategy: 'metadata' | 'exact' | 'normalized' | 'not_found' + title: string | null + found: boolean + matchStrategy: 'metadata' | 'exact' | 'normalized' | 'not_found' | 'minimal' + listingCount: number | null +} + +function isBatchGameResult(result: BatchResult): result is BatchGameResult { + return 'steamAppId' in result && typeof result.steamAppId === 'string' +} + +function isMinimalGameResult(result: BatchResult): result is MinimalGameResult { + return 'steam_app_id' in result && typeof result.steam_app_id === 'string' +} + +function toBatchResultDisplay(result: BatchResult): BatchResultDisplay { + if (isBatchGameResult(result)) { + return { + steamAppId: result.steamAppId, + title: result.game?.title ?? null, + found: result.game !== null, + matchStrategy: result.matchStrategy, + listingCount: result.game?._count.listings ?? null, + } + } + + if (!isMinimalGameResult(result)) { + return { + steamAppId: 'unknown', + title: null, + found: false, + matchStrategy: 'not_found', + listingCount: null, + } + } + + return { + steamAppId: result.steam_app_id, + title: result.title, + found: result.game_id !== null, + matchStrategy: 'minimal', + listingCount: result.listing ? 1 : null, + } } export function BatchSteamLookup() { @@ -54,7 +97,7 @@ export function BatchSteamLookup() { minimal?: boolean } | null>(null) - const batchLookupQuery = api.mobile.games.batchBySteamAppIds.useQuery( + const batchLookupQuery = api.titleIdTools.batchSteamAppIds.useQuery( queryInput ?? { steamAppIds: [] }, { enabled: queryInput !== null }, ) @@ -62,28 +105,7 @@ export function BatchSteamLookup() { const isLoading = batchLookupQuery.isFetching const responseData = batchLookupQuery.data - // Type guard for successful response - const isSuccessResponse = ( - data: unknown, - ): data is { - success: true - results: BatchResult[] - totalRequested: number - totalFound: number - totalNotFound: number - } => { - return ( - typeof data === 'object' && - data !== null && - 'success' in data && - data.success === true && - 'results' in data && - Array.isArray(data.results) - ) - } - - const isSuccess = isSuccessResponse(responseData) - const results = isSuccess ? responseData.results : [] + const results = responseData?.results ?? [] const parsedIds = useMemo(() => { return steamAppIds @@ -126,10 +148,14 @@ export function BatchSteamLookup() { const textResults = results .map((result) => { - if (!result.game) { - return `${result.steamAppId}: NOT FOUND` - } - return `${result.steamAppId}: ${result.game.title} (${result.matchStrategy}, ${result.game._count.listings} listings)` + const displayResult = toBatchResultDisplay(result) + if (!displayResult.found) return `${displayResult.steamAppId}: NOT FOUND` + + const listingSummary = + displayResult.listingCount === null + ? 'listings unavailable' + : `${displayResult.listingCount} listings` + return `${displayResult.steamAppId}: ${displayResult.title} (${displayResult.matchStrategy}, ${listingSummary})` }) .join('\n') @@ -253,7 +279,7 @@ export function BatchSteamLookup() {
- {isSuccess && responseData && ( + {responseData && (
@@ -330,10 +356,10 @@ export function BatchSteamLookup() { {results.map((result, index) => { - const isFound = result.game !== null + const displayResult = toBatchResultDisplay(result) return ( - {result.steamAppId} + + {displayResult.steamAppId} + - {isFound && result.game ? ( - {result.game.title} + {displayResult.found && displayResult.title ? ( + {displayResult.title} ) : ( Not found @@ -355,26 +383,28 @@ export function BatchSteamLookup() { - {result.matchStrategy} + {displayResult.matchStrategy} - {isFound && result.game ? ( + {displayResult.found && displayResult.listingCount !== null ? ( - {result.game._count.listings} + {displayResult.listingCount} ) : ( - + - )} diff --git a/src/app/api/mobile/trpc/[trpc]/route.ts b/src/app/api/mobile/trpc/[trpc]/route.ts index 5d9b3696..ecfe2b4c 100644 --- a/src/app/api/mobile/trpc/[trpc]/route.ts +++ b/src/app/api/mobile/trpc/[trpc]/route.ts @@ -3,18 +3,42 @@ import { connection, type NextRequest, NextResponse } from 'next/server' import { getCORSHeaders } from '@/lib/cors' import { createMobileTRPCFetchContext } from '@/server/api/mobileContext' import { mobileRouter } from '@/server/api/routers/mobile' +import { getTRPCResponseCacheHeaders, TRPC_PRIVATE_CACHE_CONTROL } from '@/server/api/trpc-cache' // Get CORS headers with additional tRPC headers function getTRPCCorsHeaders(request: NextRequest) { const baseHeaders = getCORSHeaders(request) return { ...baseHeaders, - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-auth-token', - 'Access-Control-Expose-Headers': 'Content-Type', 'Access-Control-Max-Age': '86400', // 24 hours } } +function mergeVaryHeader(existing: string | null, next: string): string { + const values = new Set() + + for (const value of [existing, next]) { + if (!value) continue + + value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .forEach((entry) => values.add(entry)) + } + + return [...values].join(', ') +} + +function setResponseHeader(response: Response, key: string, value: string) { + if (key.toLowerCase() === 'vary') { + response.headers.set(key, mergeVaryHeader(response.headers.get(key), value)) + return + } + + response.headers.set(key, value) +} + // Handle preflight OPTIONS requests export async function OPTIONS(request: NextRequest) { return new NextResponse(null, { @@ -50,16 +74,29 @@ const handler = async (req: NextRequest) => { console.error(`❌ Mobile tRPC failed on ${path ?? ''}: ${error.message}`) } : undefined, - responseMeta() { + responseMeta(opts) { return { - headers: corsHeaders, + headers: { + ...corsHeaders, + ...getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: req.method, + type: opts.type, + info: opts.info, + hasErrors: opts.errors.length > 0, + eagerGeneration: opts.eagerGeneration, + session: opts.ctx?.session, + apiKey: opts.ctx?.apiKey, + headers: opts.ctx?.headers, + }), + }, } }, }) // Ensure CORS headers are set on the response Object.entries(corsHeaders).forEach(([key, value]) => { - response.headers.set(key, value) + setResponseHeader(response, key, value) }) return response @@ -82,6 +119,7 @@ const handler = async (req: NextRequest) => { status: 500, headers: { 'Content-Type': 'application/json', + 'Cache-Control': TRPC_PRIVATE_CACHE_CONTROL, ...corsHeaders, }, }, diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index d89c3841..0f39cf77 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -2,6 +2,7 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import { connection, type NextRequest } from 'next/server' import { appRouter } from '@/server/api/root' import { createAppRouterTRPCContext } from '@/server/api/trpc' +import { getTRPCResponseCacheHeaders } from '@/server/api/trpc-cache' const handler = async (req: NextRequest) => { return fetchRequestHandler({ @@ -15,6 +16,20 @@ const handler = async (req: NextRequest) => { console.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`) } : undefined, + responseMeta(opts) { + return { + headers: getTRPCResponseCacheHeaders({ + endpoint: 'web', + method: req.method, + type: opts.type, + info: opts.info, + hasErrors: opts.errors.length > 0, + eagerGeneration: opts.eagerGeneration, + session: opts.ctx?.session, + headers: opts.ctx?.headers, + }), + } + }, }) } diff --git a/src/lib/api.tsx b/src/lib/api.tsx index 842c547a..dd69d508 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -52,6 +52,7 @@ function createQueryClient() { } const MAX_URL_LENGTH = 2000 +const MAX_TRPC_BATCH_ITEMS = 20 export function TRPCProvider(props: PropsWithChildren) { const [queryClient] = useState(createQueryClient) @@ -64,6 +65,7 @@ export function TRPCProvider(props: PropsWithChildren) { transformer: superjson, headers: () => ({}), maxURLLength: MAX_URL_LENGTH, + maxItems: MAX_TRPC_BATCH_ITEMS, }), ], }), diff --git a/src/lib/cors.test.ts b/src/lib/cors.test.ts index fd85b0d6..3c7f151e 100644 --- a/src/lib/cors.test.ts +++ b/src/lib/cors.test.ts @@ -1,3 +1,4 @@ +import { NextRequest } from 'next/server' import { afterEach, describe, expect, it, vi } from 'vitest' const allowedOrigins = ['https://emuready.com', 'capacitor://localhost'] @@ -124,3 +125,36 @@ describe('isAllowedRequestOrigin', () => { ).toBe(false) }) }) + +describe('getCORSHeaders', () => { + it('allows mobile authentication headers for configured origins', async () => { + const { getCORSHeaders } = await loadCors({ + ALLOWED_ORIGINS: 'capacitor://localhost', + }) + + const request = new NextRequest('https://emuready.com/api/mobile/trpc/games.get', { + headers: { origin: 'capacitor://localhost' }, + }) + const headers = getCORSHeaders(request) + + expect(headers['Access-Control-Allow-Origin']).toBe('capacitor://localhost') + expect(headers['Access-Control-Allow-Headers']).toContain('x-api-key') + expect(headers['Access-Control-Allow-Headers']).toContain('x-auth-token') + expect(headers['Access-Control-Allow-Headers']).toContain('x-trpc-source') + expect(headers['Access-Control-Expose-Headers']).toContain('x-trpc-source') + expect(headers.Vary).toBe('Origin') + }) + + it('does not echo unconfigured origins', async () => { + const { getCORSHeaders } = await loadCors({ + ALLOWED_ORIGINS: 'https://emuready.com', + NEXT_PUBLIC_APP_ENV: 'production', + }) + + const request = new NextRequest('https://emuready.com/api/mobile/trpc/games.get', { + headers: { origin: 'https://attacker.example' }, + }) + + expect(getCORSHeaders(request)['Access-Control-Allow-Origin']).toBe('null') + }) +}) diff --git a/src/lib/cors.ts b/src/lib/cors.ts index 991003ea..67aa64fc 100644 --- a/src/lib/cors.ts +++ b/src/lib/cors.ts @@ -28,6 +28,16 @@ const LOCAL_TEST_ORIGINS = [ 'http://127.0.0.1:3000', ] +const CORS_ALLOWED_METHODS = 'GET, POST, PUT, DELETE, OPTIONS' +const CORS_ALLOWED_HEADERS = [ + 'Content-Type', + 'Authorization', + 'x-api-key', + 'x-auth-token', + 'x-trpc-source', +].join(', ') +const CORS_EXPOSED_HEADERS = ['Content-Type', 'x-trpc-source'].join(', ') + function addMissingOrigins(origins: string[], additionalOrigins: string[]) { for (const origin of additionalOrigins) { if (!origins.includes(origin)) origins.push(origin) @@ -116,9 +126,11 @@ export function getCORSHeaders(request?: NextRequest): Record { console.error('CORS Error: No allowed origins configured in production') return { 'Access-Control-Allow-Origin': 'null', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': CORS_ALLOWED_METHODS, + 'Access-Control-Allow-Headers': CORS_ALLOWED_HEADERS, + 'Access-Control-Expose-Headers': CORS_EXPOSED_HEADERS, 'Access-Control-Allow-Credentials': 'true', + Vary: 'Origin', } } @@ -132,8 +144,10 @@ export function getCORSHeaders(request?: NextRequest): Record { return { 'Access-Control-Allow-Origin': allowOrigin, - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': CORS_ALLOWED_METHODS, + 'Access-Control-Allow-Headers': CORS_ALLOWED_HEADERS, + 'Access-Control-Expose-Headers': CORS_EXPOSED_HEADERS, 'Access-Control-Allow-Credentials': 'true', + Vary: 'Origin', } } diff --git a/src/proxy.test.ts b/src/proxy.test.ts new file mode 100644 index 00000000..7783cb71 --- /dev/null +++ b/src/proxy.test.ts @@ -0,0 +1,73 @@ +import { NextRequest } from 'next/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NextFetchEvent } from 'next/server' + +vi.mock('@clerk/nextjs/server', () => ({ + createRouteMatcher: () => () => false, + clerkMiddleware: + (handler: (auth: { protect: () => Promise }, req: NextRequest) => Promise) => + (req: NextRequest) => + handler({ protect: vi.fn(async () => undefined) }, req), +})) + +async function loadProxy() { + vi.resetModules() + vi.stubEnv('NEXT_PUBLIC_APP_ENV', 'production') + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('PLAYWRIGHT_TEST', '') + vi.stubEnv('DISABLE_RATE_LIMIT', 'true') + vi.stubEnv('NEXT_PUBLIC_ALLOWED_ORIGINS', 'https://emuready.com') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://emuready.com') + + return import('./proxy') +} + +function createMobileTRPCRequest(headers: HeadersInit = {}, method = 'GET') { + return new NextRequest('https://emuready.com/api/mobile/trpc/games.get', { + method, + headers, + }) +} + +const fetchEvent = { waitUntil: vi.fn() } as unknown as NextFetchEvent + +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() +}) + +describe('proxy mobile tRPC origin handling', () => { + it('allows native mobile tRPC requests without browser origin metadata', async () => { + const { proxy } = await loadProxy() + + const response = await proxy(createMobileTRPCRequest(), fetchEvent) + + expect(response.status).toBe(200) + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + }) + + it('rejects mobile tRPC requests that include an untrusted browser origin', async () => { + const { proxy } = await loadProxy() + + const response = await proxy( + createMobileTRPCRequest({ origin: 'https://attacker.example' }), + fetchEvent, + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Access denied. Invalid origin.', + }) + }) + + it('lets mobile CORS preflight reach the route handler', async () => { + const { proxy } = await loadProxy() + + const response = await proxy( + createMobileTRPCRequest({ origin: 'https://attacker.example' }, 'OPTIONS'), + fetchEvent, + ) + + expect(response.status).toBe(200) + }) +}) diff --git a/src/proxy.ts b/src/proxy.ts index 216cc095..2618f564 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -78,6 +78,10 @@ function checkRateLimit(identifier: string): boolean { return true } +function hasRequestOriginMetadata(req: NextRequest): boolean { + return Boolean(req.headers.get('origin') || req.headers.get('referer')) +} + function isValidOrigin(req: NextRequest): boolean { const origin = req.headers.get('origin') const referer = req.headers.get('referer') @@ -105,6 +109,14 @@ function isValidOrigin(req: NextRequest): boolean { return false } +function isMobileTRPCPath(pathname: string): boolean { + return pathname.startsWith('/api/mobile/trpc/') || pathname.startsWith('/api/trpc/mobile.') +} + +function isProtectedTRPCPath(pathname: string): boolean { + return pathname.startsWith('/api/trpc/') || pathname.startsWith('/api/mobile/trpc/') +} + function isSameOriginSource(req: NextRequest, source: string | null): boolean { const sourceOrigin = getOriginFromUrl(source ?? '') if (!sourceOrigin) return false @@ -117,12 +129,11 @@ function isSameOriginSource(req: NextRequest, source: string | null): boolean { function protectTRPCAPI(req: NextRequest): NextResponse | null { const pathname = req.nextUrl.pathname + const isMobileTRPC = isMobileTRPCPath(pathname) - if (pathname.startsWith('/api/mobile/trpc/')) return null + if (!isProtectedTRPCPath(pathname)) return null - if (!pathname.startsWith('/api/trpc/')) return null - - if (pathname.startsWith('/api/trpc/mobile.')) return null + if (isMobileTRPC && req.method === 'OPTIONS') return null const clientId = getClientIdentifier(req) @@ -146,7 +157,11 @@ function protectTRPCAPI(req: NextRequest): NextResponse | null { ) } - if (!IS_AUTOMATED_TEST_ENVIRONMENT && !isValidOrigin(req)) { + const hasInvalidOrigin = + !IS_AUTOMATED_TEST_ENVIRONMENT && + (isMobileTRPC ? hasRequestOriginMetadata(req) && !isValidOrigin(req) : !isValidOrigin(req)) + + if (hasInvalidOrigin) { console.warn( `Invalid origin for client: ${clientId}, origin: ${req.headers.get('origin')}, referer: ${req.headers.get('referer')}, path: ${pathname}`, ) diff --git a/src/schemas/mobile.ts b/src/schemas/mobile.ts index 36d99a91..3dc8e370 100644 --- a/src/schemas/mobile.ts +++ b/src/schemas/mobile.ts @@ -4,6 +4,14 @@ import { CreateListingBaseSchema, CreatePcListingBaseSchema } from '@/schemas/li import { PaginationResultSchema } from '@/schemas/pagination' import { ReportReason, ReportStatus, PcOs, CustomFieldType, NotificationType } from '@orm' +const MOBILE_SEARCH_QUERY_MAX_LENGTH = 100 +const MOBILE_GAME_NAME_MAX_LENGTH = 120 +const MOBILE_EMULATOR_NAME_MAX_LENGTH = 100 +const MOBILE_DEVICE_MODEL_MAX_LENGTH = 120 +const MOBILE_DEVICE_BRAND_MAX_LENGTH = 80 +const MOBILE_SYSTEM_FILTER_LIMIT = 100 +const CATALOG_MIN_LISTING_COUNT_MAX = 100 + // Type-safe custom field value schema using discriminated union const CustomFieldValueSchema = z.discriminatedUnion('type', [ z.object({ @@ -70,38 +78,56 @@ export const GetListingsByGameSchema = z.object({ }) export const SearchGamesSchema = z.object({ - query: z.string().min(1), + query: z.string().min(1).max(MOBILE_SEARCH_QUERY_MAX_LENGTH), }) export const FindSwitchTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), maxResults: z.number().min(1).max(20).default(5), }) export const GetBestSwitchTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), }) export const GetSwitchGamesStatsMobileSchema = z.object({}).optional() export const FindThreeDsTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), maxResults: z.number().min(1).max(20).default(5), }) export const GetBestThreeDsTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), }) export const GetThreeDsGamesStatsMobileSchema = z.object({}).optional() export const FindSteamAppIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), maxResults: z.number().min(1).max(20).default(5), }) export const GetBestSteamAppIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), }) export const GetSteamGamesStatsMobileSchema = z.object({}).nullish() @@ -112,7 +138,11 @@ export const BatchBySteamAppIdsSchema = z.object({ .min(1, 'At least one Steam App ID is required') .max(1000, 'Maximum 1000 Steam App IDs per request') .describe('Steam App IDs to lookup (1-1000)'), - emulatorName: z.string().optional().describe('Filter listings by emulator name'), + emulatorName: z + .string() + .max(MOBILE_EMULATOR_NAME_MAX_LENGTH) + .optional() + .describe('Filter listings by emulator name'), maxListingsPerGame: z .number() .min(1) @@ -126,6 +156,148 @@ export const BatchBySteamAppIdsSchema = z.object({ .describe('Return minimal response with only essential fields'), }) +const BatchSteamSocSchema = z + .object({ + id: z.string(), + name: z.string(), + manufacturer: z.string().nullable(), + architecture: z.string().nullable(), + processNode: z.string().nullable(), + cpuCores: z.number().nullable(), + gpuModel: z.string().nullable(), + }) + .passthrough() + +const BatchSteamDeviceSchema = z + .object({ + id: z.string(), + modelName: z.string(), + soc: BatchSteamSocSchema.nullable(), + }) + .passthrough() + +const BatchSteamEmulatorSchema = z + .object({ + id: z.string(), + name: z.string(), + logo: z.string().nullable(), + }) + .passthrough() + +const BatchSteamPerformanceSchema = z + .object({ + id: z.number(), + label: z.string(), + rank: z.number(), + description: z.string().nullable(), + }) + .passthrough() + +const BatchSteamListingSummarySchema = z + .object({ + id: z.string().nullable(), + notes: z.string().nullable(), + upvoteCount: z.number(), + downvoteCount: z.number(), + voteCount: z.number(), + successRate: z.number().nullable(), + }) + .passthrough() + +const BatchSteamListingSchema = BatchSteamListingSummarySchema.extend({ + id: z.string(), + deviceId: z.string(), + gameId: z.string(), + emulatorId: z.string(), + performanceId: z.number(), + device: BatchSteamDeviceSchema, + emulator: BatchSteamEmulatorSchema, + performance: BatchSteamPerformanceSchema, + customFieldValues: z.array( + z + .object({ + id: z.string(), + listingId: z.string(), + customFieldDefinitionId: z.string(), + value: JsonValueSchema, + customFieldDefinition: z + .object({ + id: z.string(), + type: z.string(), + label: z.string(), + name: z.string(), + }) + .passthrough(), + }) + .passthrough(), + ), +}).passthrough() + +const BatchSteamGameSchema = z + .object({ + id: z.string(), + title: z.string(), + normalizedTitle: z.string().nullable().optional(), + systemId: z.string(), + imageUrl: z.string().nullable(), + boxartUrl: z.string().nullable(), + bannerUrl: z.string().nullable(), + tgdbGameId: z.number().nullable(), + metadata: z.unknown(), + isErotic: z.boolean(), + ageRating: z.string().nullable().optional(), + status: z.string(), + createdAt: z.date(), + system: z + .object({ + id: z.string(), + name: z.string(), + key: z.string().nullable(), + }) + .passthrough(), + _count: z + .object({ + listings: z.number(), + }) + .passthrough(), + listings: z.array(BatchSteamListingSchema), + }) + .passthrough() + +export const BatchSteamFullResultSchema = z + .object({ + steamAppId: z.string(), + game: BatchSteamGameSchema.nullable(), + matchStrategy: z.enum(['metadata', 'exact', 'normalized', 'not_found']), + }) + .passthrough() + +export const BatchSteamMinimalResultSchema = z + .object({ + game_id: z.string().nullable(), + steam_app_id: z.string(), + title: z.string().nullable(), + performance: BatchSteamPerformanceSchema.nullable(), + emulator: BatchSteamEmulatorSchema.nullable(), + device: BatchSteamDeviceSchema.nullable(), + listing: BatchSteamListingSummarySchema.nullable(), + }) + .passthrough() + +export const BatchBySteamAppIdsResponseSchema = z + .object({ + success: z.literal(true), + results: z.array(z.union([BatchSteamFullResultSchema, BatchSteamMinimalResultSchema])), + totalRequested: z.number(), + totalFound: z.number(), + totalNotFound: z.number(), + }) + .passthrough() + +export type BatchGameResult = z.output +export type MinimalGameResult = z.output +export type BatchBySteamAppIdsResponse = z.output + export const GetListingCommentsSchema = z.object({ listingId: z.string().uuid(), }) @@ -215,7 +387,11 @@ export const GetListingsSchema = z .array(z.union([z.number(), z.string().transform(Number)])) .optional() .describe('Filter by performance IDs'), - search: z.string().optional().describe('Search listings by game name'), + search: z + .string() + .max(MOBILE_SEARCH_QUERY_MAX_LENGTH) + .optional() + .describe('Search listings by game name'), }) .optional() .describe('Get listings with optional filters and pagination') @@ -224,7 +400,7 @@ export type GetListingsInput = z.infer export const GetGamesSchema = z .object({ - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), systemId: z.string().uuid().optional(), page: z .number() @@ -269,7 +445,11 @@ export type GetGamesResponse = z.infer export const GetDevicesSchema = z .object({ - search: z.string().optional().describe('Search devices by name'), + search: z + .string() + .max(MOBILE_SEARCH_QUERY_MAX_LENGTH) + .optional() + .describe('Search devices by name'), brandId: z.string().uuid().optional().describe('Filter by brand ID'), limit: z.number().min(1).max(1000).default(50).describe('Number of results to return (1-1000)'), }) @@ -278,7 +458,7 @@ export const GetDevicesSchema = z export const GetEmulatorsSchema = z.object({ systemId: z.string().uuid().optional(), - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), limit: z.number().min(1).max(100).default(50), }) @@ -314,7 +494,7 @@ export const UpdateNotificationPreferenceMobileSchema = z.object({ }) export const SearchSuggestionsSchema = z.object({ - query: z.string().min(1), + query: z.string().min(1).max(MOBILE_SEARCH_QUERY_MAX_LENGTH), limit: z.number().min(1).max(20).default(10), }) @@ -404,7 +584,7 @@ export const GetPcListingsSchema = z.object({ gpuId: z.string().uuid().optional(), emulatorId: z.string().uuid().optional(), os: z.nativeEnum(PcOs).optional(), - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), minMemory: z.number().min(1).max(256).optional(), maxMemory: z.number().min(1).max(256).optional(), }) @@ -442,7 +622,7 @@ export const MobileAdminGetStatsSchema = z.object({}).optional() export const MobileAdminGetPendingListingsSchema = z .object({ - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), page: z .number() .min(1) @@ -464,7 +644,7 @@ export const MobileAdminRejectListingSchema = z.object({ export const MobileAdminGetPendingGamesSchema = z .object({ - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), page: z .number() .min(1) @@ -533,10 +713,19 @@ export const MobileAdminUpdateUserBanSchema = z.object({ export const GetDeviceCompatibilitySchema = z .object({ deviceId: z.string().uuid().optional().describe('Device UUID to fetch compatibility data for'), - deviceModelName: z.string().optional().describe('Device model name (e.g., "Pocket 5")'), - deviceBrandName: z.string().optional().describe('Device brand name (e.g., "Retroid")'), + deviceModelName: z + .string() + .max(MOBILE_DEVICE_MODEL_MAX_LENGTH) + .optional() + .describe('Device model name (e.g., "Pocket 5")'), + deviceBrandName: z + .string() + .max(MOBILE_DEVICE_BRAND_MAX_LENGTH) + .optional() + .describe('Device brand name (e.g., "Retroid")'), systemIds: z .array(z.string().uuid()) + .max(MOBILE_SYSTEM_FILTER_LIMIT) .optional() .describe('Filter results to specific system IDs'), includeEmulatorBreakdown: z @@ -546,6 +735,7 @@ export const GetDeviceCompatibilitySchema = z minListingCount: z .number() .min(0) + .max(CATALOG_MIN_LISTING_COUNT_MAX) .default(1) .describe('Minimum number of listings required to include a system'), }) diff --git a/src/scripts/api/generate-api-docs.ts b/src/scripts/api/generate-api-docs.ts index f7203131..c65bba3f 100644 --- a/src/scripts/api/generate-api-docs.ts +++ b/src/scripts/api/generate-api-docs.ts @@ -37,6 +37,48 @@ function createGenericResponse(routerName: string, procedureName: string): unkno } } +function getResponseExampleOverride(outputSchemaName: string | undefined): unknown | null { + if (outputSchemaName !== 'BatchBySteamAppIdsResponseSchema') return null + + return { + success: true, + results: [ + { + game_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + steam_app_id: '220', + title: 'Half-Life 2', + performance: { + id: 1, + label: 'Perfect', + rank: 1, + description: null, + }, + emulator: { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + name: 'GameHub', + logo: null, + }, + device: { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + modelName: 'Steam Deck', + soc: null, + }, + listing: { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + notes: 'Runs well', + upvoteCount: 4, + downvoteCount: 1, + voteCount: 5, + successRate: 0.8, + }, + }, + ], + totalRequested: 1, + totalFound: 1, + totalNotFound: 0, + } +} + function getBaseItemStructure(routerName: string): Record { const structures: Record> = { games: { @@ -185,6 +227,8 @@ function resolveReferencedSchema(jsonSchema: Record): Record): unknown { + if (schema.const !== undefined) return schema.const + const propType = schema.type as string | undefined const format = schema.format as string | undefined @@ -571,9 +615,11 @@ function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { type: 'object', description: `Response data from ${routerInfo.router}.${procedure.name}`, } - const responseExample = outputJsonSchema - ? generateExampleFromSchema(outputJsonSchema) - : createGenericResponse(routerInfo.router, procedure.name) + const responseExample = + getResponseExampleOverride(outputSchemaName) ?? + (outputJsonSchema + ? generateExampleFromSchema(outputJsonSchema) + : createGenericResponse(routerInfo.router, procedure.name)) // Build security requirement const security = procedure.auth === 'protected' ? [{ ClerkAuth: [] }] : [] @@ -727,7 +773,7 @@ API documentation for the mobile-compatible public integration surface built wit ## tRPC HTTP Method Conventions -NOTE: the protected routes require authentication via Clerk JWT token in the Authorization header. This isn't implemented yet. +Protected routes require authentication via Clerk JWT token in the Authorization header. Public integration requests can also include an issued API key in \`x-api-key\`; invalid explicit API keys are rejected. tRPC uses HTTP method semantics with fetchRequestHandler: - **Queries** use **GET** requests with input as query parameter @@ -742,7 +788,7 @@ All input schemas are defined in the **components/schemas** section. When you se \`\`\`bash # Query: Get games with search and limit (GET with SuperJSON wrapped input) # Schema: See components/schemas/GetGamesSchema -curl -X GET "https://www.emuready.com/api/mobile/trpc/games.getGames?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D" \\ +curl -X GET "https://www.emuready.com/api/mobile/trpc/games.get?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D" \\ -H "Content-Type: application/json" # Query: Get popular games (GET, no input required) @@ -750,7 +796,7 @@ curl -X GET "https://www.emuready.com/api/mobile/trpc/games.getPopularGames" \\ -H "Content-Type: application/json" # Query: Get listings with filters (GET with SuperJSON wrapped input) -curl -X GET "https://www.emuready.com/api/mobile/trpc/listings.getListings?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D" \\ +curl -X GET "https://www.emuready.com/api/mobile/trpc/listings.get?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D" \\ -H "Content-Type: application/json" # Mutation: Create listing (POST with request body) @@ -799,7 +845,7 @@ All responses are wrapped in a tRPC result object: "data": { "code": "BAD_REQUEST", "httpStatus": 400, - "path": "games.getGames" + "path": "games.get" } } } @@ -876,7 +922,7 @@ This API provides endpoints for: }, path: { type: 'string', - description: 'tRPC procedure path (e.g., "games.getGames")', + description: 'tRPC procedure path (e.g., "games.get")', }, zodError: { type: 'object', @@ -961,7 +1007,7 @@ function generateMarkdownDocs(openApiSpec: ReturnType vi.fn()) + +vi.mock('@/server/services/api-access.service', () => ({ + ApiAccessService: vi.fn().mockImplementation(function MockApiAccessService() { + return { + authorize: authorizeApiKeyMock, + } + }), +})) + +const { createMobileTRPCFetchContext } = await import('./mobileContext') + +function createFetchContextOptions(headers: HeadersInit = {}): FetchCreateContextFnOptions { + const req = new Request('https://www.emuready.com/api/mobile/trpc/games.get', { headers }) + + return { + req, + resHeaders: new Headers(), + info: { + accept: null, + type: 'query', + isBatchCall: false, + calls: [], + connectionParams: null, + signal: req.signal, + url: new URL(req.url), + }, + } +} + +describe('createMobileTRPCFetchContext', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('allows anonymous public mobile requests when no API key is provided', async () => { + const context = await createMobileTRPCFetchContext(createFetchContextOptions()) + + expect(context.session).toBeNull() + expect(context.apiKey).toBeNull() + expect(authorizeApiKeyMock).not.toHaveBeenCalled() + }) + + it('rejects an explicit invalid API key instead of treating it as anonymous', async () => { + authorizeApiKeyMock.mockResolvedValue(null) + + await expect( + createMobileTRPCFetchContext(createFetchContextOptions({ 'x-api-key': 'invalid-key' })), + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'Invalid API key', + }) + + expect(authorizeApiKeyMock).toHaveBeenCalledWith('invalid-key') + }) +}) diff --git a/src/server/api/mobileContext.ts b/src/server/api/mobileContext.ts index 9e3c86b7..69707a54 100644 --- a/src/server/api/mobileContext.ts +++ b/src/server/api/mobileContext.ts @@ -120,7 +120,10 @@ async function resolveApiKey(headers: Headers): Promise { if (!rawKey) return null const apiAccessService = new ApiAccessService(prisma) - return apiAccessService.authorize(rawKey) + const apiKey = await apiAccessService.authorize(rawKey) + if (!apiKey) AppError.unauthorized('Invalid API key') + + return apiKey } async function resolveClerkSessionFromHeaders(headers: Headers): Promise { diff --git a/src/server/api/routers/admin/titleIdTools.ts b/src/server/api/routers/admin/titleIdTools.ts index 76e40919..389f9b96 100644 --- a/src/server/api/routers/admin/titleIdTools.ts +++ b/src/server/api/routers/admin/titleIdTools.ts @@ -1,5 +1,6 @@ import { AppError } from '@/lib/errors' import { logger } from '@/lib/logger' +import { BatchBySteamAppIdsResponseSchema, BatchBySteamAppIdsSchema } from '@/schemas/mobile' import { TitleIdSearchInputSchema, TitleIdStatsInputSchema, @@ -7,6 +8,7 @@ import { TitleIdStatsSchema, } from '@/schemas/titleId' import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc' +import { lookupGamesBySteamAppIds } from '@/server/services/steam-batch-lookup.service' import { getBestTitleIdResult, getTitleIdProvider, @@ -67,4 +69,14 @@ export const titleIdToolsRouter = createTRPCRouter({ return AppError.internalError('Failed to fetch title ID statistics') } }), + + batchSteamAppIds: titleIdAccessProcedure + .input(BatchBySteamAppIdsSchema) + .output(BatchBySteamAppIdsResponseSchema) + .query(async ({ ctx, input }) => { + return lookupGamesBySteamAppIds(input, { + prisma: ctx.prisma, + showNsfw: ctx.session.user.showNsfw, + }) + }), }) diff --git a/src/server/api/routers/mobile/catalog.ts b/src/server/api/routers/mobile/catalog.ts index 74a101ab..ae65a3c6 100644 --- a/src/server/api/routers/mobile/catalog.ts +++ b/src/server/api/routers/mobile/catalog.ts @@ -18,7 +18,7 @@ export const mobileCatalogRouter = createMobileTRPCRouter({ * - Community votes (Wilson score) * - Developer verifications * - * Results are cached for 10 minutes to reduce server load. + * Results are cached for 15 minutes to reduce server load. */ getDeviceCompatibility: mobilePublicProcedure .input(GetDeviceCompatibilitySchema) diff --git a/src/server/api/routers/mobile/games.ts b/src/server/api/routers/mobile/games.ts index ae7c00ea..d1e5749b 100644 --- a/src/server/api/routers/mobile/games.ts +++ b/src/server/api/routers/mobile/games.ts @@ -13,12 +13,12 @@ import { GetBestSteamAppIdMobileSchema, GetSteamGamesStatsMobileSchema, BatchBySteamAppIdsSchema, + BatchBySteamAppIdsResponseSchema, type GetGamesResponse, } from '@/schemas/mobile' import { createMobileTRPCRouter, mobilePublicProcedure } from '@/server/api/mobileContext' import { GamesRepository } from '@/server/repositories/games.repository' -import { steamBatchQueryCache } from '@/server/utils/cache' -import { matchSteamAppIdsToNames, validateSteamAppIds } from '@/server/utils/steamGameBatcher' +import { lookupGamesBySteamAppIds } from '@/server/services/steam-batch-lookup.service' import { findSteamAppIdForGameName, getBestSteamAppIdMatch, @@ -35,127 +35,6 @@ import { getThreeDsGamesStats, } from '@/server/utils/threeDsGameSearch' -// Type definitions for batch Steam App ID responses -export type BatchGameResult = { - steamAppId: string - game: { - id: string - title: string - systemId: string - imageUrl: string | null - boxartUrl: string | null - bannerUrl: string | null - tgdbGameId: number | null - metadata: unknown - isErotic: boolean - status: string - createdAt: Date - system: { - id: string - name: string - key: string | null - } - _count: { - listings: number - } - listings: { - id: string - deviceId: string - gameId: string - emulatorId: string - performanceId: number - notes: string | null - upvoteCount: number - downvoteCount: number - voteCount: number - successRate: number | null - device: { - id: string - modelName: string - soc: { - id: string - name: string - manufacturer: string | null - architecture: string | null - processNode: string | null - cpuCores: number | null - gpuModel: string | null - } | null - } - emulator: { - id: string - name: string - logo: string | null - } - performance: { - id: number - label: string - rank: number - description: string | null - } - customFieldValues: { - id: string - listingId: string - customFieldDefinitionId: string - value: unknown - customFieldDefinition: { - id: string - type: string - label: string - name: string - } - }[] - }[] - } | null - matchStrategy: 'metadata' | 'exact' | 'normalized' | 'not_found' -} - -export type MinimalGameResult = { - game_id: string | null - steam_app_id: string - title: string | null - performance: { - id: number - label: string - rank: number - description: string | null - } | null - emulator: { - id: string - name: string - logo: string | null - } | null - device: { - id: string - modelName: string - soc: { - id: string - name: string - manufacturer: string | null - architecture: string | null - processNode: string | null - cpuCores: number | null - gpuModel: string | null - } | null - } | null - listing: { - id: string - notes: string | null - upvoteCount: number - downvoteCount: number - voteCount: number - successRate: number | null - } | null -} - -export type BatchBySteamAppIdsResponse = { - success: true - results: BatchGameResult[] | MinimalGameResult[] - totalRequested: number - totalFound: number - totalNotFound: number -} - export const mobileGamesRouter = createMobileTRPCRouter({ /** * Get games with search and filtering @@ -384,103 +263,15 @@ export const mobileGamesRouter = createMobileTRPCRouter({ * Batch lookup games by Steam App IDs * Optimized for large batches (up to 1000 Steam App IDs) * Returns games with their listings filtered by emulator if specified - * Results cached for 5 minutes to optimize repeated queries + * Results cached for 15 minutes to optimize repeated queries */ batchBySteamAppIds: mobilePublicProcedure .input(BatchBySteamAppIdsSchema) + .output(BatchBySteamAppIdsResponseSchema) .query(async ({ ctx, input }) => { - const { - steamAppIds, - emulatorName, - maxListingsPerGame, - showNsfw = false, - minimal = true, - } = input - - try { - // Validate Steam App IDs - const validation = validateSteamAppIds(steamAppIds) - if (!validation.valid) { - return AppError.badRequest(validation.errors.join(', ')) - } - - // Create cache key from sorted IDs and options - const sortedIds = [...steamAppIds].sort().join(',') - const cacheKey = `batch:${sortedIds}:${emulatorName ?? 'all'}:${maxListingsPerGame}:${showNsfw ?? false}:${minimal ?? false}` - - // Check cache first - const cachedResult = steamBatchQueryCache.get(cacheKey) - if (cachedResult) return cachedResult - - // Match Steam App IDs to game names - const matchResults = await matchSteamAppIdsToNames(steamAppIds) - - // Create Map of Steam App ID → Game Name - const steamAppIdToName = new Map() - for (const match of matchResults) { - if (match.gameName) { - steamAppIdToName.set(match.steamAppId, match.gameName) - } - } - - // Batch lookup games from database - const repository = new GamesRepository(ctx.prisma) - const results = await repository.batchBySteamAppIds(steamAppIdToName, { - emulatorName, - maxListingsPerGame, - showNsfw: showNsfw ?? ctx.session?.user?.showNsfw ?? false, - }) - - // Transform to minimal format if requested - const finalResults = minimal - ? results.map((result) => { - if (!result.game || result.game.listings.length === 0) { - return { - game_id: result.game?.id ?? null, - steam_app_id: result.steamAppId, - title: result.game?.title ?? null, - performance: null, - emulator: null, - device: null, - listing: null, - } - } - - const firstListing = result.game.listings[0] - return { - game_id: result.game.id, - steam_app_id: result.steamAppId, - title: result.game.title, - performance: firstListing?.performance ?? null, - emulator: firstListing?.emulator ?? null, - device: firstListing?.device ?? null, - listing: { - id: firstListing?.id ?? null, - notes: firstListing?.notes ?? null, - upvoteCount: firstListing?.upvoteCount ?? 0, - downvoteCount: firstListing?.downvoteCount ?? 0, - voteCount: firstListing?.voteCount ?? 0, - successRate: firstListing?.successRate ?? null, - }, - } - }) - : results - - const response = { - success: true as const, - results: finalResults, - totalRequested: steamAppIds.length, - totalFound: results.filter((r) => r.game !== null).length, - totalNotFound: results.filter((r) => r.game === null).length, - } - - // Cache the result - steamBatchQueryCache.set(cacheKey, response) - - return response - } catch (error) { - console.error('Error in batch Steam App ID lookup:', error) - return AppError.internalError('Failed to lookup games by Steam App IDs') - } + return lookupGamesBySteamAppIds(input, { + prisma: ctx.prisma, + showNsfw: ctx.session?.user?.showNsfw ?? false, + }) }), }) diff --git a/src/server/api/trpc-cache.test.ts b/src/server/api/trpc-cache.test.ts new file mode 100644 index 00000000..5c9c1a65 --- /dev/null +++ b/src/server/api/trpc-cache.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import { + getTRPCResponseCacheHeaders, + TRPC_PRIVATE_CACHE_CONTROL, + TRPC_PUBLIC_LOOKUP_CACHE_CONTROL, +} from './trpc-cache' + +function requestInfo(paths: string[], isBatchCall = false) { + return { + isBatchCall, + calls: paths.map((path) => ({ path })), + } +} + +describe('getTRPCResponseCacheHeaders', () => { + it('publicly caches anonymous mobile catalog GET queries', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility']), + hasErrors: false, + session: null, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PUBLIC_LOOKUP_CACHE_CONTROL) + }) + + it('publicly caches the web mobile compatibility alias only when anonymous', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'web', + method: 'GET', + type: 'query', + info: requestInfo(['mobile.games.batchBySteamAppIds']), + hasErrors: false, + session: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PUBLIC_LOOKUP_CACHE_CONTROL) + }) + + it('publicly caches anonymous web lookup queries that are already client lookup data', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'web', + method: 'GET', + type: 'query', + info: requestInfo(['devices.options']), + hasErrors: false, + session: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PUBLIC_LOOKUP_CACHE_CONTROL) + }) + + it('keeps authenticated requests private even for cacheable procedure paths', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility']), + hasErrors: false, + session: { user: { id: 'user-1' } }, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps requests with auth-capable headers private when no session resolved', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['games.batchBySteamAppIds']), + hasErrors: false, + session: null, + apiKey: null, + headers: new Headers({ authorization: 'Bearer invalid-token' }), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps batched requests private even when every path is individually cacheable', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility', 'games.batchBySteamAppIds'], true), + hasErrors: false, + session: null, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps eagerly generated response metadata private', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility']), + hasErrors: false, + eagerGeneration: true, + session: null, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps POST, mutation, and error responses private', () => { + const baseInput = { + endpoint: 'mobile' as const, + info: requestInfo(['catalog.getDeviceCompatibility']), + session: null, + apiKey: null, + headers: new Headers(), + } + + expect( + getTRPCResponseCacheHeaders({ + ...baseInput, + method: 'POST', + type: 'query', + hasErrors: false, + })['Cache-Control'], + ).toBe(TRPC_PRIVATE_CACHE_CONTROL) + + expect( + getTRPCResponseCacheHeaders({ + ...baseInput, + method: 'GET', + type: 'mutation', + hasErrors: false, + })['Cache-Control'], + ).toBe(TRPC_PRIVATE_CACHE_CONTROL) + + expect( + getTRPCResponseCacheHeaders({ + ...baseInput, + method: 'GET', + type: 'query', + hasErrors: true, + })['Cache-Control'], + ).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) +}) diff --git a/src/server/api/trpc-cache.ts b/src/server/api/trpc-cache.ts new file mode 100644 index 00000000..ef1ea020 --- /dev/null +++ b/src/server/api/trpc-cache.ts @@ -0,0 +1,88 @@ +export const TRPC_PRIVATE_CACHE_CONTROL = 'private, no-store' +export const TRPC_PUBLIC_LOOKUP_CACHE_CONTROL = + 'public, max-age=0, s-maxage=900, stale-while-revalidate=300' + +type Endpoint = 'mobile' | 'web' + +type CachePolicyInput = { + endpoint: Endpoint + method: string + type: string + info: + | { + isBatchCall: boolean + calls: readonly { path: string }[] + } + | undefined + hasErrors: boolean + eagerGeneration?: boolean + session: unknown + apiKey?: unknown + headers?: Headers | null +} + +const mobilePublicProcedureCache = new Map([ + ['catalog.getDeviceCompatibility', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['games.batchBySteamAppIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], +]) + +const webPublicProcedureCache = new Map([ + ['mobile.catalog.getDeviceCompatibility', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['mobile.games.batchBySteamAppIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['cpus.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['cpus.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['gpus.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['gpus.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['devices.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['devices.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['socs.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['socs.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['systems.get', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['emulators.get', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['performanceScales.get', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], +]) + +function getProcedureCachePolicy(endpoint: Endpoint, path: string): string | undefined { + if (endpoint === 'mobile') return mobilePublicProcedureCache.get(path) + + return webPublicProcedureCache.get(path) +} + +function hasAuthCapableHeaders(headers: Headers | null | undefined): boolean { + if (!headers) return false + + return Boolean( + headers.get('authorization') || + headers.get('cookie') || + headers.get('x-api-key') || + headers.get('x-auth-token'), + ) +} + +function getSinglePath( + info: { isBatchCall: boolean; calls: readonly { path: string }[] } | undefined, +): string | null { + if (!info || info.isBatchCall || info.calls.length !== 1) return null + + return info.calls[0]?.path ?? null +} + +export function getTRPCResponseCacheHeaders(input: CachePolicyInput): Record { + const path = getSinglePath(input.info) + const cacheControl = path ? getProcedureCachePolicy(input.endpoint, path) : undefined + + if ( + cacheControl && + input.method === 'GET' && + input.type === 'query' && + !input.hasErrors && + input.eagerGeneration !== true && + !input.session && + !input.apiKey && + !hasAuthCapableHeaders(input.headers) + ) { + return { 'Cache-Control': cacheControl } + } + + return { 'Cache-Control': TRPC_PRIVATE_CACHE_CONTROL } +} diff --git a/src/server/services/catalog.service.test.ts b/src/server/services/catalog.service.test.ts index f2404f9d..17d520cf 100644 --- a/src/server/services/catalog.service.test.ts +++ b/src/server/services/catalog.service.test.ts @@ -54,7 +54,7 @@ const cachedResponse = { }, systems: [], generatedAt: new Date('2026-01-01T00:00:00.000Z'), - cacheExpiresIn: 600, + cacheExpiresIn: 900, } describe('catalog compatibility cache', () => { diff --git a/src/server/services/catalog.service.ts b/src/server/services/catalog.service.ts index 2f5cdcea..2a0af485 100644 --- a/src/server/services/catalog.service.ts +++ b/src/server/services/catalog.service.ts @@ -31,6 +31,8 @@ export interface GetDeviceCompatibilityContext { userId?: string } +const CATALOG_COMPATIBILITY_CACHE_SECONDS = 900 + /** * Get device compatibility scores aggregated by system * @@ -45,7 +47,7 @@ export interface GetDeviceCompatibilityContext { * - When a system has < MINIMUM_DEVICE_LISTINGS (5) on the device, * data from other devices with the same SoC is included * - * Results are cached for 10 minutes to reduce server load. + * Results are cached for 15 minutes to reduce server load. */ export async function getDeviceCompatibility( input: GetDeviceCompatibilityInput, @@ -88,7 +90,7 @@ export async function getDeviceCompatibility( }, systems: [], generatedAt: new Date(), - cacheExpiresIn: 600, + cacheExpiresIn: CATALOG_COMPATIBILITY_CACHE_SECONDS, } } @@ -239,7 +241,7 @@ export async function getDeviceCompatibility( }, systems, generatedAt: new Date(), - cacheExpiresIn: 600, // 10 minutes + cacheExpiresIn: CATALOG_COMPATIBILITY_CACHE_SECONDS, } catalogCompatibilityCache.set(cacheKey, response) diff --git a/src/server/services/steam-batch-lookup.service.test.ts b/src/server/services/steam-batch-lookup.service.test.ts new file mode 100644 index 00000000..bf846a15 --- /dev/null +++ b/src/server/services/steam-batch-lookup.service.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PrismaClient } from '@orm/client' + +const validateSteamAppIdsMock = vi.hoisted(() => vi.fn()) +const matchSteamAppIdsToNamesMock = vi.hoisted(() => vi.fn()) +const batchBySteamAppIdsMock = vi.hoisted(() => vi.fn()) + +const steamBatchQueryCacheMock = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), +})) + +const gamesRepositoryMock = vi.hoisted(() => + vi.fn().mockImplementation(function MockGamesRepository() { + return { + batchBySteamAppIds: batchBySteamAppIdsMock, + } + }), +) + +vi.mock('@/server/utils/cache', () => ({ + steamBatchQueryCache: steamBatchQueryCacheMock, +})) + +vi.mock('@/server/utils/steamGameBatcher', () => ({ + validateSteamAppIds: validateSteamAppIdsMock, + matchSteamAppIdsToNames: matchSteamAppIdsToNamesMock, +})) + +vi.mock('@/server/repositories/games.repository', () => ({ + GamesRepository: gamesRepositoryMock, +})) + +const { lookupGamesBySteamAppIds } = await import('./steam-batch-lookup.service') + +const prisma = {} as PrismaClient + +const gameWithListing = { + id: 'game-1', + title: 'Half-Life 2', + normalizedTitle: 'half life 2', + systemId: 'system-1', + imageUrl: null, + boxartUrl: null, + bannerUrl: null, + tgdbGameId: null, + metadata: { steamAppId: '220' }, + isErotic: false, + ageRating: null, + status: 'APPROVED', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + system: { + id: 'system-1', + name: 'PC', + key: 'pc', + }, + _count: { + listings: 1, + }, + listings: [ + { + id: 'listing-1', + deviceId: 'device-1', + gameId: 'game-1', + emulatorId: 'emulator-1', + performanceId: 1, + notes: 'Runs well', + upvoteCount: 4, + downvoteCount: 1, + voteCount: 5, + successRate: 0.8, + device: { + id: 'device-1', + modelName: 'Pocket', + soc: null, + }, + emulator: { + id: 'emulator-1', + name: 'GameHub', + logo: null, + }, + performance: { + id: 1, + label: 'Perfect', + rank: 1, + description: null, + }, + customFieldValues: [], + }, + ], +} + +describe('lookupGamesBySteamAppIds', () => { + beforeEach(() => { + vi.clearAllMocks() + validateSteamAppIdsMock.mockReturnValue({ valid: true, errors: [] }) + steamBatchQueryCacheMock.get.mockReturnValue(undefined) + }) + + it('rejects invalid Steam App IDs before cache, Steam metadata, or repository work', async () => { + validateSteamAppIdsMock.mockReturnValue({ + valid: false, + errors: ['Invalid Steam App ID format: abc'], + }) + + await expect( + lookupGamesBySteamAppIds( + { + steamAppIds: ['abc'], + maxListingsPerGame: 1, + }, + { prisma }, + ), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Invalid Steam App ID format: abc', + }) + + expect(steamBatchQueryCacheMock.get).not.toHaveBeenCalled() + expect(matchSteamAppIdsToNamesMock).not.toHaveBeenCalled() + expect(gamesRepositoryMock).not.toHaveBeenCalled() + }) + + it('preserves unresolved Steam App IDs as not found results', async () => { + matchSteamAppIdsToNamesMock.mockResolvedValue([ + { steamAppId: '999', gameName: null, matchStrategy: 'not_found' }, + { steamAppId: '220', gameName: 'Half-Life 2', matchStrategy: 'exact' }, + ]) + batchBySteamAppIdsMock.mockResolvedValue([ + { + steamAppId: '220', + game: gameWithListing, + matchStrategy: 'exact', + }, + ]) + + const response = await lookupGamesBySteamAppIds( + { + steamAppIds: ['999', '220'], + emulatorName: 'GameHub', + maxListingsPerGame: 1, + minimal: true, + }, + { prisma, showNsfw: false }, + ) + + expect(response).toMatchObject({ + success: true, + totalRequested: 2, + totalFound: 1, + totalNotFound: 1, + }) + expect(response.results).toEqual([ + { + game_id: null, + steam_app_id: '999', + title: null, + performance: null, + emulator: null, + device: null, + listing: null, + }, + { + game_id: 'game-1', + steam_app_id: '220', + title: 'Half-Life 2', + performance: gameWithListing.listings[0].performance, + emulator: gameWithListing.listings[0].emulator, + device: gameWithListing.listings[0].device, + listing: { + id: 'listing-1', + notes: 'Runs well', + upvoteCount: 4, + downvoteCount: 1, + voteCount: 5, + successRate: 0.8, + }, + }, + ]) + + const repositoryInput = batchBySteamAppIdsMock.mock.calls[0]?.[0] + expect(repositoryInput).toBeInstanceOf(Map) + if (!(repositoryInput instanceof Map)) throw new Error('Expected repository input to be a Map') + + expect(Array.from(repositoryInput.entries())).toEqual([['220', 'Half-Life 2']]) + expect(batchBySteamAppIdsMock).toHaveBeenCalledWith(repositoryInput, { + emulatorName: 'GameHub', + maxListingsPerGame: 1, + showNsfw: false, + }) + expect(steamBatchQueryCacheMock.set).toHaveBeenCalledWith( + 'batch:999,220:GameHub:1:false:true', + response, + ) + }) +}) diff --git a/src/server/services/steam-batch-lookup.service.ts b/src/server/services/steam-batch-lookup.service.ts new file mode 100644 index 00000000..f51d1155 --- /dev/null +++ b/src/server/services/steam-batch-lookup.service.ts @@ -0,0 +1,118 @@ +import { TRPCError } from '@trpc/server' +import { AppError } from '@/lib/errors' +import { logger } from '@/lib/logger' +import { GamesRepository } from '@/server/repositories/games.repository' +import { steamBatchQueryCache } from '@/server/utils/cache' +import { matchSteamAppIdsToNames, validateSteamAppIds } from '@/server/utils/steamGameBatcher' +import type { BatchBySteamAppIdsResponse } from '@/schemas/mobile' +import type { PrismaClient } from '@orm/client' + +interface LookupGamesBySteamAppIdsInput { + steamAppIds: string[] + emulatorName?: string + maxListingsPerGame: number + showNsfw?: boolean + minimal?: boolean +} + +interface LookupGamesBySteamAppIdsContext { + prisma: PrismaClient + showNsfw?: boolean +} + +export async function lookupGamesBySteamAppIds( + input: LookupGamesBySteamAppIdsInput, + ctx: LookupGamesBySteamAppIdsContext, +): Promise { + const validation = validateSteamAppIds(input.steamAppIds) + if (!validation.valid) AppError.badRequest(validation.errors.join(', ')) + + try { + const showNsfw = input.showNsfw ?? ctx.showNsfw ?? false + const minimal = input.minimal ?? false + const requestedIds = input.steamAppIds.join(',') + const cacheKey = `batch:${requestedIds}:${input.emulatorName ?? 'all'}:${input.maxListingsPerGame}:${showNsfw}:${minimal}` + + const cachedResult = steamBatchQueryCache.get(cacheKey) + if (cachedResult) return cachedResult + + const matchResults = await matchSteamAppIdsToNames(input.steamAppIds) + const steamAppIdToName = new Map() + for (const match of matchResults) { + if (match.gameName) steamAppIdToName.set(match.steamAppId, match.gameName) + } + + const repositoryResults = + steamAppIdToName.size > 0 + ? await new GamesRepository(ctx.prisma).batchBySteamAppIds(steamAppIdToName, { + emulatorName: input.emulatorName, + maxListingsPerGame: input.maxListingsPerGame, + showNsfw, + }) + : [] + + const resultsBySteamAppId = new Map( + repositoryResults.map((result) => [result.steamAppId, result]), + ) + const orderedResults = input.steamAppIds.map((steamAppId) => { + const result = resultsBySteamAppId.get(steamAppId) + if (result) return result + + return { + steamAppId, + game: null, + matchStrategy: 'not_found' as const, + } + }) + + const finalResults = minimal + ? orderedResults.map((result) => { + if (!result.game || result.game.listings.length === 0) { + return { + game_id: result.game?.id ?? null, + steam_app_id: result.steamAppId, + title: result.game?.title ?? null, + performance: null, + emulator: null, + device: null, + listing: null, + } + } + + const firstListing = result.game.listings[0] + return { + game_id: result.game.id, + steam_app_id: result.steamAppId, + title: result.game.title, + performance: firstListing?.performance ?? null, + emulator: firstListing?.emulator ?? null, + device: firstListing?.device ?? null, + listing: { + id: firstListing?.id ?? null, + notes: firstListing?.notes ?? null, + upvoteCount: firstListing?.upvoteCount ?? 0, + downvoteCount: firstListing?.downvoteCount ?? 0, + voteCount: firstListing?.voteCount ?? 0, + successRate: firstListing?.successRate ?? null, + }, + } + }) + : orderedResults + + const response = { + success: true as const, + results: finalResults, + totalRequested: input.steamAppIds.length, + totalFound: orderedResults.filter((result) => result.game !== null).length, + totalNotFound: orderedResults.filter((result) => result.game === null).length, + } + + steamBatchQueryCache.set(cacheKey, response) + return response + } catch (error) { + if (error instanceof TRPCError) throw error + + logger.error('Error in batch Steam App ID lookup', error) + return AppError.internalError('Failed to lookup games by Steam App IDs') + } +} diff --git a/src/server/utils/cache/instances.ts b/src/server/utils/cache/instances.ts index 87fe7e06..16bf2178 100644 --- a/src/server/utils/cache/instances.ts +++ b/src/server/utils/cache/instances.ts @@ -1,7 +1,6 @@ import { LRUCache } from 'lru-cache' import { CACHE_DURATIONS } from '@/data/constants' -import type { DeviceCompatibilityResponse } from '@/schemas/mobile' -import type { BatchBySteamAppIdsResponse } from '@/server/api/routers/mobile/games' +import type { BatchBySteamAppIdsResponse, DeviceCompatibilityResponse } from '@/schemas/mobile' import type { NotificationMetrics, ChannelMetrics, From 30a0fcd6c6fd04538fed4c9ad384690e8d213f41 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 7 Jul 2026 18:27:27 +0200 Subject: [PATCH 4/5] test: update mobile context authentication behavior --- src/server/api/mobileContext.test.ts | 69 +++++++++++++++++++++++++++- src/server/api/mobileContext.ts | 35 ++++++++++---- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/server/api/mobileContext.test.ts b/src/server/api/mobileContext.test.ts index 185a4a4f..8352553a 100644 --- a/src/server/api/mobileContext.test.ts +++ b/src/server/api/mobileContext.test.ts @@ -1,7 +1,25 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Role } from '@orm/client' import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' const authorizeApiKeyMock = vi.hoisted(() => vi.fn()) +const verifyTokenMock = vi.hoisted(() => vi.fn()) +const prismaMock = vi.hoisted(() => ({ + user: { + findUnique: vi.fn(), + }, + rolePermission: { + findMany: vi.fn(), + }, +})) + +vi.mock('@clerk/backend', () => ({ + verifyToken: verifyTokenMock, +})) + +vi.mock('@/server/db', () => ({ + prisma: prismaMock, +})) vi.mock('@/server/services/api-access.service', () => ({ ApiAccessService: vi.fn().mockImplementation(function MockApiAccessService() { @@ -44,11 +62,25 @@ describe('createMobileTRPCFetchContext', () => { expect(authorizeApiKeyMock).not.toHaveBeenCalled() }) - it('rejects an explicit invalid API key instead of treating it as anonymous', async () => { + it('ignores an invalid x-api-key so public mobile requests still work anonymously', async () => { + authorizeApiKeyMock.mockResolvedValue(null) + + const context = await createMobileTRPCFetchContext( + createFetchContextOptions({ 'x-api-key': 'invalid-key' }), + ) + + expect(context.session).toBeNull() + expect(context.apiKey).toBeNull() + expect(authorizeApiKeyMock).toHaveBeenCalledWith('invalid-key') + }) + + it('keeps explicit Authorization ApiKey credentials strict', async () => { authorizeApiKeyMock.mockResolvedValue(null) await expect( - createMobileTRPCFetchContext(createFetchContextOptions({ 'x-api-key': 'invalid-key' })), + createMobileTRPCFetchContext( + createFetchContextOptions({ authorization: 'ApiKey invalid-key' }), + ), ).rejects.toMatchObject({ code: 'UNAUTHORIZED', message: 'Invalid API key', @@ -56,4 +88,37 @@ describe('createMobileTRPCFetchContext', () => { expect(authorizeApiKeyMock).toHaveBeenCalledWith('invalid-key') }) + + it('uses a valid Bearer token even when the optional x-api-key is invalid', async () => { + authorizeApiKeyMock.mockResolvedValue(null) + verifyTokenMock.mockResolvedValue({ sub: 'clerk-user-1' }) + prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'user-1' }).mockResolvedValueOnce({ + id: 'user-1', + email: 'user@example.com', + name: 'Test User', + role: Role.USER, + settings: { showNsfw: false }, + }) + prismaMock.rolePermission.findMany.mockResolvedValue([ + { permission: { key: 'view_statistics' } }, + ]) + + const context = await createMobileTRPCFetchContext( + createFetchContextOptions({ + authorization: 'Bearer valid-token', + 'x-api-key': 'stale-app-key', + }), + ) + + expect(context.session?.user.id).toBe('user-1') + expect(context.apiKey).toBeNull() + expect(authorizeApiKeyMock).toHaveBeenCalledWith('stale-app-key') + expect(verifyTokenMock).toHaveBeenCalledWith( + 'valid-token', + expect.objectContaining({ + clockSkewInMs: 60000, + skipJwksCache: false, + }), + ) + }) }) diff --git a/src/server/api/mobileContext.ts b/src/server/api/mobileContext.ts index 69707a54..cc5aeef1 100644 --- a/src/server/api/mobileContext.ts +++ b/src/server/api/mobileContext.ts @@ -33,6 +33,11 @@ type Session = { user: User } +type ApiKeyCredential = { + rawKey: string + source: 'header' | 'authorization' +} + type CreateMobileContextOptions = { session: Nullable apiKey?: ApiKeyWithUser | null @@ -101,29 +106,43 @@ async function createSessionFromApiKey(apiKey: ApiKeyWithUser): Promise { - const rawKey = extractApiKey(headers) - if (!rawKey) return null + const credential = extractApiKey(headers) + if (!credential) return null const apiAccessService = new ApiAccessService(prisma) - const apiKey = await apiAccessService.authorize(rawKey) - if (!apiKey) AppError.unauthorized('Invalid API key') + const apiKey = await apiAccessService.authorize(credential.rawKey) + if (apiKey) return apiKey - return apiKey + if (credential.source === 'authorization') AppError.unauthorized('Invalid API key') + + // x-api-key is optional quota attribution for mobile tRPC; shipped clients may also send Bearer. + return null } async function resolveClerkSessionFromHeaders(headers: Headers): Promise { From 20c5f32eb20aa347431a89c4cd32eba0cfc3989d Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 7 Jul 2026 19:42:17 +0200 Subject: [PATCH 5/5] docs: update mobile API documentation and API key handling --- docs/MOBILE_API.md | 2 +- public/api-docs/mobile-openapi.json | 2 +- src/scripts/api/generate-api-docs.ts | 2 +- src/server/api/mobileContext.ts | 5 ++++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 8aee0644..7472c3fe 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -1,6 +1,6 @@ # EmuReady Public Integration API (mobile-compatible tRPC) -*Auto-generated on: 2026-06-21T17:28:57.453Z* +*Auto-generated on: 2026-07-06T20:07:17.160Z* ## Summary - **Total Endpoints**: 113 diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json index fb75ee27..1109ec88 100644 --- a/public/api-docs/mobile-openapi.json +++ b/public/api-docs/mobile-openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "EmuReady Public Integration API (mobile-compatible tRPC)", - "description": "\n# EmuReady Public Integration tRPC API\n\nAPI documentation for the mobile-compatible public integration surface built with tRPC.\n\n## tRPC HTTP Method Conventions\n\nProtected routes require authentication via Clerk JWT token in the Authorization header. Public integration requests can also include an issued API key in `x-api-key`; invalid explicit API keys are rejected.\n\ntRPC uses HTTP method semantics with fetchRequestHandler:\n- **Queries** use **GET** requests with input as query parameter\n- **Mutations** use **POST** requests with input in request body\n\n### Schema References:\n\nAll input schemas are defined in the **components/schemas** section. When you see a parameter referencing a schema (e.g., GetEmulatorsSchema), check the schemas section for the complete structure with field types, validations, and defaults.\n\n### Usage Examples:\n\n```bash\n# Query: Get games with search and limit (GET with SuperJSON wrapped input)\n# Schema: See components/schemas/GetGamesSchema\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.get?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get popular games (GET, no input required)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getPopularGames\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get listings with filters (GET with SuperJSON wrapped input)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.get?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Mutation: Create listing (POST with request body)\ncurl -X POST \"https://www.emuready.com/api/mobile/trpc/listings.createListing\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\\n -d '{\"gameId\":\"uuid\",\"deviceId\":\"uuid\",\"emulatorId\":\"uuid\",\"performanceId\":\"uuid\"}'\n\n# Protected query with authentication (GET with SuperJSON wrapped input and auth header)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getUserListings?input=%7B%22json%22%3A%7B%22userId%22%3A%22uuid%22%7D%7D\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\"\n```\n\n### Important Notes:\n\n**For Queries (GET requests):**\n✅ Use GET method\n✅ Send input wrapped in SuperJSON format: `{\"json\":{\"field\":\"value\"}}`\n✅ URL-encode the entire JSON string\n✅ Many endpoints have defaults and don't require input\n✅ Input parameter format: `?input={\"json\":{\"field\":\"value\"}}` (URL-encoded)\n\n**For Mutations (POST requests):**\n✅ Use POST method\n✅ Send input as JSON in request body\n✅ Set Content-Type: application/json\n\n### Response Format:\nAll responses are wrapped in a tRPC result object:\n```json\n{\n \"result\": {\n \"data\": /* response data */\n }\n}\n```\n\n### Error Response Format:\n```json\n{\n \"error\": {\n \"json\": {\n \"message\": \"Error message\",\n \"code\": -32600,\n \"data\": {\n \"code\": \"BAD_REQUEST\",\n \"httpStatus\": 400,\n \"path\": \"games.get\"\n }\n }\n }\n}\n```\n\nThis API provides endpoints for:\n- Game emulation listings management\n- User authentication and profiles \n- Device and hardware information\n- Emulator data and compatibility\n- Community features (comments, votes)\n ", + "description": "\n# EmuReady Public Integration tRPC API\n\nAPI documentation for the mobile-compatible public integration surface built with tRPC.\n\n## tRPC HTTP Method Conventions\n\nProtected routes require authentication via Clerk JWT token in the Authorization header. Public integration requests can also include an issued API key in `x-api-key` for attribution and quota tracking. Invalid `Authorization: ApiKey` credentials are rejected.\n\ntRPC uses HTTP method semantics with fetchRequestHandler:\n- **Queries** use **GET** requests with input as query parameter\n- **Mutations** use **POST** requests with input in request body\n\n### Schema References:\n\nAll input schemas are defined in the **components/schemas** section. When you see a parameter referencing a schema (e.g., GetEmulatorsSchema), check the schemas section for the complete structure with field types, validations, and defaults.\n\n### Usage Examples:\n\n```bash\n# Query: Get games with search and limit (GET with SuperJSON wrapped input)\n# Schema: See components/schemas/GetGamesSchema\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.get?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get popular games (GET, no input required)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getPopularGames\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get listings with filters (GET with SuperJSON wrapped input)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.get?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Mutation: Create listing (POST with request body)\ncurl -X POST \"https://www.emuready.com/api/mobile/trpc/listings.createListing\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\\n -d '{\"gameId\":\"uuid\",\"deviceId\":\"uuid\",\"emulatorId\":\"uuid\",\"performanceId\":\"uuid\"}'\n\n# Protected query with authentication (GET with SuperJSON wrapped input and auth header)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getUserListings?input=%7B%22json%22%3A%7B%22userId%22%3A%22uuid%22%7D%7D\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\"\n```\n\n### Important Notes:\n\n**For Queries (GET requests):**\n✅ Use GET method\n✅ Send input wrapped in SuperJSON format: `{\"json\":{\"field\":\"value\"}}`\n✅ URL-encode the entire JSON string\n✅ Many endpoints have defaults and don't require input\n✅ Input parameter format: `?input={\"json\":{\"field\":\"value\"}}` (URL-encoded)\n\n**For Mutations (POST requests):**\n✅ Use POST method\n✅ Send input as JSON in request body\n✅ Set Content-Type: application/json\n\n### Response Format:\nAll responses are wrapped in a tRPC result object:\n```json\n{\n \"result\": {\n \"data\": /* response data */\n }\n}\n```\n\n### Error Response Format:\n```json\n{\n \"error\": {\n \"json\": {\n \"message\": \"Error message\",\n \"code\": -32600,\n \"data\": {\n \"code\": \"BAD_REQUEST\",\n \"httpStatus\": 400,\n \"path\": \"games.get\"\n }\n }\n }\n}\n```\n\nThis API provides endpoints for:\n- Game emulation listings management\n- User authentication and profiles \n- Device and hardware information\n- Emulator data and compatibility\n- Community features (comments, votes)\n ", "version": "1.0.0", "contact": { "name": "EmuReady API Support", diff --git a/src/scripts/api/generate-api-docs.ts b/src/scripts/api/generate-api-docs.ts index c65bba3f..b62925aa 100644 --- a/src/scripts/api/generate-api-docs.ts +++ b/src/scripts/api/generate-api-docs.ts @@ -773,7 +773,7 @@ API documentation for the mobile-compatible public integration surface built wit ## tRPC HTTP Method Conventions -Protected routes require authentication via Clerk JWT token in the Authorization header. Public integration requests can also include an issued API key in \`x-api-key\`; invalid explicit API keys are rejected. +Protected routes require authentication via Clerk JWT token in the Authorization header. Public integration requests can also include an issued API key in \`x-api-key\` for attribution and quota tracking. Invalid \`Authorization: ApiKey\` credentials are rejected. tRPC uses HTTP method semantics with fetchRequestHandler: - **Queries** use **GET** requests with input as query parameter diff --git a/src/server/api/mobileContext.ts b/src/server/api/mobileContext.ts index cc5aeef1..063e680a 100644 --- a/src/server/api/mobileContext.ts +++ b/src/server/api/mobileContext.ts @@ -141,7 +141,10 @@ async function resolveApiKey(headers: Headers): Promise { if (credential.source === 'authorization') AppError.unauthorized('Invalid API key') - // x-api-key is optional quota attribution for mobile tRPC; shipped clients may also send Bearer. + // Temporary EmuReadyApp compatibility: shipped clients may send a stale x-api-key + // together with Bearer auth. Treat it as absent so auth can fall through to + // Bearer/anonymous access, then remove this after the mobile app stops sending it. + // Until then, x-api-key must not be treated as an access-control boundary here. return null }