diff --git a/backend/__tests__/api/controllers/ape-key.spec.ts b/backend/__tests__/api/controllers/ape-key.spec.ts new file mode 100644 index 000000000..0330907d8 --- /dev/null +++ b/backend/__tests__/api/controllers/ape-key.spec.ts @@ -0,0 +1,422 @@ +import request from "supertest"; +import app from "../../../src/app"; +import * as ApeKeyDal from "../../../src/dal/ape-keys"; +import { ObjectId } from "mongodb"; +import * as Configuration from "../../../src/init/configuration"; +import * as UserDal from "../../../src/dal/user"; +import _ from "lodash"; + +const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); +const uid = new ObjectId().toHexString(); + +describe("ApeKeyController", () => { + const getUserMock = vi.spyOn(UserDal, "getUser"); + + beforeEach(async () => { + await enableApeKeysEndpoints(true); + getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: true })); + vi.useFakeTimers(); + vi.setSystemTime(1000); + }); + + afterEach(() => { + getUserMock.mockReset(); + vi.useRealTimers(); + }); + + describe("get ape keys", () => { + const getApeKeysMock = vi.spyOn(ApeKeyDal, "getApeKeys"); + + afterEach(() => { + getApeKeysMock.mockReset(); + }); + + it("should get the users config", async () => { + //GIVEN + const keyOne = apeKeyDb(uid); + const keyTwo = apeKeyDb(uid); + getApeKeysMock.mockResolvedValue([keyOne, keyTwo]); + + //WHEN + const { body } = await mockApp + .get("/ape-keys") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toHaveProperty("message", "ApeKeys retrieved"); + expect(body.data).toHaveProperty(keyOne._id.toHexString(), { + name: keyOne.name, + enabled: keyOne.enabled, + createdOn: keyOne.createdOn, + modifiedOn: keyOne.modifiedOn, + lastUsedOn: keyOne.lastUsedOn, + }); + expect(body.data).toHaveProperty(keyTwo._id.toHexString(), { + name: keyTwo.name, + enabled: keyTwo.enabled, + createdOn: keyTwo.createdOn, + modifiedOn: keyTwo.modifiedOn, + lastUsedOn: keyTwo.lastUsedOn, + }); + expect(body.data).keys([keyOne._id, keyTwo._id]); + + expect(getApeKeysMock).toHaveBeenCalledWith(uid); + }); + it("should fail if apeKeys endpoints are disabled", async () => { + //GIVEN + await enableApeKeysEndpoints(false); + + //WHEN + const { body } = await mockApp + .get("/ape-keys") + .set("authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual("ApeKeys are currently disabled."); + }); + it("should fail if user has no apeKey permissions", async () => { + //GIVEN + + getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); + //WHEN + const { body } = await mockApp + .get("/ape-keys") + .set("authorization", `Uid ${uid}`) + .expect(403); + + //THEN + expect(body.message).toEqual( + "You have lost access to ape keys, please contact support" + ); + }); + }); + + describe("add ape key", () => { + const addApeKeyMock = vi.spyOn(ApeKeyDal, "addApeKey"); + const countApeKeysMock = vi.spyOn(ApeKeyDal, "countApeKeysForUser"); + + beforeEach(() => { + countApeKeysMock.mockResolvedValue(0); + }); + + afterEach(() => { + addApeKeyMock.mockReset(); + countApeKeysMock.mockReset(); + }); + + it("should add ape key", async () => { + //GIVEN + addApeKeyMock.mockResolvedValue("1"); + + //WHEN + const { body } = await mockApp + .post("/ape-keys") + .set("authorization", `Uid ${uid}`) + .send({ name: "test", enabled: true }) + .expect(200); + + expect(body.message).toEqual("ApeKey generated"); + expect(body.data).keys("apeKey", "apeKeyDetails", "apeKeyId"); + expect(body.data.apeKey).not.toBeNull(); + + expect(body.data.apeKeyDetails).toStrictEqual({ + createdOn: 1000, + enabled: true, + lastUsedOn: -1, + modifiedOn: 1000, + name: "test", + }); + + expect(body.data.apeKeyId).toEqual("1"); + + expect(addApeKeyMock).toHaveBeenCalledWith( + expect.objectContaining({ + createdOn: 1000, + enabled: true, + lastUsedOn: -1, + modifiedOn: 1000, + name: "test", + uid: uid, + useCount: 0, + }) + ); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/ape-keys") + .send({}) + .set("authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [`"name" Required`, `"enabled" Required`], + }); + }); + it("should fail with extra properties", async () => { + //WHEN + const { body } = await mockApp + .post("/ape-keys") + .send({ name: "test", enabled: true, extra: "value" }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + + it("should fail if max apeKeys is reached", async () => { + //GIVEN + countApeKeysMock.mockResolvedValue(1); + + //WHEN + const { body } = await mockApp + .post("/ape-keys") + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) + .expect(409); + + //THEN + expect(body.message).toEqual( + "Maximum number of ApeKeys have been generated" + ); + }); + it("should fail if apeKeys endpoints are disabled", async () => { + //GIVEN + await enableApeKeysEndpoints(false); + + //WHEN + const { body } = await mockApp + .post("/ape-keys") + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual("ApeKeys are currently disabled."); + }); + it("should fail if user has no apeKey permissions", async () => { + //GIVEN + getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); + + //WHEN + const { body } = await mockApp + .post("/ape-keys") + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) + .expect(403); + + //THEN + expect(body.message).toEqual( + "You have lost access to ape keys, please contact support" + ); + }); + }); + + describe("edit ape key", () => { + const editApeKeyMock = vi.spyOn(ApeKeyDal, "editApeKey"); + const apeKeyId = new ObjectId().toHexString(); + + afterEach(() => { + editApeKeyMock.mockReset(); + }); + + it("should edit ape key", async () => { + //GIVEN + editApeKeyMock.mockResolvedValue(); + + //WHEN + const { body } = await mockApp + .patch(`/ape-keys/${apeKeyId}`) + .send({ name: "new", enabled: false }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body.message).toEqual("ApeKey updated"); + expect(editApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId, "new", false); + }); + it("should edit ape key with single property", async () => { + //GIVEN + editApeKeyMock.mockResolvedValue(); + + //WHEN + const { body } = await mockApp + .patch(`/ape-keys/${apeKeyId}`) + .send({ name: "new" }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body.message).toEqual("ApeKey updated"); + expect(editApeKeyMock).toHaveBeenCalledWith( + uid, + apeKeyId, + "new", + undefined + ); + }); + it("should fail with missing path", async () => { + //GIVEN + + //WHEN + await mockApp + .patch(`/ape-keys/`) + .set("authorization", `Uid ${uid}`) + .expect(404); + }); + it("should fail with extra properties", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .patch(`/ape-keys/${apeKeyId}`) + .send({ name: "new", extra: "value" }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail if apeKeys endpoints are disabled", async () => { + //GIVEN + await enableApeKeysEndpoints(false); + + //WHEN + const { body } = await mockApp + .patch(`/ape-keys/${apeKeyId}`) + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual("ApeKeys are currently disabled."); + }); + it("should fail if user has no apeKey permissions", async () => { + //GIVEN + getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); + + //WHEN + const { body } = await mockApp + .patch(`/ape-keys/${apeKeyId}`) + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) + .expect(403); + + //THEN + expect(body.message).toEqual( + "You have lost access to ape keys, please contact support" + ); + }); + }); + describe("delete ape key", () => { + const deleteApeKeyMock = vi.spyOn(ApeKeyDal, "deleteApeKey"); + const apeKeyId = new ObjectId().toHexString(); + + afterEach(() => { + deleteApeKeyMock.mockReset(); + }); + + it("should delete ape key", async () => { + //GIVEN + + deleteApeKeyMock.mockResolvedValue(); + //WHEN + const { body } = await mockApp + .delete(`/ape-keys/${apeKeyId}`) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body.message).toEqual("ApeKey deleted"); + expect(deleteApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId); + }); + it("should fail with missing path", async () => { + //GIVEN + + //WHEN + await mockApp + .delete(`/ape-keys/`) + .set("authorization", `Uid ${uid}`) + .expect(404); + }); + it("should fail if apeKeys endpoints are disabled", async () => { + //GIVEN + await enableApeKeysEndpoints(false); + + //WHEN + const { body } = await mockApp + .delete(`/ape-keys/${apeKeyId}`) + .set("authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual("ApeKeys are currently disabled."); + }); + + it("should fail if user has no apeKey permissions", async () => { + //GIVEN + getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); + + //WHEN + const { body } = await mockApp + .delete(`/ape-keys/${apeKeyId}`) + .set("authorization", `Uid ${uid}`) + .expect(403); + + //THEN + expect(body.message).toEqual( + "You have lost access to ape keys, please contact support" + ); + }); + }); +}); + +function apeKeyDb( + uid: string, + data?: Partial +): MonkeyTypes.ApeKeyDB { + return { + _id: new ObjectId(), + uid, + hash: "hash", + useCount: 1, + name: "name", + enabled: true, + createdOn: Math.random() * Date.now(), + lastUsedOn: Math.random() * Date.now(), + modifiedOn: Math.random() * Date.now(), + ...data, + }; +} + +async function enableApeKeysEndpoints(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + apeKeys: { endpointsEnabled: enabled, maxKeysPerUser: 1 }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} + +function user( + uid: string, + data: Partial +): MonkeyTypes.DBUser { + return { + uid, + ...data, + } as MonkeyTypes.DBUser; +} diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index d0163f885..f85ddb50e 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -61,6 +61,11 @@ export function getOpenApi(): OpenAPIObject { description: "User specific configuration presets.", "x-displayName": "User presets", }, + { + name: "ape-keys", + description: "Ape keys provide access to certain API endpoints.", + "x-displayName": "Ape Keys", + }, ], }, diff --git a/backend/src/api/controllers/ape-key.ts b/backend/src/api/controllers/ape-key.ts index bd5eb189b..6ee02822c 100644 --- a/backend/src/api/controllers/ape-key.ts +++ b/backend/src/api/controllers/ape-key.ts @@ -3,29 +3,37 @@ import { randomBytes } from "crypto"; import { hash } from "bcrypt"; import * as ApeKeysDAL from "../../dal/ape-keys"; import MonkeyError from "../../utils/error"; -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; import { base64UrlEncode } from "../../utils/misc"; import { ObjectId } from "mongodb"; -import { ApeKey } from "@monkeytype/shared-types"; + +import { + AddApeKeyRequest, + AddApeKeyResponse, + ApeKeyParams, + EditApeKeyRequest, + GetApeKeyResponse, +} from "@monkeytype/contracts/ape-keys"; +import { ApeKey } from "@monkeytype/contracts/schemas/ape-keys"; function cleanApeKey(apeKey: MonkeyTypes.ApeKeyDB): ApeKey { return _.omit(apeKey, "hash", "_id", "uid", "useCount"); } export async function getApeKeys( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const apeKeys = await ApeKeysDAL.getApeKeys(uid); const cleanedKeys = _(apeKeys).keyBy("_id").mapValues(cleanApeKey).value(); - return new MonkeyResponse("ApeKeys retrieved", cleanedKeys); + return new MonkeyResponse2("ApeKeys retrieved", cleanedKeys); } export async function generateApeKey( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { name, enabled } = req.body; const { uid } = req.ctx.decodedToken; const { maxKeysPerUser, apeKeyBytes, apeKeySaltRounds } = @@ -54,7 +62,7 @@ export async function generateApeKey( const apeKeyId = await ApeKeysDAL.addApeKey(apeKey); - return new MonkeyResponse("ApeKey generated", { + return new MonkeyResponse2("ApeKey generated", { apeKey: base64UrlEncode(`${apeKeyId}.${apiKey}`), apeKeyId, apeKeyDetails: cleanApeKey(apeKey), @@ -62,24 +70,24 @@ export async function generateApeKey( } export async function editApeKey( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { apeKeyId } = req.params; const { name, enabled } = req.body; const { uid } = req.ctx.decodedToken; - await ApeKeysDAL.editApeKey(uid, apeKeyId as string, name, enabled); + await ApeKeysDAL.editApeKey(uid, apeKeyId, name, enabled); - return new MonkeyResponse("ApeKey updated"); + return new MonkeyResponse2("ApeKey updated", null); } export async function deleteApeKey( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { apeKeyId } = req.params; const { uid } = req.ctx.decodedToken; - await ApeKeysDAL.deleteApeKey(uid, apeKeyId as string); + await ApeKeysDAL.deleteApeKey(uid, apeKeyId); - return new MonkeyResponse("ApeKey deleted"); + return new MonkeyResponse2("ApeKey deleted", null); } diff --git a/backend/src/api/routes/ape-keys.ts b/backend/src/api/routes/ape-keys.ts index 1e49bc892..e6f85650d 100644 --- a/backend/src/api/routes/ape-keys.ts +++ b/backend/src/api/routes/ape-keys.ts @@ -1,91 +1,42 @@ -import joi from "joi"; -import { Router } from "express"; -import { authenticateRequest } from "../../middlewares/auth"; -import * as ApeKeyController from "../controllers/ape-key"; +import { apeKeysContract } from "@monkeytype/contracts/ape-keys"; +import { initServer } from "@ts-rest/express"; import * as RateLimit from "../../middlewares/rate-limit"; +import * as ApeKeyController from "../controllers/ape-key"; +import { callController } from "../ts-rest-adapter"; import { checkUserPermissions } from "../../middlewares/permission"; import { validate } from "../../middlewares/configuration"; -import { asyncHandler } from "../../middlewares/utility"; -import { validateRequest } from "../../middlewares/validation"; -const apeKeyNameSchema = joi - .string() - .regex(/^[0-9a-zA-Z_.-]+$/) - .max(20) - .messages({ - "string.pattern.base": "Invalid ApeKey name", - "string.max": "ApeKey name exceeds maximum of 20 characters", - }); - -const checkIfUserCanManageApeKeys = checkUserPermissions({ - criteria: (user) => { - // Must be an exact check - return user.canManageApeKeys !== false; - }, - invalidMessage: "You have lost access to ape keys, please contact support", -}); - -const router = Router(); - -router.use( +const commonMiddleware = [ validate({ criteria: (configuration) => { return configuration.apeKeys.endpointsEnabled; }, invalidMessage: "ApeKeys are currently disabled.", - }) -); - -router.get( - "/", - authenticateRequest(), - RateLimit.apeKeysGet, - checkIfUserCanManageApeKeys, - asyncHandler(ApeKeyController.getApeKeys) -); - -router.post( - "/", - authenticateRequest(), - RateLimit.apeKeysGenerate, - checkIfUserCanManageApeKeys, - validateRequest({ - body: { - name: apeKeyNameSchema.required(), - enabled: joi.boolean().required(), - }, }), - asyncHandler(ApeKeyController.generateApeKey) -); - -router.patch( - "/:apeKeyId", - authenticateRequest(), - RateLimit.apeKeysUpdate, - checkIfUserCanManageApeKeys, - validateRequest({ - params: { - apeKeyId: joi.string().token().required(), - }, - body: { - name: apeKeyNameSchema, - enabled: joi.boolean(), + checkUserPermissions({ + criteria: (user) => { + return user.canManageApeKeys ?? false; }, + invalidMessage: "You have lost access to ape keys, please contact support", }), - asyncHandler(ApeKeyController.editApeKey) -); +]; -router.delete( - "/:apeKeyId", - authenticateRequest(), - RateLimit.apeKeysDelete, - checkIfUserCanManageApeKeys, - validateRequest({ - params: { - apeKeyId: joi.string().token().required(), - }, - }), - asyncHandler(ApeKeyController.deleteApeKey) -); - -export default router; +const s = initServer(); +export default s.router(apeKeysContract, { + get: { + middleware: [...commonMiddleware, RateLimit.apeKeysGet], + handler: async (r) => callController(ApeKeyController.getApeKeys)(r), + }, + add: { + middleware: [...commonMiddleware, RateLimit.apeKeysGenerate], + handler: async (r) => callController(ApeKeyController.generateApeKey)(r), + }, + save: { + middleware: [...commonMiddleware, RateLimit.apeKeysUpdate], + handler: async (r) => callController(ApeKeyController.editApeKey)(r), + }, + delete: { + middleware: [...commonMiddleware, RateLimit.apeKeysDelete], + handler: async (r) => callController(ApeKeyController.deleteApeKey)(r), + }, +}); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index c4088f2c5..526742090 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -47,7 +47,6 @@ const API_ROUTE_MAP = { "/public": publicStats, "/leaderboards": leaderboards, "/quotes": quotes, - "/ape-keys": apeKeys, "/admin": admin, "/webhooks": webhooks, "/docs": docs, @@ -55,6 +54,7 @@ const API_ROUTE_MAP = { const s = initServer(); const router = s.router(contract, { + apeKeys, configs, presets, }); diff --git a/backend/src/dal/ape-keys.ts b/backend/src/dal/ape-keys.ts index 9be141c43..16a03a60a 100644 --- a/backend/src/dal/ape-keys.ts +++ b/backend/src/dal/ape-keys.ts @@ -34,8 +34,7 @@ export async function getApeKey( } export async function countApeKeysForUser(uid: string): Promise { - const apeKeys = await getApeKeys(uid); - return _.size(apeKeys); + return getApeKeysCollection().countDocuments({ uid }); } export async function addApeKey(apeKey: MonkeyTypes.ApeKeyDB): Promise { @@ -64,9 +63,11 @@ async function updateApeKey( export async function editApeKey( uid: string, keyId: string, - name: string, - enabled: boolean + name?: string, + enabled?: boolean ): Promise { + //check if there is a change + if (name === undefined && enabled === undefined) return; const apeKeyUpdates = { name, enabled, diff --git a/backend/src/documentation/internal-swagger.json b/backend/src/documentation/internal-swagger.json index 672c21aaa..d19a4fc74 100644 --- a/backend/src/documentation/internal-swagger.json +++ b/backend/src/documentation/internal-swagger.json @@ -27,10 +27,6 @@ "name": "psas", "description": "Public service announcements" }, - { - "name": "ape-keys", - "description": "ApeKey data and related operations" - }, { "name": "leaderboards", "description": "Leaderboard data" @@ -434,92 +430,6 @@ } } }, - "/ape-keys": { - "get": { - "tags": ["ape-keys"], - "summary": "Gets ApeKeys created by a user", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "post": { - "tags": ["ape-keys"], - "summary": "Creates an ApeKey", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "enabled": { - "type": "boolean" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/ape-keys/{apeKeyId}": { - "patch": { - "tags": ["ape-keys"], - "summary": "Updates an ApeKey", - "parameters": [ - { - "in": "path", - "name": "apeKeyId", - "required": true, - "type": "string" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "delete": { - "tags": ["ape-keys"], - "summary": "Deletes an ApeKey", - "parameters": [ - { - "in": "path", - "name": "apeKeyId", - "required": true, - "type": "string" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, "/leaderboards": { "get": { "tags": ["leaderboards"], diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 56669c1f5..34345f393 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -76,7 +76,7 @@ declare namespace MonkeyTypes { _id: ObjectId; }; - type ApeKeyDB = import("@monkeytype/shared-types").ApeKey & { + type ApeKeyDB = import("@monkeytype/contracts/schemas/ape-keys").ApeKey & { _id: ObjectId; uid: string; hash: string; diff --git a/frontend/src/ts/ape/endpoints/ape-keys.ts b/frontend/src/ts/ape/endpoints/ape-keys.ts deleted file mode 100644 index 2c051f2ff..000000000 --- a/frontend/src/ts/ape/endpoints/ape-keys.ts +++ /dev/null @@ -1,33 +0,0 @@ -const BASE_PATH = "/ape-keys"; - -export default class ApeKeys { - constructor(private httpClient: Ape.HttpClient) { - this.httpClient = httpClient; - } - - async get(): Ape.EndpointResponse { - return await this.httpClient.get(BASE_PATH); - } - - async generate( - name: string, - enabled: boolean - ): Ape.EndpointResponse { - const payload = { name, enabled }; - return await this.httpClient.post(BASE_PATH, { payload }); - } - - async update( - apeKeyId: string, - updates: { name?: string; enabled?: boolean } - ): Ape.EndpointResponse { - const payload = { ...updates }; - const encoded = encodeURIComponent(apeKeyId); - return await this.httpClient.patch(`${BASE_PATH}/${encoded}`, { payload }); - } - - async delete(apeKeyId: string): Ape.EndpointResponse { - const encoded = encodeURIComponent(apeKeyId); - return await this.httpClient.delete(`${BASE_PATH}/${encoded}`); - } -} diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index b7bdb5801..5b7b849fe 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -3,7 +3,6 @@ import Psas from "./psas"; import Quotes from "./quotes"; import Results from "./results"; import Users from "./users"; -import ApeKeys from "./ape-keys"; import Public from "./public"; import Configuration from "./configuration"; import Dev from "./dev"; @@ -15,7 +14,6 @@ export default { Quotes, Results, Users, - ApeKeys, Configuration, Dev, }; diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 68827fba9..137a08871 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -4,6 +4,7 @@ import { envConfig } from "../constants/env-config"; import { buildClient } from "./adapters/ts-rest-adapter"; import { configsContract } from "@monkeytype/contracts/configs"; import { presetsContract } from "@monkeytype/contracts/presets"; +import { apeKeysContract } from "@monkeytype/contracts/ape-keys"; const API_PATH = ""; const BASE_URL = envConfig.backendUrl; @@ -21,7 +22,7 @@ const Ape = { leaderboards: new endpoints.Leaderboards(httpClient), presets: buildClient(presetsContract, BASE_URL, 10_000), publicStats: new endpoints.Public(httpClient), - apeKeys: new endpoints.ApeKeys(httpClient), + apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000), configuration: new endpoints.Configuration(httpClient), dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), }; diff --git a/frontend/src/ts/ape/types/ape-keys.d.ts b/frontend/src/ts/ape/types/ape-keys.d.ts deleted file mode 100644 index a8a7ec6e0..000000000 --- a/frontend/src/ts/ape/types/ape-keys.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -// for some reason when using the dot notaion, the types are not being recognized as used -declare namespace Ape.ApeKeys { - type GetApeKeys = Record; - - type GenerateApeKey = { - apeKey: string; - apeKeyId: string; - apeKeyDetails: import("@monkeytype/shared-types").ApeKey; - }; -} diff --git a/frontend/src/ts/modals/ape-keys.ts b/frontend/src/ts/modals/ape-keys.ts index 63f6115ed..906092e98 100644 --- a/frontend/src/ts/modals/ape-keys.ts +++ b/frontend/src/ts/modals/ape-keys.ts @@ -5,9 +5,9 @@ import { format } from "date-fns/format"; import * as ConnectionState from "../states/connection"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { showPopup } from "./simple-modals"; -import { ApeKey } from "@monkeytype/shared-types"; +import { ApeKey, ApeKeys } from "@monkeytype/contracts/schemas/ape-keys"; -let apeKeys: Ape.ApeKeys.GetApeKeys | null = {}; +let apeKeys: ApeKeys | null = {}; async function getData(): Promise { Loader.show(); @@ -15,11 +15,11 @@ async function getData(): Promise { Loader.hide(); if (response.status !== 200) { - Notifications.add("Error getting ape keys: " + response.message, -1); + Notifications.add("Error getting ape keys: " + response.body.message, -1); return undefined; } - apeKeys = response.data; + apeKeys = response.body.data; } function refreshList(): void { @@ -113,10 +113,13 @@ async function toggleActiveKey(keyId: string): Promise { const key = apeKeys?.[keyId]; if (!key || apeKeys === undefined) return; Loader.show(); - const response = await Ape.apeKeys.update(keyId, { enabled: !key.enabled }); + const response = await Ape.apeKeys.save({ + params: { apeKeyId: keyId }, + body: { enabled: !key.enabled }, + }); Loader.hide(); if (response.status !== 200) { - Notifications.add("Failed to update key: " + response.message, -1); + Notifications.add("Failed to update key: " + response.body.message, -1); return; } key.enabled = !key.enabled; diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 2b10f929a..7096a7ec6 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -163,6 +163,7 @@ type SimpleModalOptions = { title: string; inputs?: CommonInputType[]; text?: string; + textAllowHtml?: boolean; buttonText: string; execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise; beforeInitFn?: (thisPopup: SimpleModal) => void; @@ -197,6 +198,7 @@ class SimpleModal { title: string; inputs: CommonInputType[]; text?: string; + textAllowHtml: boolean; buttonText: string; execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise; beforeInitFn: ((thisPopup: SimpleModal) => void) | undefined; @@ -212,6 +214,7 @@ class SimpleModal { this.title = options.title; this.inputs = options.inputs ?? []; this.text = options.text; + this.textAllowHtml = options.textAllowHtml ?? false; this.wrapper = modal.getWrapper(); this.element = modal.getModal(); this.buttonText = options.buttonText; @@ -236,7 +239,11 @@ class SimpleModal { this.reset(); el.attr("data-popup-id", this.id); el.find(".title").text(this.title); - el.find(".text").text(this.text ?? ""); + if (this.textAllowHtml) { + el.find(".text").html(this.text ?? ""); + } else { + el.find(".text").text(this.text ?? ""); + } this.initInputs(); @@ -1468,16 +1475,15 @@ list.generateApeKey = new SimpleModal({ buttonText: "generate", onlineOnly: true, execFn: async (_thisPopup, name): Promise => { - const response = await Ape.apeKeys.generate(name, false); + const response = await Ape.apeKeys.add({ body: { name, enabled: false } }); if (response.status !== 200) { return { status: -1, - message: "Failed to generate key: " + response.message, + message: "Failed to generate key: " + response.body.message, }; } - //if response is 200 data is guaranteed to not be null - const data = response.data as Ape.ApeKeys.GenerateApeKey; + const data = response.body.data; const modalChain = modal.getPreviousModalInChain(); return { @@ -1508,7 +1514,10 @@ list.viewApeKey = new SimpleModal({ initVal: "", }, ], - text: "This is your new Ape Key. Please keep it safe. You will only see it once!", + textAllowHtml: true, + text: ` + This is your new Ape Key. Please keep it safe. You will only see it once!

+ Note: Ape Keys are disabled by default, you need to enable them before they can be used.`, buttonText: "close", hideCallsExec: true, execFn: async (_thisPopup): Promise => { @@ -1543,11 +1552,13 @@ list.deleteApeKey = new SimpleModal({ buttonText: "delete", onlineOnly: true, execFn: async (_thisPopup): Promise => { - const response = await Ape.apeKeys.delete(_thisPopup.parameters[0] ?? ""); + const response = await Ape.apeKeys.delete({ + params: { apeKeyId: _thisPopup.parameters[0] ?? "" }, + }); if (response.status !== 200) { return { status: -1, - message: "Failed to delete key: " + response.message, + message: "Failed to delete key: " + response.body.message, }; } @@ -1574,13 +1585,16 @@ list.editApeKey = new SimpleModal({ buttonText: "edit", onlineOnly: true, execFn: async (_thisPopup, input): Promise => { - const response = await Ape.apeKeys.update(_thisPopup.parameters[0] ?? "", { - name: input, + const response = await Ape.apeKeys.save({ + params: { apeKeyId: _thisPopup.parameters[0] ?? "" }, + body: { + name: input, + }, }); if (response.status !== 200) { return { status: -1, - message: "Failed to update key: " + response.message, + message: "Failed to update key: " + response.body.message, }; } return { diff --git a/packages/contracts/src/ape-keys.ts b/packages/contracts/src/ape-keys.ts new file mode 100644 index 000000000..c5657d32c --- /dev/null +++ b/packages/contracts/src/ape-keys.ts @@ -0,0 +1,96 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; + +import { + CommonResponses, + EndpointMetadata, + MonkeyResponseSchema, + responseWithData, +} from "./schemas/api"; + +import { IdSchema } from "./schemas/util"; +import { + ApeKeySchema, + ApeKeysSchema, + ApeKeyUserDefinedSchema, +} from "./schemas/ape-keys"; + +export const GetApeKeyResponseSchema = responseWithData(ApeKeysSchema); +export type GetApeKeyResponse = z.infer; + +export const AddApeKeyRequestSchema = ApeKeyUserDefinedSchema; +export type AddApeKeyRequest = z.infer; + +export const AddApeKeyResponseSchema = responseWithData( + z.object({ + apeKeyId: IdSchema, + apeKey: z.string().base64(), + apeKeyDetails: ApeKeySchema, + }) +); +export type AddApeKeyResponse = z.infer; + +export const EditApeKeyRequestSchema = AddApeKeyRequestSchema.partial(); +export type EditApeKeyRequest = z.infer; + +export const ApeKeyParamsSchema = z.object({ + apeKeyId: IdSchema, +}); +export type ApeKeyParams = z.infer; + +const c = initContract(); + +export const apeKeysContract = c.router( + { + get: { + summary: "get ape keys", + description: "Get ape keys of the current user.", + method: "GET", + path: "/", + responses: { + 200: GetApeKeyResponseSchema, + }, + }, + add: { + summary: "add ape key", + description: "Add an ape key for the current user.", + method: "POST", + path: "/", + body: AddApeKeyRequestSchema.strict(), + responses: { + 200: AddApeKeyResponseSchema, + }, + }, + save: { + summary: "update ape key", + description: "Update an existing ape key for the current user.", + method: "PATCH", + path: "/:apeKeyId", + pathParams: ApeKeyParamsSchema, + body: EditApeKeyRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + delete: { + summary: "delete ape key", + description: "Delete ape key by id.", + method: "DELETE", + path: "/:apeKeyId", + pathParams: ApeKeyParamsSchema, + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + }, + { + pathPrefix: "/ape-keys", + strictStatusCodes: true, + metadata: { + openApiTags: "ape-keys", + } as EndpointMetadata, + + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 55954b358..94ab54b91 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,10 +1,12 @@ import { initContract } from "@ts-rest/core"; import { configsContract } from "./configs"; import { presetsContract } from "./presets"; +import { apeKeysContract } from "./ape-keys"; const c = initContract(); export const contract = c.router({ + apeKeys: apeKeysContract, configs: configsContract, presets: presetsContract, }); diff --git a/packages/contracts/src/schemas/ape-keys.ts b/packages/contracts/src/schemas/ape-keys.ts new file mode 100644 index 000000000..609e37d93 --- /dev/null +++ b/packages/contracts/src/schemas/ape-keys.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { IdSchema } from "./util"; + +export const ApeKeyUserDefinedSchema = z.object({ + name: z + .string() + .regex(/^[0-9a-zA-Z_.-]+$/) + .max(20), + enabled: z.boolean(), +}); + +export const ApeKeySchema = ApeKeyUserDefinedSchema.extend({ + createdOn: z.number().min(0), + modifiedOn: z.number().min(0), + lastUsedOn: z.number().min(0).or(z.literal(-1)), +}); +export type ApeKey = z.infer; + +export const ApeKeysSchema = z.record(IdSchema, ApeKeySchema); +export type ApeKeys = z.infer; diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index 4e46fbbb4..69a1ae299 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -1,6 +1,6 @@ import { z, ZodSchema } from "zod"; -export type OpenApiTag = "configs" | "presets"; +export type OpenApiTag = "configs" | "presets" | "ape-keys"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 4b3b295ef..695c637b4 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -320,14 +320,6 @@ export type PublicTypingStats = { testsStarted: number; }; -export type ApeKey = { - name: string; - enabled: boolean; - createdOn: number; - modifiedOn: number; - lastUsedOn: number; -}; - export type LeaderboardEntry = { _id: string; wpm: number;