impr: use tsrest for configurations endpoint (@fehmer) (#5796)

!nuf
This commit is contained in:
Christian Fehmer 2024-08-23 19:06:41 +02:00 committed by GitHub
parent 73f379ae8a
commit e2d574444a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 654 additions and 234 deletions

View file

@ -1,4 +1,4 @@
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { randomBytes } from "crypto";
import { hash } from "bcrypt";
import { ObjectId } from "mongodb";

View file

@ -0,0 +1,194 @@
import request from "supertest";
import app from "../../../src/app";
import {
BASE_CONFIGURATION,
CONFIGURATION_FORM_SCHEMA,
} from "../../../src/constants/base-configuration";
import * as Configuration from "../../../src/init/configuration";
import type { Configuration as ConfigurationType } from "@monkeytype/contracts/schemas/configuration";
import { ObjectId } from "mongodb";
import * as Misc from "../../../src/utils/misc";
import { DecodedIdToken } from "firebase-admin/auth";
import * as AuthUtils from "../../../src/utils/auth";
import * as AdminUuids from "../../../src/dal/admin-uids";
const mockApp = request(app);
const uid = new ObjectId().toHexString();
const mockDecodedToken = {
uid,
email: "newuser@mail.com",
iat: 0,
} as DecodedIdToken;
describe("Configuration Controller", () => {
const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment");
const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken");
const isAdminMock = vi.spyOn(AdminUuids, "isAdmin");
beforeEach(() => {
isAdminMock.mockReset();
verifyIdTokenMock.mockReset();
isDevEnvironmentMock.mockReset();
isDevEnvironmentMock.mockReturnValue(true);
isAdminMock.mockResolvedValue(true);
});
describe("getConfiguration", () => {
it("should get without authentication", async () => {
//GIVEN
//WHEN
const { body } = await mockApp.get("/configuration").expect(200);
//THEN
expect(body).toEqual({
message: "Configuration retrieved",
data: BASE_CONFIGURATION,
});
});
});
describe("getConfigurationSchema", () => {
it("should get without authentication on dev", async () => {
//GIVEN
//WHEN
const { body } = await mockApp.get("/configuration/schema").expect(200);
//THEN
expect(body).toEqual({
message: "Configuration schema retrieved",
data: CONFIGURATION_FORM_SCHEMA,
});
});
it("should fail without authentication on prod", async () => {
//GIVEN
isDevEnvironmentMock.mockReturnValue(false);
//WHEN
await mockApp.get("/configuration/schema").expect(401);
});
it("should get with authentication on prod", async () => {
//GIVEN
isDevEnvironmentMock.mockReturnValue(false);
verifyIdTokenMock.mockResolvedValue(mockDecodedToken);
//WHEN
const { body } = await mockApp
.get("/configuration/schema")
.set("Authorization", "Bearer 123456789")
.expect(200);
//THEN
expect(body).toEqual({
message: "Configuration schema retrieved",
data: CONFIGURATION_FORM_SCHEMA,
});
expect(verifyIdTokenMock).toHaveBeenCalled();
});
it("should fail with non-admin user on prod", async () => {
//GIVEN
isDevEnvironmentMock.mockReturnValue(false);
verifyIdTokenMock.mockResolvedValue(mockDecodedToken);
isAdminMock.mockResolvedValue(false);
//WHEN
const { body } = await mockApp
.get("/configuration/schema")
.set("Authorization", "Bearer 123456789")
.expect(403);
//THEN
expect(body.message).toEqual("You don't have permission to do this.");
expect(verifyIdTokenMock).toHaveBeenCalled();
expect(isAdminMock).toHaveBeenCalledWith(uid);
});
});
describe("updateConfiguration", () => {
const patchConfigurationMock = vi.spyOn(
Configuration,
"patchConfiguration"
);
beforeEach(() => {
patchConfigurationMock.mockReset();
patchConfigurationMock.mockResolvedValue(true);
});
it("should update without authentication on dev", async () => {
//GIVEN
const patch = {
users: {
premium: {
enabled: true,
},
},
} as Partial<ConfigurationType>;
//WHEN
const { body } = await mockApp
.patch("/configuration")
.send({ configuration: patch })
.expect(200);
//THEN
expect(body).toEqual({
message: "Configuration updated",
data: null,
});
expect(patchConfigurationMock).toHaveBeenCalledWith(patch);
});
it("should fail update without authentication on prod", async () => {
//GIVEN
isDevEnvironmentMock.mockReturnValue(false);
//WHEN
await request(app)
.patch("/configuration")
.send({ configuration: {} })
.expect(401);
//THEN
expect(patchConfigurationMock).not.toHaveBeenCalled();
});
it("should update with authentication on prod", async () => {
//GIVEN
isDevEnvironmentMock.mockReturnValue(false);
verifyIdTokenMock.mockResolvedValue(mockDecodedToken);
//WHEN
await mockApp
.patch("/configuration")
.set("Authorization", "Bearer 123456789")
.send({ configuration: {} })
.expect(200);
//THEN
expect(patchConfigurationMock).toHaveBeenCalled();
expect(verifyIdTokenMock).toHaveBeenCalled();
});
it("should fail for non admin users on prod", async () => {
//GIVEN
isDevEnvironmentMock.mockReturnValue(false);
isAdminMock.mockResolvedValue(false);
verifyIdTokenMock.mockResolvedValue(mockDecodedToken);
//WHEN
await mockApp
.patch("/configuration")
.set("Authorization", "Bearer 123456789")
.send({ configuration: {} })
.expect(403);
//THEN
expect(patchConfigurationMock).not.toHaveBeenCalled();
expect(isAdminMock).toHaveBeenCalledWith(uid);
});
});
});

View file

@ -86,22 +86,15 @@ describe("middlewares/auth", () => {
requireFreshToken: true,
});
let result;
try {
result = await authenticateRequest(
expect(() =>
authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
} catch (e) {
result = e;
}
expect(result.message).toBe(
)
).rejects.toThrowError(
"Unauthorized\nStack: This endpoint requires a fresh token"
);
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should allow the request if token is fresh", async () => {
Date.now = vi.fn(() => 10000);
@ -321,7 +314,7 @@ describe("middlewares/auth", () => {
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should fail wit apeKey if apeKey is not supported", async () => {
it("should fail with apeKey if apeKey is not supported", async () => {
//WHEN
await expect(() =>
authenticate(
@ -332,6 +325,22 @@ describe("middlewares/auth", () => {
//THEN
});
it("should fail with apeKey if apeKeys are disabled", async () => {
//GIVEN
//@ts-expect-error
mockRequest.ctx.configuration.apeKeys.acceptKeys = false;
//WHEN
await expect(() =>
authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: false }
)
).rejects.toThrowError("ApeKeys are not being accepted at this time");
//THEN
});
it("should allow the request with authentation on public endpoint", async () => {
//WHEN
const result = await authenticate({}, { isPublic: true });
@ -489,6 +498,112 @@ describe("middlewares/auth", () => {
expect.anything()
);
});
it("should allow the request with authentation on dev public endpoint", async () => {
//WHEN
const result = await authenticate({}, { isPublicOnDev: true });
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("Bearer");
expect(decodedToken?.email).toBe(mockDecodedToken.email);
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should allow the request without authentication on dev public endpoint", async () => {
//WHEN
const result = await authenticate(
{ headers: {} },
{ isPublicOnDev: true }
);
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("None");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("");
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
});
it("should allow the request with apeKey on dev public endpoint", async () => {
//WHEN
const result = await authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: true, isPublicOnDev: true }
);
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("ApeKey");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
});
it("should allow with apeKey if apeKeys are disabled on dev public endpoint", async () => {
//GIVEN
//@ts-expect-error
mockRequest.ctx.configuration.apeKeys.acceptKeys = false;
//WHEN
const result = await authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: true, isPublicOnDev: true }
);
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("ApeKey");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
});
it("should allow the request with authentation on dev public endpoint in production", async () => {
//WHEN
isDevModeMock.mockReturnValue(false);
const result = await authenticate({}, { isPublicOnDev: true });
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("Bearer");
expect(decodedToken?.email).toBe(mockDecodedToken.email);
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should fail without authentication on dev public endpoint in production", async () => {
//WHEN
isDevModeMock.mockReturnValue(false);
//THEN
await expect(() =>
authenticate({ headers: {} }, { isPublicOnDev: true })
).rejects.toThrowError("Unauthorized");
});
it("should allow with apeKey on dev public endpoint in production", async () => {
//WHEN
isDevModeMock.mockReturnValue(false);
const result = await authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: true, isPublicOnDev: true }
);
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("ApeKey");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
});
});
});

