diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 2abcdaca3..7472c3fe5 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-07-06T20:07:17.160Z* ## 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 affdc29e9..2d2dbc6d3 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 f7f24bab1..1109ec888 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` 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", @@ -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 f59d00230..352b76225 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 ea46c2898..a60279f37 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 a53234f0a..28eeffcf8 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 f9a513f81..5183c07fb 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 5d9b36963..ecfe2b4ca 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 d89c3841f..0f39cf772 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/app/listings/[id]/components/EditListingButton.tsx b/src/app/listings/[id]/components/EditListingButton.tsx index 6136263dd..fa6404a31 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 1871602c4..1201e91c8 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, }, ) diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 14316e79a..d4e17cef5 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 = [ diff --git a/src/lib/api.tsx b/src/lib/api.tsx index 842c547a0..dd69d5083 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 fd85b0d6c..3c7f151ee 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 991003ea9..67aa64fc3 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 000000000..7783cb718 --- /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 216cc0955..2618f564a 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 36d99a912..3dc8e3707 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 f72031315..b62925aaa 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\` 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 @@ -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()) +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() { + 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('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({ authorization: 'ApiKey invalid-key' }), + ), + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'Invalid API key', + }) + + 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 9e3c86b70..063e680a0 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,26 +106,46 @@ 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) - return apiAccessService.authorize(rawKey) + const apiKey = await apiAccessService.authorize(credential.rawKey) + if (apiKey) return apiKey + + if (credential.source === 'authorization') AppError.unauthorized('Invalid API key') + + // 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 } 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 76e409195..389f9b96a 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 74a101ab6..ae65a3c67 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 ae7c00ea5..d1e5749ba 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 000000000..5c9c1a658 --- /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 000000000..ef1ea020e --- /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 f2404f9d0..17d520cf9 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 2f5cdcea8..2a0af485b 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 000000000..bf846a155 --- /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 000000000..f51d11552 --- /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 87fe7e06c..16bf21781 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,