From c750614b442f02ac3a60f9522eebeba3f9c1e6e7 Mon Sep 17 00:00:00 2001 From: Owen <98496129+owens1127@users.noreply.github.com> Date: Sun, 18 Aug 2024 12:25:36 -0400 Subject: [PATCH] add bearer to teammates route (#62) * add bearer to teammates route * fix package * add bearer token to security schema * clean up open api doc * add auth test --- bun.lockb | Bin 312562 -> 312562 bytes open-api/generate.ts | 6 + open-api/openapi.json | 1489 ++++++++++++++--- package.json | 2 +- src/RaidHubRoute.test.ts | 4 +- src/RaidHubRoute.ts | 55 +- src/data-access-layer/history.ts | 80 +- src/middlewares/admin.test.ts | 14 +- src/routes/activity.ts | 2 +- src/routes/admin/query.ts | 2 +- src/routes/authorize/admin.ts | 16 +- src/routes/authorize/user.ts | 16 +- src/routes/leaderboard/individual/global.ts | 2 +- src/routes/leaderboard/individual/pantheon.ts | 4 +- src/routes/leaderboard/individual/raid.ts | 4 +- src/routes/leaderboard/team/contest.ts | 4 +- src/routes/leaderboard/team/first.test.ts | 2 +- src/routes/leaderboard/team/first.ts | 4 +- src/routes/pgcr.ts | 2 +- src/routes/player/index.ts | 2 +- src/routes/player/membershipId/activities.ts | 5 +- src/routes/player/membershipId/basic.ts | 2 +- .../player/membershipId/profile.test.ts | 14 +- src/routes/player/membershipId/profile.ts | 5 +- .../player/membershipId/teammates.test.ts | 25 +- .../{teamates.ts => teammates.ts} | 13 +- src/schema/RaidHubResponse.ts | 37 +- src/util/auth.test.ts | 46 +- src/util/auth.ts | 27 +- 29 files changed, 1458 insertions(+), 426 deletions(-) rename src/routes/player/membershipId/{teamates.ts => teammates.ts} (78%) diff --git a/bun.lockb b/bun.lockb index 6e9da0438b41443f94de0d94eb2d25a4e236f095..6706406286a88d459c40048d77f49119b84cbda7 100755 GIT binary patch delta 175 zcmV;g08sz(i4*dP6Ob+-ia(K*@UNNAg;G2U;Mve>p!%Jd9os;oBj*#|38u=Qu}-?` zlL$93gE;HAIO_ooIzTMsfU5hNMTzP-T-JuS70k?O#cmI?@Z+$JwC|fn@$+Dt3tQh8 zfOUXtcB0~XzYDtkw#_$D0@RJL@C+nk=`DwaB?7mFB?HS51~M)&E;9hPRyPCZ0S5tI dF)lDJHHSnw1Ghvu1h{>dLkI*Qw;ZlIAoM)R+O?7>OTBXe!oo9#5 zUTBx|9d#ZLTt%^(W&JO<=HA*3N=2UB)}4oiB?7mFB?HS523|5QF)lH;RyPCZ0S5vA d000000Ea|51Ghvu1h{>d^b`ajw;Z=~? diff --git a/open-api/generate.ts b/open-api/generate.ts index 164c8b5..d324844 100644 --- a/open-api/generate.ts +++ b/open-api/generate.ts @@ -36,6 +36,12 @@ doc.components!.securitySchemes = { name: "X-API-KEY", in: "header" }, + "Bearer Token": { + type: "http", + name: "Authorization", + scheme: "bearer", + in: "header" + }, "Administrator Token": { type: "http", name: "Authorization", diff --git a/open-api/openapi.json b/open-api/openapi.json index 8633157..81a15d7 100644 --- a/open-api/openapi.json +++ b/open-api/openapi.json @@ -90,46 +90,28 @@ "ApiKeyError": { "type": "object", "properties": { - "minted": { - "type": "string", - "format": "date-time" - }, - "success": { - "type": "boolean", - "enum": [false] - }, - "code": { - "type": "string", - "enum": ["ApiKeyError"] - }, - "error": { - "type": "object", - "properties": { - "message": { - "anyOf": [ - { - "type": "string", - "enum": ["Invalid API Key"] - }, - { - "type": "string", - "enum": ["Missing API Key"] - } - ] - }, - "apiKey": { + "message": { + "anyOf": [ + { "type": "string", - "nullable": true + "enum": ["Invalid API Key"] }, - "origin": { + { "type": "string", - "nullable": true + "enum": ["Missing API Key"] } - }, - "required": ["message", "apiKey", "origin"] + ] + }, + "apiKey": { + "type": "string", + "nullable": true + }, + "origin": { + "type": "string", + "nullable": true } }, - "required": ["minted", "success", "code", "error"] + "required": ["message", "apiKey", "origin"] }, "ZodIssue": { "type": "object", @@ -180,120 +162,48 @@ "BodyValidationError": { "type": "object", "properties": { - "minted": { - "type": "string", - "format": "date-time" - }, - "success": { - "type": "boolean", - "enum": [false] - }, - "code": { - "type": "string", - "enum": ["BodyValidationError"] - }, - "error": { - "type": "object", - "properties": { - "issues": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ZodIssue" - } - } - }, - "required": ["issues"] + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ZodIssue" + } } }, - "required": ["minted", "success", "code", "error"] + "required": ["issues"] }, "InsufficientPermissionsError": { "type": "object", "properties": { - "minted": { - "type": "string", - "format": "date-time" - }, - "success": { - "type": "boolean", - "enum": [false] - }, - "code": { + "message": { "type": "string", - "enum": ["InsufficientPermissionsError"] - }, - "error": { - "type": "object", - "properties": { - "message": { - "type": "string", - "enum": ["Forbidden"] - } - }, - "required": ["message"] + "enum": ["Forbidden"] } }, - "required": ["minted", "success", "code", "error"] + "required": ["message"] }, "PathValidationError": { "type": "object", "properties": { - "minted": { - "type": "string", - "format": "date-time" - }, - "success": { - "type": "boolean", - "enum": [false] - }, - "code": { - "type": "string", - "enum": ["PathValidationError"] - }, - "error": { - "type": "object", - "properties": { - "issues": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ZodIssue" - } - } - }, - "required": ["issues"] + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ZodIssue" + } } }, - "required": ["minted", "success", "code", "error"] + "required": ["issues"] }, "QueryValidationError": { "type": "object", "properties": { - "minted": { - "type": "string", - "format": "date-time" - }, - "success": { - "type": "boolean", - "enum": [false] - }, - "code": { - "type": "string", - "enum": ["QueryValidationError"] - }, - "error": { - "type": "object", - "properties": { - "issues": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ZodIssue" - } - } - }, - "required": ["issues"] + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ZodIssue" + } } }, - "required": ["minted", "success", "code", "error"] + "required": ["issues"] }, "DestinyMembershipType": { "type": "integer", @@ -1586,6 +1496,26 @@ "required": ["membershipId", "nextCursor", "activities"], "additionalProperties": false }, + "PlayerNotFoundError": { + "type": "object", + "properties": { + "membershipId": { + "type": "string", + "pattern": "^\\d+n?$" + } + }, + "required": ["membershipId"] + }, + "PlayerPrivateProfileError": { + "type": "object", + "properties": { + "membershipId": { + "type": "string", + "pattern": "^\\d+n?$" + } + }, + "required": ["membershipId"] + }, "PlayerBasicResponse": { "type": "object", "properties": { @@ -1724,6 +1654,16 @@ } ] }, + "InstanceNotFoundError": { + "type": "object", + "properties": { + "instanceId": { + "type": "string", + "pattern": "^\\d+n?$" + } + }, + "required": ["instanceId"] + }, "LeaderboardIndividualGlobalResponse": { "oneOf": [ { @@ -1788,6 +1728,16 @@ } ] }, + "PlayerNotOnLeaderboardError": { + "type": "object", + "properties": { + "membershipId": { + "type": "string", + "pattern": "^\\d+n?$" + } + }, + "required": ["membershipId"] + }, "LeaderboardIndividualRaidResponse": { "oneOf": [ { @@ -1852,6 +1802,15 @@ } ] }, + "RaidNotFoundError": { + "type": "object", + "properties": { + "raid": { + "type": "string" + } + }, + "required": ["raid"] + }, "LeaderboardIndividualPantheonResponse": { "oneOf": [ { @@ -1916,6 +1875,15 @@ } ] }, + "PantheonVersionNotFoundError": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": ["path"] + }, "LeaderboardTeamFirstResponse": { "oneOf": [ { @@ -1980,6 +1948,18 @@ } ] }, + "InvalidActivityVersionComboError": { + "type": "object", + "properties": { + "activity": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": ["activity", "version"] + }, "LeaderboardTeamContestResponse": { "oneOf": [ { @@ -2288,6 +2268,16 @@ "url": "https://bungie-net.github.io/multi/schema_Destiny-HistoricalStats-DestinyPostGameCarnageReportData.html#schema_Destiny-HistoricalStats-DestinyPostGameCarnageReportData" } }, + "PGCRNotFoundError": { + "type": "object", + "properties": { + "instanceId": { + "type": "string", + "pattern": "^\\d+n?$" + } + }, + "required": ["instanceId"] + }, "AdminQueryResponse": { "oneOf": [ { @@ -2346,6 +2336,26 @@ } ] }, + "AdminQuerySyntaxError": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "string" + }, + "line": { + "type": "string" + }, + "position": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + } + }, + "required": ["name", "code", "line", "position"] + }, "AuthorizeAdminResponse": { "type": "object", "properties": { @@ -2359,6 +2369,10 @@ }, "required": ["value", "expires"] }, + "InvalidClientSecretError": { + "type": "object", + "properties": {} + }, "AuthorizeUserResponse": { "type": "object", "properties": { @@ -2380,6 +2394,12 @@ "name": "X-API-KEY", "in": "header" }, + "Bearer Token": { + "type": "http", + "name": "Authorization", + "scheme": "bearer", + "in": "header" + }, "Administrator Token": { "type": "http", "name": "Authorization", @@ -2402,7 +2422,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -2422,7 +2443,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2492,7 +2531,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -2512,7 +2552,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["QueryValidationError"] + }, + "error": { + "$ref": "#/components/schemas/QueryValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2522,7 +2580,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2534,6 +2610,11 @@ "get": { "summary": "/player/{membershipId}/activities", "description": "Get a player's activity history. This endpoint uses date cursors to paginate through a player's activity history. \nThe first request should not include a cursor. Subsequent requests should include the `nextCursor` \nvalue from the previous response. Note that the first request may not return the full number of activities requested\nin order to optimize performance. Subsequent requests will return the full number of activities requested.", + "security": [ + { + "Bearer Token": [] + } + ], "parameters": [ { "schema": { @@ -2574,7 +2655,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -2594,7 +2676,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["QueryValidationError"] + }, + "error": { + "$ref": "#/components/schemas/QueryValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2604,7 +2704,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2616,12 +2734,23 @@ "schema": { "type": "object", "properties": { - "membershipId": { + "minted": { "type": "string", - "pattern": "^\\d+n?$" - } + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PlayerPrivateProfileError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerPrivateProfileError" + } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] } } } @@ -2635,15 +2764,44 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotFoundError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -2677,7 +2835,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -2697,7 +2856,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2711,15 +2888,44 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotFoundError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -2733,6 +2939,11 @@ "get": { "summary": "/player/{membershipId}/profile", "description": "Get a player's profile information. This includes global stats, activity stats, and world first entries. \nThis is used to hydrate the RaidHub profile page", + "security": [ + { + "Bearer Token": [] + } + ], "parameters": [ { "schema": { @@ -2753,7 +2964,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -2773,7 +2985,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2785,12 +3015,23 @@ "schema": { "type": "object", "properties": { - "membershipId": { + "minted": { "type": "string", - "pattern": "^\\d+n?$" + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PlayerPrivateProfileError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerPrivateProfileError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] } } } @@ -2804,15 +3045,44 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotFoundError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -2825,7 +3095,12 @@ "/player/{membershipId}/teammates": { "get": { "summary": "/player/{membershipId}/teammates", - "description": "", + "description": "Get a list of a player's top 100 teammates.", + "security": [ + { + "Bearer Token": [] + } + ], "parameters": [ { "schema": { @@ -2846,7 +3121,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -2866,7 +3142,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2878,12 +3172,23 @@ "schema": { "type": "object", "properties": { - "membershipId": { + "minted": { "type": "string", - "pattern": "^\\d+n?$" + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PlayerPrivateProfileError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerPrivateProfileError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] } } } @@ -2897,15 +3202,44 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotFoundError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -2939,7 +3273,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -2959,7 +3294,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -2973,15 +3326,44 @@ { "type": "object", "properties": { - "instanceId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["InstanceNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/InstanceNotFoundError" } }, - "required": ["instanceId"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -3053,7 +3435,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3073,7 +3456,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["QueryValidationError"] + }, + "error": { + "$ref": "#/components/schemas/QueryValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3083,7 +3484,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3097,15 +3516,44 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotOnLeaderboardError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotOnLeaderboardError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -3179,7 +3627,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3199,7 +3648,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["QueryValidationError"] + }, + "error": { + "$ref": "#/components/schemas/QueryValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3209,7 +3676,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3223,24 +3708,65 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { "type": "string", - "pattern": "^\\d+n?$" + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PlayerNotOnLeaderboardError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotOnLeaderboardError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { "type": "object", "properties": { - "raid": { - "type": "string" + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["RaidNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/RaidNotFoundError" } }, - "required": ["raid"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -3314,7 +3840,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3334,17 +3861,53 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryValidationError" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["QueryValidationError"] + }, + "error": { + "$ref": "#/components/schemas/QueryValidationError" + } + }, + "required": ["minted", "success", "code", "error"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3358,24 +3921,65 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotOnLeaderboardError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotOnLeaderboardError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { "type": "object", "properties": { - "path": { - "type": "string" + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PantheonVersionNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/PantheonVersionNotFoundError" } }, - "required": ["path"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -3448,7 +4052,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3468,7 +4073,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["QueryValidationError"] + }, + "error": { + "$ref": "#/components/schemas/QueryValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3478,7 +4101,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3492,27 +4133,65 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotOnLeaderboardError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotOnLeaderboardError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { "type": "object", "properties": { - "activity": { - "type": "string" + "minted": { + "type": "string", + "format": "date-time" }, - "version": { - "type": "string" + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["InvalidActivityVersionComboError"] + }, + "error": { + "$ref": "#/components/schemas/InvalidActivityVersionComboError" } }, - "required": ["activity", "version"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -3577,7 +4256,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3597,7 +4277,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["QueryValidationError"] + }, + "error": { + "$ref": "#/components/schemas/QueryValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3607,7 +4305,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3621,24 +4337,65 @@ { "type": "object", "properties": { - "membershipId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PlayerNotOnLeaderboardError"] + }, + "error": { + "$ref": "#/components/schemas/PlayerNotOnLeaderboardError" } }, - "required": ["membershipId"] + "required": ["minted", "success", "code", "error"] }, { "type": "object", "properties": { - "raid": { - "type": "string" + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["RaidNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/RaidNotFoundError" } }, - "required": ["raid"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -3672,7 +4429,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3692,7 +4450,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3706,15 +4482,44 @@ { "type": "object", "properties": { - "instanceId": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { "type": "string", - "pattern": "^\\d+n?$" + "enum": ["PGCRNotFoundError"] + }, + "error": { + "$ref": "#/components/schemas/PGCRNotFoundError" } }, - "required": ["instanceId"] + "required": ["minted", "success", "code", "error"] }, { - "$ref": "#/components/schemas/PathValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["PathValidationError"] + }, + "error": { + "$ref": "#/components/schemas/PathValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } ] } @@ -3765,7 +4570,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3785,7 +4591,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BodyValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["BodyValidationError"] + }, + "error": { + "$ref": "#/components/schemas/BodyValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3795,7 +4619,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3805,7 +4647,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InsufficientPermissionsError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["InsufficientPermissionsError"] + }, + "error": { + "$ref": "#/components/schemas/InsufficientPermissionsError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3817,22 +4677,23 @@ "schema": { "type": "object", "properties": { - "name": { - "type": "string" + "minted": { + "type": "string", + "format": "date-time" }, - "code": { - "type": "string" + "success": { + "type": "boolean", + "enum": [false] }, - "line": { - "type": "string" + "code": { + "type": "string", + "enum": ["AdminQuerySyntaxError"] }, - "position": { - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true + "error": { + "$ref": "#/components/schemas/AdminQuerySyntaxError" } }, - "required": ["name", "code", "line", "position"] + "required": ["minted", "success", "code", "error"] } } } @@ -3872,7 +4733,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3892,7 +4754,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BodyValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["BodyValidationError"] + }, + "error": { + "$ref": "#/components/schemas/BodyValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3902,7 +4782,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3913,7 +4811,24 @@ "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["InvalidClientSecretError"] + }, + "error": { + "$ref": "#/components/schemas/InvalidClientSecretError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3964,7 +4879,8 @@ "type": "object", "properties": { "minted": { - "type": "string" + "type": "string", + "format": "date-time" }, "success": { "type": "boolean", @@ -3984,7 +4900,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BodyValidationError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["BodyValidationError"] + }, + "error": { + "$ref": "#/components/schemas/BodyValidationError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -3994,7 +4928,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyError" + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] } } } @@ -4005,7 +4957,24 @@ "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["InvalidClientSecretError"] + }, + "error": { + "$ref": "#/components/schemas/InvalidClientSecretError" + } + }, + "required": ["minted", "success", "code", "error"] } } } diff --git a/package.json b/package.json index 105efb9..f7d9a64 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@typescript-eslint/eslint-plugin": "^6.19.1", "@typescript-eslint/parser": "^6.19.1", "bun-types": "^1.0.35", - "bungie-net-core": "^2.1.1", + "bungie-net-core": "2.1.3", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", diff --git a/src/RaidHubRoute.test.ts b/src/RaidHubRoute.test.ts index 6e20dee..be3cc44 100644 --- a/src/RaidHubRoute.test.ts +++ b/src/RaidHubRoute.test.ts @@ -40,14 +40,14 @@ const testGetRoute = new RaidHubRoute({ }, errors: [ { - code: ErrorCode.PlayerNotFoundError, + type: ErrorCode.PlayerNotFoundError, statusCode: 404, schema: z.object({ playerId: zBigIntString() }) }, { - code: ErrorCode.InstanceNotFoundError, + type: ErrorCode.InstanceNotFoundError, statusCode: 404, schema: z.object({ activityId: zBigIntString() diff --git a/src/RaidHubRoute.ts b/src/RaidHubRoute.ts index 5e3fb23..d6e8c6f 100644 --- a/src/RaidHubRoute.ts +++ b/src/RaidHubRoute.ts @@ -4,7 +4,7 @@ import { IncomingHttpHeaders } from "http" import { ZodObject, ZodType, ZodTypeAny, ZodUnknown, z } from "zod" import { RaidHubRouter } from "./RaidHubRouter" import { IRaidHubRoute, RaidHubHandler, RaidHubHandlerReturn } from "./RaidHubRouterTypes" -import { RaidHubResponse, registerResponse } from "./schema/RaidHubResponse" +import { RaidHubResponse, registerError, registerResponse } from "./schema/RaidHubResponse" import { zApiKeyError } from "./schema/errors/ApiKeyError" import { zBodyValidationError } from "./schema/errors/BodyValidationError" import { ErrorCode } from "./schema/errors/ErrorCode" @@ -42,14 +42,15 @@ export class RaidHubRoute< readonly querySchema: Query | null readonly bodySchema: Body | null readonly responseSchema: ResponseBody - readonly errors: [ - 400 | 401 | 403 | 404 | 501 | 503, - type: ErrorType, + readonly errors: { + statusCode: 400 | 401 | 403 | 404 | 501 | 503 + type: ErrorType schema: ErrorResponseBody[number] - ][] + }[] readonly successCode: 200 | 201 | 207 private parent: RaidHubRouter | null = null private readonly isAdministratorRoute: boolean = false + private readonly isProtectedPlayerRoute: boolean = false private readonly middlewares: RequestHandler< z.infer, any, @@ -74,6 +75,7 @@ export class RaidHubRoute< query?: Query body?: Body isAdministratorRoute?: boolean + isProtectedPlayerRoute?: boolean middleware?: RequestHandler, any, z.infer, z.infer>[] handler: RaidHubHandler< Params, @@ -90,7 +92,7 @@ export class RaidHubRoute< } errors?: { statusCode: 400 | 401 | 403 | 404 | 501 | 503 - code: ErrorType + type: ErrorType schema: ErrorResponseBody[number] }[] } @@ -105,10 +107,11 @@ export class RaidHubRoute< this.querySchema = args.query ?? null this.bodySchema = args.body ?? null this.isAdministratorRoute = args.isAdministratorRoute ?? false + this.isProtectedPlayerRoute = args.isProtectedPlayerRoute ?? false this.middlewares = args.middleware ?? [] this.handler = args.handler this.responseSchema = args.response.success.schema - this.errors = args.response.errors?.map(e => [e.statusCode, e.code, e.schema]) ?? [] + this.errors = args.response.errors ?? [] this.successCode = args.response.success.statusCode } @@ -227,9 +230,9 @@ export class RaidHubRoute< if (result.success) { res.status(this.successCode).json(response) } else { - res.status(this.errors.find(([_, type]) => type === result.code)![0]).json( - response - ) + res.status( + this.errors.find(({ type }) => type === result.code)!.statusCode + ).json(response) } } catch (e) { next(e) @@ -295,16 +298,12 @@ export class RaidHubRoute< const path = this.getFullPath().replace(/\/:(\w+)/g, "/{$1}") const allResponses = [ - [ - this.successCode, - "Success", - z.object({ - minted: z.date(), - success: z.literal(true), - response: registerResponse(path, this.responseSchema) - }) - ], - ...this.errors, + [this.successCode, "Success", registerResponse(path, this.responseSchema)], + ...this.errors.map(({ statusCode, schema, type }) => [ + statusCode, + type, + registerError(type, schema) + ]), [401, "Unauthorized", zApiKeyError], this.isAdministratorRoute ? [403, "Forbidden", zInsufficientPermissionsError] : null, this.paramsSchema ? [404, "Not found", zPathValidationError] : null, @@ -321,13 +320,19 @@ export class RaidHubRoute< } }) - const security = this.isAdministratorRoute + const security = this.isProtectedPlayerRoute ? [ { - "Administrator Token": [] + "Bearer Token": [] } ] - : undefined + : this.isAdministratorRoute + ? [ + { + "Administrator Token": [] + } + ] + : undefined return [ { @@ -404,13 +409,13 @@ export class RaidHubRoute< ? z.never() : this.errors.length > 1 ? z.union( - this.errors.map(([, , schema]) => schema) as unknown as readonly [ + this.errors.map(err => err.schema) as unknown as readonly [ ZodTypeAny, ZodTypeAny, ...ZodTypeAny[] ] ) - : this.errors[0][2] + : this.errors[0].schema return { type: "err", parsed: schema.parse(res.error) diff --git a/src/data-access-layer/history.ts b/src/data-access-layer/history.ts index dc9c1e4..42bd6f2 100644 --- a/src/data-access-layer/history.ts +++ b/src/data-access-layer/history.ts @@ -22,46 +22,52 @@ export const getActivities = async ( cursor: String(!!cursor), cutoff: String(!!cutoff) }, - () => - postgres.queryRows( - `SELECT - instance_id::text AS "instanceId", - hash::text AS "hash", - activity_id AS "activityId", - version_id AS "versionId", - activity.completed AS "completed", - player_count AS "playerCount", - score AS "score", - fresh AS "fresh", - flawless AS "flawless", - date_started AS "dateStarted", - date_completed AS "dateCompleted", - duration AS "duration", - platform_type AS "platformType", - date_completed < COALESCE(day_one_end, TIMESTAMP 'epoch') AS "isDayOne", - date_completed < COALESCE(contest_end, TIMESTAMP 'epoch') AS "isContest", - date_completed < COALESCE(week_one_end, TIMESTAMP 'epoch') AS "isWeekOne", - JSONB_BUILD_OBJECT( - 'completed', activity_player.completed, - 'sherpas', activity_player.sherpas, - 'isFirstClear', activity_player.is_first_clear, - 'timePlayedSeconds', activity_player.time_played_seconds - ) as player - FROM activity_player - INNER JOIN activity USING (instance_id) - INNER JOIN activity_hash USING (hash) - INNER JOIN activity_definition ON activity_definition.id = activity_hash.activity_id - WHERE membership_id = $1::bigint - AND date_completed < $2 - ${cutoff ? "AND date_completed > $4" : ""} - ORDER BY date_completed DESC - LIMIT $3;`, + async () => { + const params: (string | number | bigint | Date)[] = [membershipId, count] + if (cursor) params.push(cursor) + if (cutoff) params.push(cutoff) + + return await postgres.queryRows( + `SELECT * FROM ( + SELECT + instance_id::text AS "instanceId", + hash::text AS "hash", + activity_id AS "activityId", + version_id AS "versionId", + activity.completed AS "completed", + player_count AS "playerCount", + score AS "score", + fresh AS "fresh", + flawless AS "flawless", + date_started AS "dateStarted", + date_completed AS "dateCompleted", + duration AS "duration", + platform_type AS "platformType", + date_completed < COALESCE(day_one_end, TIMESTAMP 'epoch') AS "isDayOne", + date_completed < COALESCE(contest_end, TIMESTAMP 'epoch') AS "isContest", + date_completed < COALESCE(week_one_end, TIMESTAMP 'epoch') AS "isWeekOne", + JSONB_BUILD_OBJECT( + 'completed', activity_player.completed, + 'sherpas', activity_player.sherpas, + 'isFirstClear', activity_player.is_first_clear, + 'timePlayedSeconds', activity_player.time_played_seconds + ) as player + FROM activity_player + INNER JOIN activity USING (instance_id) + INNER JOIN activity_hash USING (hash) + INNER JOIN activity_definition ON activity_definition.id = activity_hash.activity_id + WHERE membership_id = $1::bigint + AND date_completed < ${cursor ? "$3" : "(SELECT last_seen + interval '1 second' FROM player WHERE membership_id = $1::bigint)"} + ${cutoff ? `AND date_completed > ${cursor ? "$4" : "$3"}` : ""} + ORDER BY date_completed DESC + ${!cutoff ? "LIMIT $2" : ""} + ) as __inner__ + LIMIT $2;`, { - params: cutoff - ? [membershipId, cursor ?? new Date(), count, cutoff] - : [membershipId, cursor ?? new Date(), count], + params: params, fetchCount: count } ) + } ) } diff --git a/src/middlewares/admin.test.ts b/src/middlewares/admin.test.ts index 655c138..d8c34a6 100644 --- a/src/middlewares/admin.test.ts +++ b/src/middlewares/admin.test.ts @@ -27,12 +27,14 @@ describe("admin protected", () => { }) test("should return 200 if valid authorization is provided", async () => { - const token = generateJWT({ - isAdmin: true, - bungieMembershipId: "123", - destinyMembershipIds: [], - durationSeconds: 600 - }) + const token = generateJWT( + { + isAdmin: true, + bungieMembershipId: "123", + destinyMembershipIds: [] + }, + 600 + ) const res = await request(app) .get("/admin") diff --git a/src/routes/activity.ts b/src/routes/activity.ts index e08dc1d..5dd8046 100644 --- a/src/routes/activity.ts +++ b/src/routes/activity.ts @@ -22,7 +22,7 @@ export const activityRoute = new RaidHubRoute({ errors: [ { statusCode: 404, - code: ErrorCode.InstanceNotFoundError, + type: ErrorCode.InstanceNotFoundError, schema: z.object({ instanceId: zBigIntString() }) diff --git a/src/routes/admin/query.ts b/src/routes/admin/query.ts index 8605b9c..6d250eb 100644 --- a/src/routes/admin/query.ts +++ b/src/routes/admin/query.ts @@ -38,7 +38,7 @@ export const adminQueryRoute = new RaidHubRoute({ }, errors: [ { - code: ErrorCode.AdminQuerySyntaxError, + type: ErrorCode.AdminQuerySyntaxError, statusCode: 501, schema: z.object({ name: z.string(), diff --git a/src/routes/authorize/admin.ts b/src/routes/authorize/admin.ts index 8d49353..e41407b 100644 --- a/src/routes/authorize/admin.ts +++ b/src/routes/authorize/admin.ts @@ -24,7 +24,7 @@ export const adminAuthorizationRoute = new RaidHubRoute({ errors: [ { statusCode: 403, - code: ErrorCode.InvalidClientSecretError, + type: ErrorCode.InvalidClientSecretError, schema: z.object({}) } ] @@ -32,12 +32,14 @@ export const adminAuthorizationRoute = new RaidHubRoute({ async handler({ body }) { if (body.adminClientSecret === process.env.ADMIN_CLIENT_SECRET) { return RaidHubRoute.ok({ - value: generateJWT({ - bungieMembershipId: body.bungieMembershipId, - isAdmin: true, - destinyMembershipIds: [], - durationSeconds: TOKEN_EXPIRY - }), + value: generateJWT( + { + bungieMembershipId: body.bungieMembershipId, + isAdmin: true, + destinyMembershipIds: [] + }, + TOKEN_EXPIRY + ), expires: new Date(Date.now() + TOKEN_EXPIRY * 1000) }) } else { diff --git a/src/routes/authorize/user.ts b/src/routes/authorize/user.ts index c385cb4..7e13306 100644 --- a/src/routes/authorize/user.ts +++ b/src/routes/authorize/user.ts @@ -25,7 +25,7 @@ export const userAuthorizationRoute = new RaidHubRoute({ errors: [ { statusCode: 403, - code: ErrorCode.InvalidClientSecretError, + type: ErrorCode.InvalidClientSecretError, schema: z.object({}) } ] @@ -33,12 +33,14 @@ export const userAuthorizationRoute = new RaidHubRoute({ async handler({ body }) { if (body.clientSecret === process.env.CLIENT_SECRET) { return RaidHubRoute.ok({ - value: generateJWT({ - bungieMembershipId: body.bungieMembershipId, - destinyMembershipIds: body.destinyMembershipIds.map(String), - isAdmin: false, - durationSeconds: TOKEN_EXPIRY - }), + value: generateJWT( + { + bungieMembershipId: body.bungieMembershipId, + destinyMembershipIds: body.destinyMembershipIds.map(String), + isAdmin: false + }, + TOKEN_EXPIRY + ), expires: new Date(Date.now() + TOKEN_EXPIRY * 1000) }) } else { diff --git a/src/routes/leaderboard/individual/global.ts b/src/routes/leaderboard/individual/global.ts index 1f8544b..9ee2fa0 100644 --- a/src/routes/leaderboard/individual/global.ts +++ b/src/routes/leaderboard/individual/global.ts @@ -34,7 +34,7 @@ export const leaderboardIndividualGlobalRoute = new RaidHubRoute({ errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotOnLeaderboardError, + type: ErrorCode.PlayerNotOnLeaderboardError, schema: z.object({ membershipId: zBigIntString() }) diff --git a/src/routes/leaderboard/individual/pantheon.ts b/src/routes/leaderboard/individual/pantheon.ts index 816e77e..f581e9b 100644 --- a/src/routes/leaderboard/individual/pantheon.ts +++ b/src/routes/leaderboard/individual/pantheon.ts @@ -35,14 +35,14 @@ export const leaderboardIndividualPantheonRoute = new RaidHubRoute({ errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotOnLeaderboardError, + type: ErrorCode.PlayerNotOnLeaderboardError, schema: z.object({ membershipId: zBigIntString() }) }, { statusCode: 404, - code: ErrorCode.PantheonVersionNotFoundError, + type: ErrorCode.PantheonVersionNotFoundError, schema: z.object({ path: z.string() }) diff --git a/src/routes/leaderboard/individual/raid.ts b/src/routes/leaderboard/individual/raid.ts index b207fb0..e88d7ef 100644 --- a/src/routes/leaderboard/individual/raid.ts +++ b/src/routes/leaderboard/individual/raid.ts @@ -35,14 +35,14 @@ export const leaderboardIndividualRaidRoute = new RaidHubRoute({ errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotOnLeaderboardError, + type: ErrorCode.PlayerNotOnLeaderboardError, schema: z.object({ membershipId: zBigIntString() }) }, { statusCode: 404, - code: ErrorCode.RaidNotFoundError, + type: ErrorCode.RaidNotFoundError, schema: z.object({ raid: z.string() }) diff --git a/src/routes/leaderboard/team/contest.ts b/src/routes/leaderboard/team/contest.ts index a44b5d5..3177eb0 100644 --- a/src/routes/leaderboard/team/contest.ts +++ b/src/routes/leaderboard/team/contest.ts @@ -22,14 +22,14 @@ export const leaderboardTeamContestRoute = new RaidHubRoute({ errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotOnLeaderboardError, + type: ErrorCode.PlayerNotOnLeaderboardError, schema: z.object({ membershipId: zBigIntString() }) }, { statusCode: 404, - code: ErrorCode.RaidNotFoundError, + type: ErrorCode.RaidNotFoundError, schema: z.object({ raid: z.string() }) diff --git a/src/routes/leaderboard/team/first.test.ts b/src/routes/leaderboard/team/first.test.ts index 1e95283..88186e9 100644 --- a/src/routes/leaderboard/team/first.test.ts +++ b/src/routes/leaderboard/team/first.test.ts @@ -30,7 +30,7 @@ describe("first leaderboard 200", () => { t( { activity: "leviathan", - version: "guided" + version: "prestige" }, { count: 14, diff --git a/src/routes/leaderboard/team/first.ts b/src/routes/leaderboard/team/first.ts index 1af129b..79d3ba1 100644 --- a/src/routes/leaderboard/team/first.ts +++ b/src/routes/leaderboard/team/first.ts @@ -24,14 +24,14 @@ Use the /contest endpoint instead to get the full rankings for the duration of t errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotOnLeaderboardError, + type: ErrorCode.PlayerNotOnLeaderboardError, schema: z.object({ membershipId: zBigIntString() }) }, { statusCode: 404, - code: ErrorCode.InvalidActivityVersionComboError, + type: ErrorCode.InvalidActivityVersionComboError, schema: z.object({ activity: z.string(), version: z.string() diff --git a/src/routes/pgcr.ts b/src/routes/pgcr.ts index 66e7312..087333a 100644 --- a/src/routes/pgcr.ts +++ b/src/routes/pgcr.ts @@ -30,7 +30,7 @@ Useful if you need to access PGCRs when Bungie's API is down.`, errors: [ { statusCode: 404, - code: ErrorCode.PGCRNotFoundError, + type: ErrorCode.PGCRNotFoundError, schema: z.object({ instanceId: zBigIntString() }) diff --git a/src/routes/player/index.ts b/src/routes/player/index.ts index dbde1d8..c06185f 100644 --- a/src/routes/player/index.ts +++ b/src/routes/player/index.ts @@ -2,7 +2,7 @@ import { RaidHubRouter } from "../../RaidHubRouter" import { playerActivitiesRoute } from "./membershipId/activities" import { playerBasicRoute } from "./membershipId/basic" import { playerProfileRoute } from "./membershipId/profile" -import { playerTeammatesRoute } from "./membershipId/teamates" +import { playerTeammatesRoute } from "./membershipId/teammates" import { playerSearchRoute } from "./search" export const playerRouter = new RaidHubRouter({ diff --git a/src/routes/player/membershipId/activities.ts b/src/routes/player/membershipId/activities.ts index f09c8a7..6fc9713 100644 --- a/src/routes/player/membershipId/activities.ts +++ b/src/routes/player/membershipId/activities.ts @@ -13,6 +13,7 @@ export const playerActivitiesRoute = new RaidHubRoute({ The first request should not include a cursor. Subsequent requests should include the \`nextCursor\` value from the previous response. Note that the first request may not return the full number of activities requested in order to optimize performance. Subsequent requests will return the full number of activities requested.`, + isProtectedPlayerRoute: true, params: z.object({ membershipId: zBigIntString() }), @@ -35,14 +36,14 @@ in order to optimize performance. Subsequent requests will return the full numbe errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotFoundError, + type: ErrorCode.PlayerNotFoundError, schema: z.object({ membershipId: zBigIntString() }) }, { statusCode: 403, - code: ErrorCode.PlayerPrivateProfileError, + type: ErrorCode.PlayerPrivateProfileError, schema: z.object({ membershipId: zBigIntString() }) diff --git a/src/routes/player/membershipId/basic.ts b/src/routes/player/membershipId/basic.ts index 9b62fd5..b2f0a6a 100644 --- a/src/routes/player/membershipId/basic.ts +++ b/src/routes/player/membershipId/basic.ts @@ -24,7 +24,7 @@ you only have the membershipId available.`, errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotFoundError, + type: ErrorCode.PlayerNotFoundError, schema: z.object({ membershipId: zBigIntString() }) diff --git a/src/routes/player/membershipId/profile.test.ts b/src/routes/player/membershipId/profile.test.ts index 268cc11..13b94f5 100644 --- a/src/routes/player/membershipId/profile.test.ts +++ b/src/routes/player/membershipId/profile.test.ts @@ -44,12 +44,14 @@ describe("player profile 403", () => { }) describe("player profile authorized", () => { - const token = generateJWT({ - isAdmin: false, - bungieMembershipId: "123", - destinyMembershipIds: ["4611686018467346804"], - durationSeconds: 600 - }) + const token = generateJWT( + { + isAdmin: false, + bungieMembershipId: "123", + destinyMembershipIds: ["4611686018467346804"] + }, + 600 + ) playerProfileRoute .$mock({ diff --git a/src/routes/player/membershipId/profile.ts b/src/routes/player/membershipId/profile.ts index 5ddd19a..ccc27c1 100644 --- a/src/routes/player/membershipId/profile.ts +++ b/src/routes/player/membershipId/profile.ts @@ -17,6 +17,7 @@ export const playerProfileRoute = new RaidHubRoute({ method: "get", description: `Get a player's profile information. This includes global stats, activity stats, and world first entries. This is used to hydrate the RaidHub profile page`, + isProtectedPlayerRoute: true, params: z.object({ membershipId: zBigIntString() }), @@ -28,14 +29,14 @@ This is used to hydrate the RaidHub profile page`, errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotFoundError, + type: ErrorCode.PlayerNotFoundError, schema: z.object({ membershipId: zBigIntString() }) }, { statusCode: 403, - code: ErrorCode.PlayerPrivateProfileError, + type: ErrorCode.PlayerPrivateProfileError, schema: z.object({ membershipId: zBigIntString() }) diff --git a/src/routes/player/membershipId/teammates.test.ts b/src/routes/player/membershipId/teammates.test.ts index 7589055..bdc137b 100644 --- a/src/routes/player/membershipId/teammates.test.ts +++ b/src/routes/player/membershipId/teammates.test.ts @@ -1,5 +1,6 @@ +import { generateJWT } from "../../../util/auth" import { cleanupPostgresAfterAll, expectErr, expectOk } from "../../testUtil" -import { playerTeammatesRoute } from "./teamates" +import { playerTeammatesRoute } from "./teammates" cleanupPostgresAfterAll() @@ -40,3 +41,25 @@ describe("teammates 404", () => { test("1", () => t("1")) }) + +describe("teammates authorized", () => { + const token = generateJWT( + { + isAdmin: false, + bungieMembershipId: "123", + destinyMembershipIds: ["4611686018467346804"] + }, + 600 + ) + + playerTeammatesRoute + .$mock({ + params: { + membershipId: "4611686018467346804" + }, + headers: { + authorization: `Bearer ${token}` + } + }) + .then(result => expectOk(result)) +}) diff --git a/src/routes/player/membershipId/teamates.ts b/src/routes/player/membershipId/teammates.ts similarity index 78% rename from src/routes/player/membershipId/teamates.ts rename to src/routes/player/membershipId/teammates.ts index 6c25363..e27336f 100644 --- a/src/routes/player/membershipId/teamates.ts +++ b/src/routes/player/membershipId/teammates.ts @@ -6,10 +6,12 @@ import { cacheControl } from "../../../middlewares/cache-control" import { zTeammate } from "../../../schema/components/Teammate" import { ErrorCode } from "../../../schema/errors/ErrorCode" import { zBigIntString } from "../../../schema/util" +import { canAccessPrivateProfile } from "../../../util/auth" export const playerTeammatesRoute = new RaidHubRoute({ method: "get", - description: ``, + description: `Get a list of a player's top 100 teammates.`, + isProtectedPlayerRoute: true, params: z.object({ membershipId: zBigIntString() }), @@ -21,14 +23,14 @@ export const playerTeammatesRoute = new RaidHubRoute({ errors: [ { statusCode: 404, - code: ErrorCode.PlayerNotFoundError, + type: ErrorCode.PlayerNotFoundError, schema: z.object({ membershipId: zBigIntString() }) }, { statusCode: 403, - code: ErrorCode.PlayerPrivateProfileError, + type: ErrorCode.PlayerPrivateProfileError, schema: z.object({ membershipId: zBigIntString() }) @@ -43,7 +45,10 @@ export const playerTeammatesRoute = new RaidHubRoute({ if (!player) { return RaidHubRoute.fail(ErrorCode.PlayerNotFoundError, { membershipId }) - } else if (player.isPrivate) { + } else if ( + player.isPrivate && + !(await canAccessPrivateProfile(membershipId, req.headers.authorization ?? "")) + ) { return RaidHubRoute.fail(ErrorCode.PlayerPrivateProfileError, { membershipId }) } diff --git a/src/schema/RaidHubResponse.ts b/src/schema/RaidHubResponse.ts index dac34d2..a6bf07c 100644 --- a/src/schema/RaidHubResponse.ts +++ b/src/schema/RaidHubResponse.ts @@ -25,26 +25,27 @@ export const zRaidHubResponse = registry.register( ) export const registerResponse = (path: string, schema: ZodType) => - registry.register( - path - .replace(/\/{[^/]+}/g, "") - .split("/") - .filter(Boolean) - .map(str => str.charAt(0).toUpperCase() + str.slice(1)) - .join("") + "Response", - schema - ) + z.object({ + minted: zISODateString(), + success: z.literal(true), + response: registry.register( + path + .replace(/\/{[^/]+}/g, "") + .split("/") + .filter(Boolean) + .map(str => str.charAt(0).toUpperCase() + str.slice(1)) + .join("") + "Response", + schema + ) + }) export const registerError = (code: ErrorCode, schema: ZodObject) => - registry.register( - code, - z.object({ - minted: zISODateString(), - success: z.literal(false), - code: z.literal(code), - error: schema - }) - ) + z.object({ + minted: zISODateString(), + success: z.literal(false), + code: z.literal(code), + error: registry.register(code, schema) + }) export type RaidHubResponse = { minted: Date diff --git a/src/util/auth.test.ts b/src/util/auth.test.ts index 315a8eb..cb2e412 100644 --- a/src/util/auth.test.ts +++ b/src/util/auth.test.ts @@ -3,12 +3,14 @@ import { canAccessPrivateProfile, generateJWT } from "./auth" describe("auth", () => { it("should generate a valid JWT token", async () => { - const token = generateJWT({ - isAdmin: false, - bungieMembershipId: "123", - destinyMembershipIds: ["4611686018467346804"], - durationSeconds: 1 - }) + const token = generateJWT( + { + isAdmin: false, + bungieMembershipId: "123", + destinyMembershipIds: ["4611686018467346804"] + }, + 1 + ) expect(token).toBeTruthy() @@ -39,13 +41,33 @@ describe("auth", () => { }) }) - it("does not work with invalid secret", async () => { - const token = generateJWT({ - isAdmin: false, - bungieMembershipId: "123", - destinyMembershipIds: ["4611686018467346804"], - durationSeconds: 1 + it("does not work with malformed token", async () => { + const token = jwt.sign( + { + destinyMembershipIds: ["4611686018467346804"] + }, + process.env.JWT_SECRET, + { + expiresIn: 10 + } + ) + + expect(token).toBeTruthy() + + canAccessPrivateProfile("4611686018467346804", `Bearer ${token}`).then(result => { + expect(result).toBe(false) }) + }) + + it("does not work with invalid client secret", async () => { + const token = generateJWT( + { + isAdmin: false, + bungieMembershipId: "123", + destinyMembershipIds: ["4611686018467346804"] + }, + 10 + ) expect(token).toBeTruthy() diff --git a/src/util/auth.ts b/src/util/auth.ts index f977c24..0f3ce54 100644 --- a/src/util/auth.ts +++ b/src/util/auth.ts @@ -8,26 +8,11 @@ const zJWTAuthFormat = z.object({ destinyMembershipIds: z.array(zDigitString()) }) -export const generateJWT = ({ - isAdmin = false, - bungieMembershipId, - destinyMembershipIds, - durationSeconds -}: { - isAdmin: boolean - bungieMembershipId: string - destinyMembershipIds: string[] - durationSeconds: number -}) => - jwt.sign( - { isAdmin, bungieMembershipId, destinyMembershipIds } satisfies z.infer< - typeof zJWTAuthFormat - >, - process.env.JWT_SECRET, - { - expiresIn: durationSeconds - } - ) +export const generateJWT = (data: z.infer, expiresIn: number) => { + return jwt.sign(data, process.env.JWT_SECRET, { + expiresIn + }) +} export const canAccessPrivateProfile = async ( destinyMembershipId: string | bigint, @@ -36,7 +21,7 @@ export const canAccessPrivateProfile = async ( if (!authHeader) return false const [format, token] = authHeader ? authHeader.split(" ") : ["", ""] - if (format !== "Bearer") return false + if (format !== "Bearer" || !token) return false try { return await new Promise(resolve =>