View file

@ -53,8 +53,8 @@ export function getOpenApi(): OpenAPIObject {
{
name: "configs",
description:
"User specific configurations like test settings, theme or tags.",
"x-displayName": "User configuration",
"User specific configs like test settings, theme or tags.",
"x-displayName": "User configs",
"x-public": "no",
},
{
@ -99,6 +99,12 @@ export function getOpenApi(): OpenAPIObject {
"x-displayName": "Admin",
"x-public": "no",
},
{
name: "configuration",
description: "Server configuration",
"x-displayName": "Server configuration",
"x-public": "yes",
},
],
},

View file

@ -12,7 +12,7 @@ import {
ToggleBanResponse,
} from "@monkeytype/contracts/admin";
import MonkeyError from "../../utils/error";
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { addImportantLog } from "../../dal/logs";
export async function test(

View file

@ -1,32 +1,38 @@
import * as Configuration from "../../init/configuration";
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { CONFIGURATION_FORM_SCHEMA } from "../../constants/base-configuration";
import {
ConfigurationSchemaResponse,
GetConfigurationResponse,
PatchConfigurationRequest,
} from "@monkeytype/contracts/configuration";
import MonkeyError from "../../utils/error";
export async function getConfiguration(
_req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
_req: MonkeyTypes.Request2
): Promise<GetConfigurationResponse> {
const currentConfiguration = await Configuration.getLiveConfiguration();
return new MonkeyResponse("Configuration retrieved", currentConfiguration);
return new MonkeyResponse2("Configuration retrieved", currentConfiguration);
}
export async function getSchema(
_req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
return new MonkeyResponse(
_req: MonkeyTypes.Request2
): Promise<ConfigurationSchemaResponse> {
return new MonkeyResponse2(
"Configuration schema retrieved",
CONFIGURATION_FORM_SCHEMA
);
}
export async function updateConfiguration(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, PatchConfigurationRequest>
): Promise<MonkeyResponse2> {
const { configuration } = req.body;
const success = await Configuration.patchConfiguration(configuration);
if (!success) {
return new MonkeyResponse("Configuration update failed", {}, 500);
throw new MonkeyError(500, "Configuration update failed");
}
return new MonkeyResponse("Configuration updated");
return new MonkeyResponse2("Configuration updated", null);
}

View file

@ -21,7 +21,7 @@ import {
GetWeeklyXpLeaderboardResponse,
LanguageAndModeQuery,
} from "@monkeytype/contracts/leaderboards";
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
export async function getLeaderboard(
req: MonkeyTypes.Request2<GetLeaderboardQuery>

View file

@ -36,7 +36,7 @@ import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
import { UAParser } from "ua-parser-js";
import { canFunboxGetPb } from "../../utils/pb";
import { buildDbResult, replaceLegacyValues } from "../../utils/result";
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { addLog } from "../../dal/logs";
import {
AddResultRequest,

View file

@ -1,43 +1,25 @@
import joi from "joi";
import { Router } from "express";
import * as ConfigurationController from "../controllers/configuration";
import { authenticateRequest } from "../../middlewares/auth";
import { adminLimit } from "../../middlewares/rate-limit";
import { asyncHandler, useInProduction } from "../../middlewares/utility";
import { configurationContract } from "@monkeytype/contracts/configuration";
import { initServer } from "@ts-rest/express";
import { checkIfUserIsAdmin } from "../../middlewares/permission";
import { validateRequest } from "../../middlewares/validation";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ConfigurationController from "../controllers/configuration";
import { callController } from "../ts-rest-adapter";
const router = Router();
const s = initServer();
router.get("/", asyncHandler(ConfigurationController.getConfiguration));
export default s.router(configurationContract, {
get: {
handler: async (r) =>
callController(ConfigurationController.getConfiguration)(r),
},
router.patch(
"/",
adminLimit,
useInProduction([
authenticateRequest({
noCache: true,
}),
checkIfUserIsAdmin(),
]),
validateRequest({
body: {
configuration: joi.object(),
},
}),
asyncHandler(ConfigurationController.updateConfiguration)
);
router.get(
"/schema",
adminLimit,
useInProduction([
authenticateRequest({
noCache: true,
}),
checkIfUserIsAdmin(),
]),
asyncHandler(ConfigurationController.getSchema)
);
export default router;
update: {
middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit],
handler: async (r) =>
callController(ConfigurationController.updateConfiguration)(r),
},
getSchema: {
middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit],
handler: async (r) => callController(ConfigurationController.getSchema)(r),
},
});

View file

@ -56,6 +56,7 @@ const router = s.router(contract, {
public: publicStats,
leaderboards,
results,
configuration,
});
export function addApiRoutes(app: Application): void {
@ -145,13 +146,16 @@ function applyDevApiRoutes(app: Application): void {
}
function applyApiRoutes(app: Application): void {
// Cannot be added to the route map because it needs to be added before the maintenance handler
app.use("/configuration", configuration);
addSwaggerMiddlewares(app);
//TODO move to globalMiddleware when all endpoints use tsrest
app.use(
(req: MonkeyTypes.Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith("/configuration")) {
next();
return;
}
const inMaintenance =
process.env["MAINTENANCE"] === "true" ||
req.ctx.configuration.maintenance;

View file

@ -1,4 +1,4 @@
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
/**
* This is the base schema for the configuration of the API backend.

View file

@ -17,7 +17,6 @@ import { UTCDate } from "@date-fns/utc";
import {
AllRewards,
Badge,
Configuration,
CustomTheme,
MonkeyMail,
UserInventory,
@ -33,6 +32,7 @@ import {
import { addImportantLog } from "./logs";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
import { Result as ResultType } from "@monkeytype/contracts/schemas/results";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
const SECONDS_PER_HOUR = 3600;

View file

@ -4,14 +4,15 @@ import { ObjectId } from "mongodb";
import Logger from "../utils/logger";
import { identity } from "../utils/misc";
import { BASE_CONFIGURATION } from "../constants/base-configuration";
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { addLog } from "../dal/logs";
import { PartialConfiguration } from "@monkeytype/contracts/configuration";
const CONFIG_UPDATE_INTERVAL = 10 * 60 * 1000; // 10 Minutes
function mergeConfigurations(
baseConfiguration: Configuration,
liveConfiguration: Partial<Configuration>
liveConfiguration: PartialConfiguration
): void {
if (
!_.isPlainObject(baseConfiguration) ||
@ -111,7 +112,7 @@ async function pushConfiguration(configuration: Configuration): Promise<void> {
}
export async function patchConfiguration(
configurationUpdates: Partial<Configuration>
configurationUpdates: PartialConfiguration
): Promise<boolean> {
try {
const currentConfiguration = _.cloneDeep(configuration);

View file

@ -15,12 +15,13 @@ import { performance } from "perf_hooks";
import { TsRestRequestHandler } from "@ts-rest/express";
import { AppRoute, AppRouter } from "@ts-rest/core";
import { RequestAuthenticationOptions } from "@monkeytype/contracts/schemas/api";
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
isPublic: false,
acceptApeKeys: false,
requireFreshToken: false,
isPublicOnDev: false,
};
export type TsRestRequestWithCtx = {
@ -73,6 +74,9 @@ async function _authenticateRequestInternal(
let token: MonkeyTypes.DecodedToken;
let authType = "None";
const isPublic =
options.isPublic || (options.isPublicOnDev && isDevEnvironment());
const { authorization: authHeader } = req.headers;
try {
@ -82,7 +86,7 @@ async function _authenticateRequestInternal(
req.ctx.configuration,
options
);
} else if (options.isPublic === true) {
} else if (isPublic === true) {
token = {
type: "None",
uid: "",
@ -239,12 +243,17 @@ async function authenticateWithApeKey(
configuration: Configuration,
options: RequestAuthenticationOptions
): Promise<MonkeyTypes.DecodedToken> {
if (!configuration.apeKeys.acceptKeys) {
throw new MonkeyError(503, "ApeKeys are not being accepted at this time");
}
const isPublic =
options.isPublic || (options.isPublicOnDev && isDevEnvironment());
if (!options.acceptApeKeys && !options.isPublic) {
throw new MonkeyError(401, "This endpoint does not accept ApeKeys");
if (!isPublic) {
if (!configuration.apeKeys.acceptKeys) {
throw new MonkeyError(503, "ApeKeys are not being accepted at this time");
}
if (!options.acceptApeKeys) {
throw new MonkeyError(401, "This endpoint does not accept ApeKeys");
}
}
try {

View file

@ -1,6 +1,6 @@
import type { Response, NextFunction, RequestHandler } from "express";
import MonkeyError from "../utils/error";
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
export type ValidationOptions<T> = {
criteria: (data: T) => boolean;

View file

@ -4,18 +4,32 @@ import type { Response, NextFunction, RequestHandler } from "express";
import { getPartialUser } from "../dal/user";
import { isAdmin } from "../dal/admin-uids";
import type { ValidationOptions } from "./configuration";
import { TsRestRequestHandler } from "@ts-rest/express";
import { TsRestRequestWithCtx } from "./auth";
import { RequestAuthenticationOptions } from "@monkeytype/contracts/schemas/api";
import { isDevEnvironment } from "../utils/misc";
/**
* Check if the user is an admin before handling request.
* Note that this middleware must be used after authentication in the middleware stack.
*/
export function checkIfUserIsAdmin(): RequestHandler {
export function checkIfUserIsAdmin<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: MonkeyTypes.Request,
req: TsRestRequestWithCtx,
_res: Response,
next: NextFunction
) => {
try {
const options: RequestAuthenticationOptions =
req.tsRestRoute["metadata"]?.["authenticationOptions"] ?? {};
if (options.isPublicOnDev && isDevEnvironment()) {
next();
return;
}
const { uid } = req.ctx.decodedToken;
const admin = await isAdmin(uid);

View file

@ -1,7 +1,6 @@
import _ from "lodash";
import type { Request, Response, NextFunction, RequestHandler } from "express";
import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response";
import { isDevEnvironment } from "../utils/misc";
import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus";
export const emptyMiddleware = (
@ -36,17 +35,6 @@ export function asyncHandler(handler: AsyncHandler): RequestHandler {
};
}
/**
* Uses the middlewares only in production. Otherwise, uses an empty middleware.
*/
export function useInProduction(
middlewares: RequestHandler[]
): RequestHandler[] {
return middlewares.map((middleware) =>
isDevEnvironment() ? emptyMiddleware : middleware
);
}
/**
* record the client version from the `x-client-version` or ` client-version` header to prometheus
*/

View file

@ -2,7 +2,7 @@ import LRUCache from "lru-cache";
import Logger from "../utils/logger";
import { MonkeyQueue } from "./monkey-queue";
import { getCurrentDayTimestamp, getCurrentWeekTimestamp } from "../utils/misc";
import { ValidModeRule } from "@monkeytype/shared-types";
import { ValidModeRule } from "@monkeytype/contracts/schemas/configuration";
const QUEUE_NAME = "later";

View file

@ -1,4 +1,4 @@
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import * as RedisClient from "../init/redis";
import LaterQueue from "../queues/later-queue";
import { getCurrentWeekTimestamp } from "../utils/misc";

View file

@ -14,7 +14,7 @@ declare namespace MonkeyTypes {
};
export type Context = {
configuration: import("@monkeytype/shared-types").Configuration;
configuration: import("@monkeytype/contracts/schemas/configuration").Configuration;
decodedToken: DecodedToken;
};

View file

@ -2,12 +2,16 @@ import _, { omit } from "lodash";
import * as RedisClient from "../init/redis";
import LaterQueue from "../queues/later-queue";
import { getCurrentDayTimestamp, matchesAPattern, kogascore } from "./misc";
import { Configuration, ValidModeRule } from "@monkeytype/shared-types";
import {
Configuration,
ValidModeRule,
} from "@monkeytype/contracts/schemas/configuration";
import {
DailyLeaderboardRank,
LeaderboardEntry,
} from "@monkeytype/contracts/schemas/leaderboards";
import MonkeyError from "./error";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard";
const scoresNamespace = `${dailyLeaderboardNamespace}:scores`;
@ -221,14 +225,14 @@ function isValidModeRule(
export function getDailyLeaderboard(
language: string,
mode: string,
mode2: string,
mode: Mode,
mode2: Mode2<Mode>,
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
customTimestamp = -1
): DailyLeaderboard | null {
const { validModeRules, enabled } = dailyLeaderboardsConfig;
const modeRule = { language, mode, mode2 };
const modeRule: ValidModeRule = { language, mode, mode2 };
const isValidMode = isValidModeRule(modeRule, validModeRules);
if (!enabled || !isValidMode) {

View file

@ -1,11 +0,0 @@
import { Configuration } from "@monkeytype/shared-types";
export default class Root {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(): Ape.EndpointResponse<Configuration> {
return await this.httpClient.get("/configuration");
}
}

View file

@ -1,11 +1,9 @@
import Quotes from "./quotes";
import Users from "./users";
import Configuration from "./configuration";
import Dev from "./dev";
export default {
Quotes,
Users,
Configuration,
Dev,
};

View file

@ -16,7 +16,6 @@ const Ape = {
...tsRestClient,
users: new endpoints.Users(httpClient),
quotes: new endpoints.Quotes(httpClient),
configuration: new endpoints.Configuration(httpClient),
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
};

View file

@ -1,4 +1,4 @@
import { Configuration } from "@monkeytype/shared-types";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import Ape from ".";
let config: Configuration | undefined = undefined;
@ -11,9 +11,9 @@ export async function sync(): Promise<void> {
const response = await Ape.configuration.get();
if (response.status !== 200) {
console.error("Could not fetch configuration", response.message);
console.error("Could not fetch configuration", response.body.message);
return;
} else {
config = response.data ?? undefined;
config = response.body.data ?? undefined;
}
}

View file

@ -0,0 +1,96 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
EndpointMetadata,
MonkeyResponseSchema,
responseWithData,
} from "./schemas/api";
import { ConfigurationSchema } from "./schemas/configuration";
export const GetConfigurationResponseSchema =
responseWithData(ConfigurationSchema);
export type GetConfigurationResponse = z.infer<
typeof GetConfigurationResponseSchema
>;
export const PartialConfigurationSchema = ConfigurationSchema.deepPartial();
export type PartialConfiguration = z.infer<typeof PartialConfigurationSchema>;
export const PatchConfigurationRequestSchema = z
.object({
configuration: PartialConfigurationSchema.strict(),
})
.strict();
export type PatchConfigurationRequest = z.infer<
typeof PatchConfigurationRequestSchema
>;
export const ConfigurationSchemaResponseSchema = responseWithData(z.object({})); //TODO define schema?
export type ConfigurationSchemaResponse = z.infer<
typeof ConfigurationSchemaResponseSchema
>;
const c = initContract();
export const configurationContract = c.router(
{
get: {
summary: "get configuration",
description: "Get server configuration",
method: "GET",
path: "",
responses: {
200: GetConfigurationResponseSchema,
},
metadata: {
authenticationOptions: {
isPublic: true,
},
} as EndpointMetadata,
},
update: {
summary: "update configuration",
description:
"Update the server configuration. Only provided values will be updated while the missing values will be unchanged.",
method: "PATCH",
path: "",
body: PatchConfigurationRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
metadata: {
authenticationOptions: {
noCache: true,
isPublicOnDev: true,
},
} as EndpointMetadata,
},
getSchema: {
summary: "get configuration schema",
description: "Get schema definition of the server configuration.",
method: "GET",
path: "/schema",
responses: {
200: ConfigurationSchemaResponseSchema,
},
metadata: {
authenticationOptions: {
isPublicOnDev: true,
noCache: true,
},
} as EndpointMetadata,
},
},
{
pathPrefix: "/configuration",
strictStatusCodes: true,
metadata: {
openApiTags: "configuration",
} as EndpointMetadata,
commonResponses: CommonResponses,
}
);

View file

@ -7,6 +7,7 @@ import { psasContract } from "./psas";
import { publicContract } from "./public";
import { leaderboardsContract } from "./leaderboards";
import { resultsContract } from "./results";
import { configurationContract } from "./configuration";
const c = initContract();
@ -19,4 +20,5 @@ export const contract = c.router({
public: publicContract,
leaderboards: leaderboardsContract,
results: resultsContract,
configuration: configurationContract,
});

View file

@ -8,7 +8,8 @@ export type OpenApiTag =
| "psas"
| "public"
| "leaderboards"
| "results";
| "results"
| "configuration";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */
@ -24,6 +25,8 @@ export type RequestAuthenticationOptions = {
/** Endpoint requires an authentication token which is younger than one minute. */
requireFreshToken?: boolean;
noCache?: boolean;
/** Allow unauthenticated requests on dev */
isPublicOnDev?: boolean;
};
export const MonkeyResponseSchema = z.object({

View file

@ -0,0 +1,121 @@
import { z } from "zod";
/* ValidModeRuleSchema allows complex rules like `"mode2": "(15|60)"`. We don't want a strict validation here. */
export const ValidModeRuleSchema = z
.object({
language: z.string(),
mode: z.string(),
mode2: z.string(),
})
.strict();
export type ValidModeRule = z.infer<typeof ValidModeRuleSchema>;
export const RewardBracketSchema = z
.object({
minRank: z.number().int().nonnegative(),
maxRank: z.number().int().nonnegative(),
minReward: z.number().int().nonnegative(),
maxReward: z.number().int().nonnegative(),
})
.strict();
export type RewardBracket = z.infer<typeof RewardBracketSchema>;
export const ConfigurationSchema = z.object({
maintenance: z.boolean(),
dev: z.object({
responseSlowdownMs: z.number().int().nonnegative(),
}),
quotes: z.object({
reporting: z.object({
enabled: z.boolean(),
maxReports: z.number().int().nonnegative(),
contentReportLimit: z.number().int().nonnegative(),
}),
submissionsEnabled: z.boolean(),
maxFavorites: z.number().int().nonnegative(),
}),
results: z.object({
savingEnabled: z.boolean(),
objectHashCheckEnabled: z.boolean(),
filterPresets: z.object({
enabled: z.boolean(),
maxPresetsPerUser: z.number().int().nonnegative(),
}),
limits: z.object({
regularUser: z.number().int().nonnegative(),
premiumUser: z.number().int().nonnegative(),
}),
maxBatchSize: z.number().int().nonnegative(),
}),
users: z.object({
signUp: z.boolean(),
lastHashesCheck: z.object({
enabled: z.boolean(),
maxHashes: z.number().int().nonnegative(),
}),
autoBan: z.object({
enabled: z.boolean(),
maxCount: z.number().int().nonnegative(),
maxHours: z.number().int().nonnegative(),
}),
profiles: z.object({
enabled: z.boolean(),
}),
discordIntegration: z.object({
enabled: z.boolean(),
}),
xp: z.object({
enabled: z.boolean(),
funboxBonus: z.number(),
gainMultiplier: z.number(),
maxDailyBonus: z.number(),
minDailyBonus: z.number(),
streak: z.object({
enabled: z.boolean(),
maxStreakDays: z.number().nonnegative(),
maxStreakMultiplier: z.number(),
}),
}),
inbox: z.object({
enabled: z.boolean(),
maxMail: z.number().int().nonnegative(),
}),
premium: z.object({
enabled: z.boolean(),
}),
}),
admin: z.object({
endpointsEnabled: z.boolean(),
}),
apeKeys: z.object({
endpointsEnabled: z.boolean(),
acceptKeys: z.boolean(),
maxKeysPerUser: z.number().int().nonnegative(),
apeKeyBytes: z.number().int().nonnegative(),
apeKeySaltRounds: z.number().int().nonnegative(),
}),
rateLimiting: z.object({
badAuthentication: z.object({
enabled: z.boolean(),
penalty: z.number(),
flaggedStatusCodes: z.array(z.number().int().nonnegative()),
}),
}),
dailyLeaderboards: z.object({
enabled: z.boolean(),
leaderboardExpirationTimeInDays: z.number().nonnegative(),
maxResults: z.number().int().nonnegative(),
validModeRules: z.array(ValidModeRuleSchema),
scheduleRewardsModeRules: z.array(ValidModeRuleSchema),
topResultsToAnnounce: z.number().int().positive(), // This should never be 0. Setting to zero will announce all results.
xpRewardBrackets: z.array(RewardBracketSchema),
}),
leaderboards: z.object({
weeklyXp: z.object({
enabled: z.boolean(),
expirationTimeInDays: z.number().nonnegative(),
xpRewardBrackets: z.array(RewardBracketSchema),
}),
}),
});
export type Configuration = z.infer<typeof ConfigurationSchema>;

View file

@ -2,117 +2,6 @@ type PersonalBest = import("@monkeytype/contracts/schemas/shared").PersonalBest;
type PersonalBests =
import("@monkeytype/contracts/schemas/shared").PersonalBests;
export type ValidModeRule = {
language: string;
mode: string;
mode2: string;
};
export type RewardBracket = {
minRank: number;
maxRank: number;
minReward: number;
maxReward: number;
};
export type Configuration = {
maintenance: boolean;
dev: {
responseSlowdownMs: number;
};
quotes: {
reporting: {
enabled: boolean;
maxReports: number;
contentReportLimit: number;
};
submissionsEnabled: boolean;
maxFavorites: number;
};
results: {
savingEnabled: boolean;
objectHashCheckEnabled: boolean;
filterPresets: {
enabled: boolean;
maxPresetsPerUser: number;
};
limits: {
regularUser: number;
premiumUser: number;
};
maxBatchSize: number;
};
users: {
signUp: boolean;
lastHashesCheck: {
enabled: boolean;
maxHashes: number;
};
autoBan: {
enabled: boolean;
maxCount: number;
maxHours: number;
};
profiles: {
enabled: boolean;
};
discordIntegration: {
enabled: boolean;
};
xp: {
enabled: boolean;
funboxBonus: number;
gainMultiplier: number;
maxDailyBonus: number;
minDailyBonus: number;
streak: {
enabled: boolean;
maxStreakDays: number;
maxStreakMultiplier: number;
};
};
inbox: {
enabled: boolean;
maxMail: number;
};
premium: {
enabled: boolean;
};
};
admin: {
endpointsEnabled: boolean;
};
apeKeys: {
endpointsEnabled: boolean;
acceptKeys: boolean;
maxKeysPerUser: number;
apeKeyBytes: number;
apeKeySaltRounds: number;
};
rateLimiting: {
badAuthentication: {
enabled: boolean;
penalty: number;
flaggedStatusCodes: number[];
};
};
dailyLeaderboards: {
enabled: boolean;
leaderboardExpirationTimeInDays: number;
maxResults: number;
validModeRules: ValidModeRule[];
scheduleRewardsModeRules: ValidModeRule[];
topResultsToAnnounce: number;
xpRewardBrackets: RewardBracket[];
};
leaderboards: {
weeklyXp: {
enabled: boolean;
expirationTimeInDays: number;
xpRewardBrackets: RewardBracket[];
};
};
};
export type CustomTextLimit = {
value: number;
mode: import("@monkeytype/contracts/schemas/util").CustomTextLimitMode;