impr: use tsrest/zod for config endpoints (@fehmer) (#5649)

!nuf
This commit is contained in:
Christian Fehmer 2024-07-29 11:31:14 +02:00 committed by GitHub
parent d96832c927
commit 7f9f704dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 4556 additions and 832 deletions

View file

@ -1,3 +1,4 @@
backend/dist
backend/__migration__
docker
docker
backend/scripts

View file

@ -0,0 +1,132 @@
import request from "supertest";
import app from "../../../src/app";
import * as ConfigDal from "../../../src/dal/config";
import { ObjectId } from "mongodb";
const mockApp = request(app);
describe("ConfigController", () => {
describe("get config", () => {
const getConfigMock = vi.spyOn(ConfigDal, "getConfig");
afterEach(() => {
getConfigMock.mockReset();
});
it("should get the users config", async () => {
//GIVEN
getConfigMock.mockResolvedValue({
_id: new ObjectId(),
uid: "123456789",
config: { language: "english" },
});
//WHEN
const { body } = await mockApp
.get("/configs")
.set("authorization", "Uid 123456789")
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Configuration retrieved",
data: { language: "english" },
});
expect(getConfigMock).toHaveBeenCalledWith("123456789");
});
});
describe("update config", () => {
const saveConfigMock = vi.spyOn(ConfigDal, "saveConfig");
afterEach(() => {
saveConfigMock.mockReset();
});
it("should update the users config", async () => {
//GIVEN
saveConfigMock.mockResolvedValue({} as any);
//WHEN
const { body } = await mockApp
.patch("/configs")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ language: "english" })
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Config updated",
data: null,
});
expect(saveConfigMock).toHaveBeenCalledWith("123456789", {
language: "english",
});
});
it("should fail with unknown config", async () => {
//WHEN
const { body } = await mockApp
.patch("/configs")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ unknownValue: "unknown" })
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`Unrecognized key(s) in object: 'unknownValue'`],
});
expect(saveConfigMock).not.toHaveBeenCalled();
});
it("should fail with invalid configs", async () => {
//WHEN
const { body } = await mockApp
.patch("/configs")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ autoSwitchTheme: "yes", confidenceMode: "pretty" })
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"autoSwitchTheme" Expected boolean, received string`,
`"confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
],
});
expect(saveConfigMock).not.toHaveBeenCalled();
});
});
describe("delete config", () => {
const deleteConfigMock = vi.spyOn(ConfigDal, "deleteConfig");
afterEach(() => {
deleteConfigMock.mockReset();
});
it("should delete the users config", async () => {
//GIVEN
deleteConfigMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.delete("/configs")
.set("authorization", "Uid 123456789")
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Config deleted",
data: null,
});
expect(deleteConfigMock).toHaveBeenCalledWith("123456789");
});
});
});

View file

@ -1,6 +1,6 @@
import * as AuthUtils from "../../src/utils/auth";
import * as Auth from "../../src/middlewares/auth";
import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier";
import { DecodedIdToken } from "firebase-admin/auth";
import { NextFunction, Request, Response } from "express";
import { getCachedConfiguration } from "../../src/init/configuration";
import * as ApeKeys from "../../src/dal/ape-keys";
@ -8,6 +8,11 @@ import { ObjectId } from "mongodb";
import { hashSync } from "bcrypt";
import MonkeyError from "../../src/utils/error";
import * as Misc from "../../src/utils/misc";
import {
EndpointMetadata,
RequestAuthenticationOptions,
} from "@monkeytype/contracts/schemas/api";
import * as Prometheus from "../../src/utils/prometheus";
const mockDecodedToken: DecodedIdToken = {
uid: "123456789",
@ -31,12 +36,11 @@ const mockApeKey = {
vi.spyOn(ApeKeys, "getApeKey").mockResolvedValue(mockApeKey);
vi.spyOn(ApeKeys, "updateLastUsedOn").mockResolvedValue();
const isDevModeMock = vi.spyOn(Misc, "isDevEnvironment");
let mockRequest: Partial<MonkeyTypes.Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;
describe("middlewares/auth", () => {
let mockRequest: Partial<MonkeyTypes.Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;
beforeEach(async () => {
isDevModeMock.mockReturnValue(true);
let config = await getCachedConfiguration(true);
@ -258,4 +262,253 @@ describe("middlewares/auth", () => {
);
});
});
describe("authenticateTsRestRequest", () => {
const prometheusRecordAuthTimeMock = vi.spyOn(Prometheus, "recordAuthTime");
const prometheusIncrementAuthMock = vi.spyOn(Prometheus, "incrementAuth");
beforeEach(() =>
[prometheusIncrementAuthMock, prometheusRecordAuthTimeMock].forEach(
(it) => it.mockReset()
)
);
it("should fail if token is not fresh", async () => {
//GIVEN
Date.now = vi.fn(() => 60001);
//WHEN
expect(() =>
authenticate({}, { requireFreshToken: true })
).rejects.toThrowError(
"Unauthorized\nStack: This endpoint requires a fresh token"
);
//THEN
expect(nextFunction).not.toHaveBeenCalled();
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).not.toHaveBeenCalled();
});
it("should allow the request if token is fresh", async () => {
//GIVEN
Date.now = vi.fn(() => 10000);
//WHEN
const result = await authenticate({}, { requireFreshToken: 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).toHaveBeenCalledOnce();
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("Bearer");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
});
it("should allow the request if apeKey is supported", async () => {
//WHEN
const result = await authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: true }
);
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("ApeKey");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should fail wit apeKey if apeKey is not supported", async () => {
//WHEN
await expect(() =>
authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: false }
)
).rejects.toThrowError("This endpoint does not accept ApeKeys");
//THEN
});
it("should allow the request with authentation on public endpoint", async () => {
//WHEN
const result = await authenticate({}, { isPublic: 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 public endpoint", async () => {
//WHEN
const result = await authenticate({ headers: {} }, { isPublic: 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 public endpoint", async () => {
//WHEN
const result = await authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ isPublic: 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 request with Uid on dev", async () => {
//WHEN
const result = await authenticate({
headers: { authorization: "Uid 123" },
});
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("Bearer");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should allow request with Uid and email on dev", async () => {
const result = await authenticate({
headers: { authorization: "Uid 123|test@example.com" },
});
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("Bearer");
expect(decodedToken?.email).toBe("test@example.com");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should fail request with Uid on non-dev", async () => {
//GIVEN
isDevModeMock.mockReturnValue(false);
//WHEN / THEN
await expect(() =>
authenticate({ headers: { authorization: "Uid 123" } })
).rejects.toThrow(
new MonkeyError(401, "Baerer type uid is not supported")
);
});
it("should fail without authentication", async () => {
await expect(() => authenticate({ headers: {} })).rejects.toThrowError(
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found"
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"None",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail with empty authentication", async () => {
await expect(() =>
authenticate({ headers: { authorization: "" } })
).rejects.toThrowError(
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found"
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail with missing authentication token", async () => {
await expect(() =>
authenticate({ headers: { authorization: "Bearer" } })
).rejects.toThrowError(
"Missing authentication token\nStack: authenticateWithAuthHeader"
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"Bearer",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail with unknown authentication scheme", async () => {
await expect(() =>
authenticate({ headers: { authorization: "unknown format" } })
).rejects.toThrowError(
'Unknown authentication scheme\nStack: The authentication scheme "unknown" is not implemented'
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"unknown",
"failure",
expect.anything(),
expect.anything()
);
});
it("should record country if provided", async () => {
const prometheusRecordRequestCountryMock = vi.spyOn(
Prometheus,
"recordRequestCountry"
);
await authenticate(
{ headers: { "cf-ipcountry": "gb" } },
{ isPublic: true }
);
//THEN
expect(prometheusRecordRequestCountryMock).toHaveBeenCalledWith(
"gb",
expect.anything()
);
});
});
});
async function authenticate(
request: Partial<Request>,
authenticationOptions?: RequestAuthenticationOptions
): Promise<{ decodedToken: MonkeyTypes.DecodedToken }> {
const mergedRequest = {
...mockRequest,
...request,
tsRestRoute: {
metadata: { authenticationOptions } as EndpointMetadata,
},
} as any;
await Auth.authenticateTsRestRequest()(
mergedRequest,
mockResponse as Response,
nextFunction
);
return { decodedToken: mergedRequest.ctx.decodedToken };
}

View file

@ -1,18 +1,6 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"incremental": true,
"module": "commonjs",
"target": "es6",
"sourceMap": false,
"allowJs": true,
"checkJs": true,
"outDir": "build",
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strictNullChecks": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vitest/globals"]
},

View file

@ -1,5 +1,6 @@
import _ from "lodash";
import * as misc from "../../src/utils/misc";
import { ObjectId } from "mongodb";
describe("Misc Utils", () => {
afterAll(() => {
@ -605,4 +606,19 @@ describe("Misc Utils", () => {
expect(misc.formatSeconds(seconds)).toBe(expected);
});
});
describe("replaceObjectId", () => {
it("replaces objecId with string", () => {
const fromDatabase = {
_id: new ObjectId(),
test: "test",
number: 1,
};
expect(misc.replaceObjectId(fromDatabase)).toStrictEqual({
_id: fromDatabase._id.toHexString(),
test: "test",
number: 1,
});
});
});
});

View file

@ -5,17 +5,18 @@
"private": true,
"scripts": {
"lint": "eslint \"./src/**/*.ts\"",
"build": "tsc --build",
"build": "npm run gen-docs && tsc --build",
"watch": "tsc --build --watch",
"clean": "tsc --build --clean",
"ts-check": "tsc --noEmit",
"start": "npm run build && node ./dist/server.js",
"start": "node ./dist/server.js",
"test": "vitest run",
"test-coverage": "vitest run --coverage",
"dev": "concurrently \"tsx watch --clear-screen=false ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"npx eslint-watch \"./src/**/*.ts\"\"",
"knip": "knip",
"docker-db-only": "docker compose -f docker/compose.db-only.yml up",
"docker": "docker compose -f docker/compose.yml up"
"docker": "docker compose -f docker/compose.yml up",
"gen-docs": "tsx scripts/openapi.ts dist/static/api/openapi.json && redocly build-docs -o dist/static/api/internal.html internal@v2 && redocly bundle -o dist/static/api/public.json public-filter && redocly build-docs -o dist/static/api/public.html public@v2"
},
"engines": {
"node": "18.20.4",
@ -23,6 +24,9 @@
},
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "*",
"@ts-rest/express": "3.45.2",
"@ts-rest/open-api": "3.45.2",
"bcrypt": "5.1.1",
"bullmq": "1.91.1",
"chalk": "4.1.2",
@ -60,6 +64,7 @@
"@monkeytype/shared-types": "*",
"@monkeytype/typescript-config": "*",
"@monkeytype/eslint-config": "*",
"@redocly/cli": "1.18.1",
"@types/bcrypt": "5.0.0",
"@types/cors": "2.8.12",
"@types/cron": "1.7.3",

46
backend/redocly.yaml Normal file
View file

@ -0,0 +1,46 @@
extends:
- recommended
apis:
internal@v2:
root: dist/static/api/openapi.json
public-filter:
root: dist/static/api/openapi.json
decorators:
filter-in:
property: x-public
value: yes
public@v2:
root: dist/static/api/public.json
features.openapi:
theme:
logo:
gutter: "2rem"
colors:
primary:
main: "#e2b714"
border:
dark: "#e2b714"
light: "#e2b714"
error:
main: "#da3333"
success:
main: "#009400"
text:
primary: "#646669"
secondary: "#d1d0c5"
warning:
main: "#FF00FF"
http:
delete: "#da3333"
post: "#004D94"
patch: "#e2b714"
get: "#009400"
sidebar:
backgroundColor: "#323437"
textColor: "#d1d0c5"
activeTextColor: "#e2b714"
rightPanel:
backgroundColor: "#323437"
textColor: "#d1d0c5"

120
backend/scripts/openapi.ts Normal file
View file

@ -0,0 +1,120 @@
import { generateOpenApi } from "@ts-rest/open-api";
import { contract } from "@monkeytype/contracts/index";
import { writeFileSync, mkdirSync } from "fs";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
import type { OpenAPIObject } from "openapi3-ts";
type SecurityRequirementObject = {
[name: string]: string[];
};
export function getOpenApi(): OpenAPIObject {
const openApiDocument = generateOpenApi(
contract,
{
openapi: "3.1.0",
info: {
title: "Monkeytype API",
description:
"Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.",
version: "2.0.0",
termsOfService: "https://monkeytype.com/terms-of-service",
contact: {
name: "Support",
email: "support@monkeytype.com",
},
"x-logo": {
url: "https://monkeytype.com/images/mtfulllogo.png",
},
license: {
name: "GPL-3.0",
url: "https://www.gnu.org/licenses/gpl-3.0.html",
},
},
servers: [
{
url: "https://api.monkeytype.com",
description: "Production server",
},
],
components: {
securitySchemes: {
BearerAuth: {
type: "http",
scheme: "bearer",
},
ApeKey: {
type: "http",
scheme: "ApeKey",
},
},
},
tags: [
{
name: "configs",
description:
"User specific configurations like test settings, theme or tags.",
"x-displayName": "User configuration",
},
],
},
{
jsonQuery: true,
setOperationId: "concatenated-path",
operationMapper: (operation, route) => ({
...operation,
...addAuth(route.metadata as EndpointMetadata),
...addTags(route.metadata as EndpointMetadata),
}),
}
);
return openApiDocument;
}
function addAuth(metadata: EndpointMetadata | undefined): object {
const auth = metadata?.["authenticationOptions"] ?? {};
const security: SecurityRequirementObject[] = [];
if (!auth.isPublic === true) {
security.push({ BearerAuth: [] });
if (auth.acceptApeKeys === true) {
security.push({ ApeKey: [] });
}
}
const includeInPublic = auth.isPublic === true || auth.acceptApeKeys === true;
return {
"x-public": includeInPublic ? "yes" : "no",
security,
};
}
function addTags(metadata: EndpointMetadata | undefined): object {
if (metadata === undefined || metadata.openApiTags === undefined) return {};
return {
tags: Array.isArray(metadata.openApiTags)
? metadata.openApiTags
: [metadata.openApiTags],
};
}
//detect if we run this as a main
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length !== 1) {
console.error("Provide filename.");
process.exit(1);
}
const outFile = args[0] as string;
//create directories if needed
const lastSlash = outFile.lastIndexOf("/");
if (lastSlash > 1) {
const dir = outFile.substring(0, lastSlash);
mkdirSync(dir, { recursive: true });
}
const openapi = getOpenApi();
writeFileSync(args[0] as string, JSON.stringify(openapi, null, 2));
}

View file

@ -1,22 +1,33 @@
import { PartialConfig } from "@monkeytype/contracts/schemas/configs";
import * as ConfigDAL from "../../dal/config";
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { GetConfigResponse } from "@monkeytype/contracts/configs";
export async function getConfig(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<GetConfigResponse> {
const { uid } = req.ctx.decodedToken;
const data = (await ConfigDAL.getConfig(uid))?.config ?? null;
const data = await ConfigDAL.getConfig(uid);
return new MonkeyResponse("Configuration retrieved", data);
return new MonkeyResponse2("Configuration retrieved", data);
}
export async function saveConfig(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { config } = req.body;
req: MonkeyTypes.Request2<undefined, PartialConfig>
): Promise<MonkeyResponse2> {
const config = req.body;
const { uid } = req.ctx.decodedToken;
await ConfigDAL.saveConfig(uid, config);
return new MonkeyResponse("Config updated");
return new MonkeyResponse2("Config updated");
}
export async function deleteConfig(
req: MonkeyTypes.Request2
): Promise<MonkeyResponse2> {
const { uid } = req.ctx.decodedToken;
await ConfigDAL.deleteConfig(uid);
return new MonkeyResponse2("Config deleted");
}

View file

@ -1,30 +1,23 @@
import { Router } from "express";
import { authenticateRequest } from "../../middlewares/auth";
import configSchema from "../schemas/config-schema";
import * as ConfigController from "../controllers/config";
import { configsContract } from "@monkeytype/contracts/configs";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import { asyncHandler } from "../../middlewares/utility";
import { validateRequest } from "../../middlewares/validation";
import * as ConfigController from "../controllers/config";
import { callController } from "../ts-rest-adapter";
const router = Router();
const s = initServer();
router.get(
"/",
authenticateRequest(),
RateLimit.configGet,
asyncHandler(ConfigController.getConfig)
);
export default s.router(configsContract, {
get: {
middleware: [RateLimit.configGet],
handler: async (r) => callController(ConfigController.getConfig)(r),
},
router.patch(
"/",
authenticateRequest(),
RateLimit.configUpdate,
validateRequest({
body: {
config: configSchema.required(),
},
}),
asyncHandler(ConfigController.saveConfig)
);
export default router;
save: {
middleware: [RateLimit.configUpdate],
handler: async (r) => callController(ConfigController.saveConfig)(r),
},
delete: {
middleware: [RateLimit.configDelete],
handler: async (r) => callController(ConfigController.deleteConfig)(r),
},
});

View file

@ -0,0 +1,40 @@
import { Router } from "express";
import * as swaggerUi from "swagger-ui-express";
import publicSwaggerSpec from "../../documentation/public-swagger.json";
const SWAGGER_UI_OPTIONS = {
customCss: ".swagger-ui .topbar { display: none } .try-out { display: none }",
customSiteTitle: "Monkeytype API Documentation",
};
const router = Router();
const root = __dirname + "../../../static";
router.use("/v2/internal", (req, res) => {
res.sendFile("api/internal.html", { root });
});
router.use("/v2/internal.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.sendFile("api/openapi.json", { root });
});
router.use("/v2/public", (req, res) => {
res.sendFile("api/public.html", { root });
});
router.use("/v2/public.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.sendFile("api/public.json", { root });
});
const options = {};
router.use(
"/",
swaggerUi.serveFiles(publicSwaggerSpec, options),
swaggerUi.setup(publicSwaggerSpec, SWAGGER_UI_OPTIONS)
);
export default router;

View file

@ -1,16 +1,18 @@
import _ from "lodash";
import { contract } from "@monkeytype/contracts/index";
import psas from "./psas";
import publicStats from "./public";
import users from "./users";
import { join } from "path";
import quotes from "./quotes";
import configs from "./configs";
import results from "./results";
import presets from "./presets";
import apeKeys from "./ape-keys";
import admin from "./admin";
import docs from "./docs";
import webhooks from "./webhooks";
import dev from "./dev";
import configs from "./configs";
import configuration from "./configuration";
import { version } from "../../version";
import leaderboards from "./leaderboards";
@ -19,15 +21,20 @@ import { asyncHandler } from "../../middlewares/utility";
import { MonkeyResponse } from "../../utils/monkey-response";
import { recordClientVersion } from "../../utils/prometheus";
import {
type Application,
type NextFunction,
type Response,
Application,
IRouter,
NextFunction,
Response,
Router,
static as expressStatic,
} from "express";
import { isDevEnvironment } from "../../utils/misc";
import { getLiveConfiguration } from "../../init/configuration";
import Logger from "../../utils/logger";
import { createExpressEndpoints, initServer } from "@ts-rest/express";
import { ZodIssue } from "zod";
import { MonkeyValidationError } from "@monkeytype/contracts/schemas/api";
import { authenticateTsRestRequest } from "../../middlewares/auth";
const pathOverride = process.env["API_PATH_OVERRIDE"];
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
@ -35,7 +42,6 @@ const APP_START_TIME = Date.now();
const API_ROUTE_MAP = {
"/users": users,
"/configs": configs,
"/results": results,
"/presets": presets,
"/psas": psas,
@ -45,13 +51,52 @@ const API_ROUTE_MAP = {
"/ape-keys": apeKeys,
"/admin": admin,
"/webhooks": webhooks,
"/docs": docs,
};
function addApiRoutes(app: Application): void {
app.get("/leaderboard", (_req, res) => {
res.sendStatus(404);
});
const s = initServer();
const router = s.router(contract, {
configs,
});
export function addApiRoutes(app: Application): void {
applyDevApiRoutes(app);
applyApiRoutes(app);
applyTsRestApiRoutes(app);
app.use(
asyncHandler(async (req, _res) => {
return new MonkeyResponse(
`Unknown request URL (${req.method}: ${req.path})`,
null,
404
);
})
);
}
function applyTsRestApiRoutes(app: IRouter): void {
createExpressEndpoints(contract, router, app, {
jsonQuery: true,
requestValidationErrorHandler(err, req, res, next) {
if (err.body?.issues === undefined) return next();
const issues = err.body?.issues.map(prettyErrorMessage);
res.status(422).json({
message: "Invalid request data schema",
validationErrors: issues,
} as MonkeyValidationError);
},
globalMiddleware: [authenticateTsRestRequest()],
});
}
function prettyErrorMessage(issue: ZodIssue | undefined): string {
if (issue === undefined) return "";
const path = issue.path.length > 0 ? `"${issue.path.join(".")}" ` : "";
return `${path}${issue.message}`;
}
function applyDevApiRoutes(app: Application): void {
if (isDevEnvironment()) {
//disable csp to allow assets to load from unsecured http
app.use((req, res, next) => {
@ -72,7 +117,9 @@ function addApiRoutes(app: Application): void {
//enable dev edpoints
app.use("/dev", dev);
}
}
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);
@ -110,6 +157,7 @@ function addApiRoutes(app: Application): void {
})
);
//legacy route
app.get("/psa", (_req, res) => {
res.json([
{
@ -124,16 +172,4 @@ function addApiRoutes(app: Application): void {
const apiRoute = `${BASE_ROUTE}${route}`;
app.use(apiRoute, router);
});
app.use(
asyncHandler(async (req, _res) => {
return new MonkeyResponse(
`Unknown request URL (${req.method}: ${req.path})`,
null,
404
);
})
);
}
export default addApiRoutes;

View file

@ -1,19 +1,8 @@
import _ from "lodash";
import { type Application } from "express";
import { Application } from "express";
import { getMiddleware as getSwaggerMiddleware } from "swagger-stats";
import {
serve as serveSwagger,
setup as setupSwaggerUi,
} from "swagger-ui-express";
import publicSwaggerSpec from "../../documentation/public-swagger.json";
import internalSwaggerSpec from "../../documentation/internal-swagger.json";
import { isDevEnvironment } from "../../utils/misc";
const SWAGGER_UI_OPTIONS = {
customCss: ".swagger-ui .topbar { display: none } .try-out { display: none }",
customSiteTitle: "Monkeytype API Documentation",
};
function addSwaggerMiddlewares(app: Application): void {
app.use(
getSwaggerMiddleware({
@ -30,12 +19,6 @@ function addSwaggerMiddlewares(app: Application): void {
},
})
);
app.use(
["/documentation", "/docs"],
serveSwagger,
setupSwaggerUi(publicSwaggerSpec, SWAGGER_UI_OPTIONS)
);
}
export default addSwaggerMiddlewares;

View file

@ -10,7 +10,7 @@ const CARET_STYLES = [
"carrot",
"banana",
];
//TODO replaced, still used by presets
const CONFIG_SCHEMA = joi.object({
theme: joi.string().max(50).token(),
themeLight: joi.string().max(50).token(),
@ -80,7 +80,7 @@ const CONFIG_SCHEMA = joi.object({
.valid("lowercase", "uppercase", "blank", "dynamic"),
keymapLayout: joi
.string()
.regex(/[\w-_]+/)
.regex(/[\w\-_]+/)
.valid()
.max(50),
keymapShowTopRow: joi.string().valid("always", "layout", "never"),

View file

@ -0,0 +1,73 @@
import { AppRoute, AppRouter } from "@ts-rest/core";
import { TsRestRequest } from "@ts-rest/express";
import { MonkeyResponse2 } from "../utils/monkey-response";
export function callController<
TRoute extends AppRoute | AppRouter,
TQuery,
TBody,
TParams,
TResponse,
TStatus = 200
>(
handler: Handler<TQuery, TBody, TParams, TResponse>
): (all: RequestType2<TRoute, TQuery, TBody, TParams>) => Promise<{
status: TStatus;
body: { message: string; data: TResponse };
}> {
return async (all) => {
const req: MonkeyTypes.Request2<TQuery, TBody, TParams> = {
body: all.body as TBody,
query: all.query as TQuery,
params: all.params as TParams,
raw: all.req,
ctx: all.req["ctx"],
};
const result = await handler(req);
const response = {
status: 200 as TStatus,
body: {
message: result.message,
data: result.data as TResponse,
},
};
return response;
};
}
type WithBody<T> = {
body: T;
};
type WithQuery<T> = {
query: T;
};
type WithParams<T> = {
params: T;
};
type WithoutBody = {
body?: never;
};
type WithoutQuery = {
query?: never;
};
type WithoutParams = {
params?: never;
};
type Handler<TQuery, TBody, TParams, TResponse> = (
req: MonkeyTypes.Request2<TQuery, TBody, TParams>
) => Promise<MonkeyResponse2<TResponse>>;
type RequestType2<
TRoute extends AppRoute | AppRouter,
TQuery,
TBody,
TParams
> = {
req: TsRestRequest<TRoute>;
} & (TQuery extends undefined ? WithoutQuery : WithQuery<TQuery>) &
(TBody extends undefined ? WithoutBody : WithBody<TBody>) &
(TParams extends undefined ? WithoutParams : WithParams<TParams>);

View file

@ -1,6 +1,6 @@
import cors from "cors";
import helmet from "helmet";
import addApiRoutes from "./api/routes";
import { addApiRoutes } from "./api/routes";
import express, { urlencoded, json } from "express";
import contextMiddleware from "./middlewares/context";
import errorHandlingMiddleware from "./middlewares/error";

View file

@ -1,7 +1,7 @@
import { type UpdateResult } from "mongodb";
import { Collection, UpdateResult } from "mongodb";
import * as db from "../init/db";
import _ from "lodash";
import { Config } from "@monkeytype/shared-types/config";
import { Config, PartialConfig } from "@monkeytype/contracts/schemas/configs";
const configLegacyProperties = [
"swapEscAndTab",
@ -23,9 +23,19 @@ const configLegacyProperties = [
"enableAds",
];
type DBConfig = {
_id: ObjectId;
uid: string;
config: PartialConfig;
};
// Export for use in tests
export const getConfigCollection = (): Collection<DBConfig> =>
db.collection<DBConfig>("configs");
export async function saveConfig(
uid: string,
config: Config
config: Partial<Config>
): Promise<UpdateResult> {
const configChanges = _.mapKeys(config, (_value, key) => `config.${key}`);
@ -33,20 +43,18 @@ export async function saveConfig(
_.map(configLegacyProperties, (key) => [`config.${key}`, ""])
) as Record<string, "">;
return await db
.collection<Config>("configs")
.updateOne(
{ uid },
{ $set: configChanges, $unset: unset },
{ upsert: true }
);
return await getConfigCollection().updateOne(
{ uid },
{ $set: configChanges, $unset: unset },
{ upsert: true }
);
}
export async function getConfig(uid: string): Promise<Config | null> {
const config = await db.collection<Config>("configs").findOne({ uid });
export async function getConfig(uid: string): Promise<DBConfig | null> {
const config = await getConfigCollection().findOne({ uid });
return config;
}
export async function deleteConfig(uid: string): Promise<void> {
await db.collection<Config>("configs").deleteOne({ uid });
await getConfigCollection().deleteOne({ uid });
}

View file

@ -3,32 +3,52 @@ import { getApeKey, updateLastUsedOn } from "../dal/ape-keys";
import MonkeyError from "../utils/error";
import { verifyIdToken } from "../utils/auth";
import { base64UrlDecode, isDevEnvironment } from "../utils/misc";
import type { NextFunction, Response, Handler } from "express";
import { NextFunction, Response, Handler } from "express";
import statuses from "../constants/monkey-status-codes";
import {
incrementAuth,
recordAuthTime,
recordRequestCountry,
// recordRequestForUid,
} from "../utils/prometheus";
import crypto from "crypto";
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";
type RequestAuthenticationOptions = {
isPublic?: boolean;
acceptApeKeys?: boolean;
requireFreshToken?: boolean;
noCache?: boolean;
};
const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
isPublic: false,
acceptApeKeys: false,
requireFreshToken: false,
};
function authenticateRequest(authOptions = DEFAULT_OPTIONS): Handler {
export type TsRestRequestWithCtx = {
ctx: Readonly<MonkeyTypes.Context>;
} & TsRestRequest;
/**
* Authenticate request based on the auth settings of the route.
* By default a Bearer token with user authentication is required.
* @returns
*/
export function authenticateTsRestRequest<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: TsRestRequestWithCtx,
_res: Response,
next: NextFunction
): Promise<void> => {
const options = {
...DEFAULT_OPTIONS,
...(req.tsRestRoute["metadata"]?.["authenticationOptions"] ?? {}),
};
return _authenticateRequestInternal(req, _res, next, options);
};
}
export function authenticateRequest(authOptions = DEFAULT_OPTIONS): Handler {
const options = {
...DEFAULT_OPTIONS,
...authOptions,
@ -39,69 +59,78 @@ function authenticateRequest(authOptions = DEFAULT_OPTIONS): Handler {
_res: Response,
next: NextFunction
): Promise<void> => {
const startTime = performance.now();
let token: MonkeyTypes.DecodedToken;
let authType = "None";
return _authenticateRequestInternal(req, _res, next, options);
};
}
const { authorization: authHeader } = req.headers;
async function _authenticateRequestInternal(
req: MonkeyTypes.Request | TsRestRequestWithCtx,
_res: Response,
next: NextFunction,
options: RequestAuthenticationOptions
): Promise<void> {
const startTime = performance.now();
let token: MonkeyTypes.DecodedToken;
let authType = "None";
try {
if (authHeader !== undefined && authHeader !== "") {
token = await authenticateWithAuthHeader(
authHeader,
req.ctx.configuration,
options
);
} else if (options.isPublic === true) {
token = {
type: "None",
uid: "",
email: "",
};
} else {
throw new MonkeyError(
401,
"Unauthorized",
`endpoint: ${req.baseUrl} no authorization header found`
);
}
const { authorization: authHeader } = req.headers;
incrementAuth(token.type);
req.ctx = {
...req.ctx,
decodedToken: token,
};
} catch (error) {
authType = authHeader?.split(" ")[0] ?? "None";
recordAuthTime(
authType,
"failure",
Math.round(performance.now() - startTime),
req
try {
if (authHeader !== undefined && authHeader !== "") {
token = await authenticateWithAuthHeader(
authHeader,
req.ctx.configuration,
options
);
} else if (options.isPublic === true) {
token = {
type: "None",
uid: "",
email: "",
};
} else {
throw new MonkeyError(
401,
"Unauthorized",
`endpoint: ${req.baseUrl} no authorization header found`
);
return next(error);
}
incrementAuth(token.type);
req.ctx = {
...req.ctx,
decodedToken: token,
};
} catch (error) {
authType = authHeader?.split(" ")[0] ?? "None";
recordAuthTime(
token.type,
"success",
authType,
"failure",
Math.round(performance.now() - startTime),
req
);
const country = req.headers["cf-ipcountry"] as string;
if (country) {
recordRequestCountry(country, req as MonkeyTypes.Request);
}
return next(error);
}
recordAuthTime(
token.type,
"success",
Math.round(performance.now() - startTime),
req
);
// if (req.method !== "OPTIONS" && req?.ctx?.decodedToken?.uid) {
// recordRequestForUid(req.ctx.decodedToken.uid);
// }
const country = req.headers["cf-ipcountry"] as string;
if (country) {
recordRequestCountry(country, req);
}
next();
};
// if (req.method !== "OPTIONS" && req?.ctx?.decodedToken?.uid) {
// recordRequestForUid(req.ctx.decodedToken.uid);
// }
next();
}
async function authenticateWithAuthHeader(
@ -109,24 +138,8 @@ async function authenticateWithAuthHeader(
configuration: Configuration,
options: RequestAuthenticationOptions
): Promise<MonkeyTypes.DecodedToken> {
if (authHeader === undefined || authHeader === "") {
throw new MonkeyError(
401,
"Missing authentication header",
"authenticateWithAuthHeader"
);
}
const [authScheme, token] = authHeader.split(" ");
if (authScheme === undefined) {
throw new MonkeyError(
401,
"Missing authentication scheme",
"authenticateWithAuthHeader"
);
}
if (token === undefined) {
throw new MonkeyError(
401,
@ -135,7 +148,7 @@ async function authenticateWithAuthHeader(
);
}
const normalizedAuthScheme = authScheme.trim();
const normalizedAuthScheme = authScheme?.trim();
switch (normalizedAuthScheme) {
case "Bearer":
@ -298,7 +311,7 @@ async function authenticateWithUid(
};
}
function authenticateGithubWebhook(): Handler {
export function authenticateGithubWebhook(): Handler {
return async (
req: MonkeyTypes.Request,
_res: Response,
@ -338,5 +351,3 @@ function authenticateGithubWebhook(): Handler {
next();
};
}
export { authenticateRequest, authenticateGithubWebhook };

View file

@ -121,6 +121,13 @@ export const configGet = rateLimit({
handler: customHandler,
});
export const configDelete = rateLimit({
windowMs: ONE_HOUR_MS,
max: 120 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Leaderboards Routing
export const leaderboardsGet = rateLimit({
windowMs: ONE_HOUR_MS,

View file

@ -1,15 +1,19 @@
type ObjectId = import("mongodb").ObjectId;
type ExpressRequest = import("express").Request;
/* eslint-disable @typescript-eslint/no-explicit-any */
type TsRestRequest = import("@ts-rest/express").TsRestRequest<any>;
/* eslint-enable @typescript-eslint/no-explicit-any */
type AppRoute = import("@ts-rest/core").AppRoute;
type AppRouter = import("@ts-rest/core").AppRouter;
declare namespace MonkeyTypes {
type DecodedToken = {
export type DecodedToken = {
type: "Bearer" | "ApeKey" | "None";
uid: string;
email: string;
};
type Context = {
export type Context = {
configuration: import("@monkeytype/shared-types").Configuration;
decodedToken: DecodedToken;
};
@ -18,6 +22,14 @@ declare namespace MonkeyTypes {
ctx: Readonly<Context>;
} & ExpressRequest;
type Request2<TQuery = undefined, TBody = undefined, TParams = undefined> = {
query: Readonly<TQuery>;
body: Readonly<TBody>;
params: Readonly<TParams>;
ctx: Readonly<Context>;
raw: Readonly<TsRestRequest>;
};
type DBUser = Omit<
import("@monkeytype/shared-types").User,
| "resultFilterPresets"

View file

@ -1,7 +1,8 @@
import { v4 as uuidv4 } from "uuid";
import { isDevEnvironment } from "./misc";
import { MonkeyServerErrorType } from "@monkeytype/contracts/schemas/api";
class MonkeyError extends Error {
class MonkeyError extends Error implements MonkeyServerErrorType {
status: number;
errorId: string;
uid?: string;

View file

@ -1,4 +1,4 @@
import _ from "lodash";
import _, { omit } from "lodash";
import uaparser from "ua-parser-js";
//todo split this file into smaller util files (grouped by functionality)
@ -306,3 +306,18 @@ export function stringToNumberOrDefault(
export function isDevEnvironment(): boolean {
return process.env["MODE"] === "dev";
}
/**
* convert database object into api object
* @param data database object with `_id: ObjectId`
* @returns api obkect with `id: string`
*/
export function replaceObjectId<T extends { _id: ObjectId }>(
data: T
): T & { _id: string } {
const result = {
_id: data._id.toString(),
...omit(data, "_id"),
} as T & { _id: string };
return result;
}

View file

@ -1,6 +1,10 @@
import { type Response } from "express";
import { isCustomCode } from "../constants/monkey-status-codes";
import { MonkeyResponseType } from "@monkeytype/contracts/schemas/api";
export type MonkeyDataAware<T> = {
data: T | null;
};
//TODO FIX ANYS
export class MonkeyResponse {
@ -36,3 +40,15 @@ export function handleMonkeyResponse(
res.json({ message, data });
}
export class MonkeyResponse2<T = null>
implements MonkeyResponseType, MonkeyDataAware<T>
{
public message: string;
public data: T | null;
constructor(message: string, data: T | null = null) {
this.message = message;
this.data = data;
}
}

View file

@ -2,6 +2,7 @@ import { Result } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/shared-types/config";
import "dotenv/config";
import { Counter, Histogram, Gauge } from "prom-client";
import { TsRestRequestWithCtx } from "../middlewares/auth";
const auth = new Counter({
name: "api_request_auth_total",
@ -212,7 +213,7 @@ export function recordAuthTime(
type: string,
status: "success" | "failure",
time: number,
req: MonkeyTypes.Request
req: MonkeyTypes.Request | TsRestRequestWithCtx
): void {
const reqPath = req.baseUrl + req.route.path;
@ -234,7 +235,7 @@ const requestCountry = new Counter({
export function recordRequestCountry(
country: string,
req: MonkeyTypes.Request
req: MonkeyTypes.Request | TsRestRequestWithCtx
): void {
const reqPath = req.baseUrl + req.route.path;

View file

@ -0,0 +1,533 @@
import * as Config from "../../src/ts/config";
import { CustomThemeColors } from "@monkeytype/contracts/schemas/configs";
import { randomBytes } from "crypto";
describe("Config", () => {
it("setMode", () => {
expect(Config.setMode("zen")).toBe(true);
expect(Config.setMode("invalid" as any)).toBe(false);
});
it("setPlaySoundOnError", () => {
expect(Config.setPlaySoundOnError("off")).toBe(true);
expect(Config.setPlaySoundOnError("1")).toBe(true);
expect(Config.setPlaySoundOnError("invalid" as any)).toBe(false);
});
it("setPlaySoundOnClick", () => {
expect(Config.setPlaySoundOnClick("off")).toBe(true);
expect(Config.setPlaySoundOnClick("15")).toBe(true);
expect(Config.setPlaySoundOnClick("invalid" as any)).toBe(false);
});
it("setSoundVolume", () => {
expect(Config.setSoundVolume("0.1")).toBe(true);
expect(Config.setSoundVolume("1.0")).toBe(true);
expect(Config.setSoundVolume("invalid" as any)).toBe(false);
});
it("setDifficulty", () => {
expect(Config.setDifficulty("expert")).toBe(true);
expect(Config.setDifficulty("invalid" as any)).toBe(false);
});
it("setAccountChart", () => {
expect(Config.setAccountChart(["on", "off", "off", "on"])).toBe(true);
//arrays not having 4 values will get [on, on, on, on] as default
expect(Config.setAccountChart(["on", "off"] as any)).toBe(true);
expect(Config.setAccountChart(["on", "off", "on", "true"] as any)).toBe(
false
);
});
it("setAccountChartResults", () => {
expect(Config.setAccountChartResults(true)).toBe(true);
expect(Config.setAccountChartResults("on" as any)).toBe(false);
});
it("setStopOnError", () => {
expect(Config.setStopOnError("off")).toBe(true);
expect(Config.setStopOnError("word")).toBe(true);
expect(Config.setStopOnError("invalid" as any)).toBe(false);
});
it("setTypingSpeedUnit", () => {
expect(Config.setTypingSpeedUnit("wpm")).toBe(true);
expect(Config.setTypingSpeedUnit("cpm")).toBe(true);
expect(Config.setTypingSpeedUnit("invalid" as any)).toBe(false);
});
it("setPaceCaret", () => {
expect(Config.setPaceCaret("average")).toBe(true);
expect(Config.setPaceCaret("last")).toBe(true);
expect(Config.setPaceCaret("invalid" as any)).toBe(false);
});
it("setMinWpm", () => {
expect(Config.setMinWpm("custom")).toBe(true);
expect(Config.setMinWpm("off")).toBe(true);
expect(Config.setMinWpm("invalid" as any)).toBe(false);
});
it("setMinAcc", () => {
expect(Config.setMinAcc("custom")).toBe(true);
expect(Config.setMinAcc("off")).toBe(true);
expect(Config.setMinAcc("invalid" as any)).toBe(false);
});
it("setMinBurst", () => {
expect(Config.setMinBurst("fixed")).toBe(true);
expect(Config.setMinBurst("off")).toBe(true);
expect(Config.setMinBurst("invalid" as any)).toBe(false);
});
it("setSingleListCommandLine", () => {
expect(Config.setSingleListCommandLine("on")).toBe(true);
expect(Config.setSingleListCommandLine("manual")).toBe(true);
expect(Config.setSingleListCommandLine("invalid" as any)).toBe(false);
});
it("setAds", () => {
expect(Config.setAds("on")).toBe(true);
expect(Config.setAds("sellout")).toBe(true);
expect(Config.setAds("invalid" as any)).toBe(false);
});
it("setRepeatQuotes", () => {
expect(Config.setRepeatQuotes("off")).toBe(true);
expect(Config.setRepeatQuotes("typing")).toBe(true);
expect(Config.setRepeatQuotes("invalid" as any)).toBe(false);
});
it("setOppositeShiftMode", () => {
expect(Config.setOppositeShiftMode("on")).toBe(true);
expect(Config.setOppositeShiftMode("keymap")).toBe(true);
expect(Config.setOppositeShiftMode("invalid" as any)).toBe(false);
});
it("setCaretStyle", () => {
expect(Config.setCaretStyle("banana")).toBe(true);
expect(Config.setCaretStyle("block")).toBe(true);
expect(Config.setCaretStyle("invalid" as any)).toBe(false);
});
it("setPaceCaretStyle", () => {
expect(Config.setPaceCaretStyle("carrot")).toBe(true);
expect(Config.setPaceCaretStyle("outline")).toBe(true);
expect(Config.setPaceCaretStyle("invalid" as any)).toBe(false);
});
it("setShowAverage", () => {
expect(Config.setShowAverage("acc")).toBe(true);
expect(Config.setShowAverage("both")).toBe(true);
expect(Config.setShowAverage("invalid" as any)).toBe(false);
});
it("setHighlightMode", () => {
expect(Config.setHighlightMode("letter")).toBe(true);
expect(Config.setHighlightMode("next_three_words")).toBe(true);
expect(Config.setHighlightMode("invalid" as any)).toBe(false);
});
it("setTapeMode", () => {
expect(Config.setTapeMode("letter")).toBe(true);
expect(Config.setTapeMode("off")).toBe(true);
expect(Config.setTapeMode("invalid" as any)).toBe(false);
});
it("setTimerStyle", () => {
expect(Config.setTimerStyle("bar")).toBe(true);
expect(Config.setTimerStyle("mini")).toBe(true);
expect(Config.setTimerStyle("invalid" as any)).toBe(false);
});
it("setLiveSpeedStyle", () => {
expect(Config.setLiveSpeedStyle("text")).toBe(true);
expect(Config.setLiveSpeedStyle("mini")).toBe(true);
expect(Config.setLiveSpeedStyle("invalid" as any)).toBe(false);
});
it("setLiveAccStyle", () => {
expect(Config.setLiveAccStyle("text")).toBe(true);
expect(Config.setLiveAccStyle("mini")).toBe(true);
expect(Config.setLiveAccStyle("invalid" as any)).toBe(false);
});
it("setLiveBurstStyle", () => {
expect(Config.setLiveBurstStyle("text")).toBe(true);
expect(Config.setLiveBurstStyle("mini")).toBe(true);
expect(Config.setLiveBurstStyle("invalid" as any)).toBe(false);
});
it("setTimerColor", () => {
expect(Config.setTimerColor("text")).toBe(true);
expect(Config.setTimerColor("sub")).toBe(true);
expect(Config.setTimerColor("invalid" as any)).toBe(false);
});
it("setTimerOpacity", () => {
expect(Config.setTimerOpacity("1")).toBe(true);
expect(Config.setTimerOpacity("0.5")).toBe(true);
expect(Config.setTimerOpacity("invalid" as any)).toBe(false);
});
it("setSmoothCaret", () => {
expect(Config.setSmoothCaret("fast")).toBe(true);
expect(Config.setSmoothCaret("medium")).toBe(true);
expect(Config.setSmoothCaret("invalid" as any)).toBe(false);
});
it("setQuickRestartMode", () => {
expect(Config.setQuickRestartMode("off")).toBe(true);
expect(Config.setQuickRestartMode("tab")).toBe(true);
expect(Config.setQuickRestartMode("invalid" as any)).toBe(false);
});
it("setConfidenceMode", () => {
expect(Config.setConfidenceMode("max")).toBe(true);
expect(Config.setConfidenceMode("on")).toBe(true);
expect(Config.setConfidenceMode("invalid" as any)).toBe(false);
});
it("setIndicateTypos", () => {
expect(Config.setIndicateTypos("below")).toBe(true);
expect(Config.setIndicateTypos("off")).toBe(true);
expect(Config.setIndicateTypos("invalid" as any)).toBe(false);
});
it("setRandomTheme", () => {
expect(Config.setRandomTheme("fav")).toBe(true);
expect(Config.setRandomTheme("off")).toBe(true);
expect(Config.setRandomTheme("invalid" as any)).toBe(false);
});
it("setKeymapMode", () => {
expect(Config.setKeymapMode("next")).toBe(true);
expect(Config.setKeymapMode("react")).toBe(true);
expect(Config.setKeymapMode("invalid" as any)).toBe(false);
});
it("setKeymapLegendStyle", () => {
expect(Config.setKeymapLegendStyle("blank")).toBe(true);
expect(Config.setKeymapLegendStyle("lowercase")).toBe(true);
expect(Config.setKeymapLegendStyle("invalid" as any)).toBe(false);
});
it("setKeymapStyle", () => {
expect(Config.setKeymapStyle("matrix")).toBe(true);
expect(Config.setKeymapStyle("split")).toBe(true);
expect(Config.setKeymapStyle("invalid" as any)).toBe(false);
});
it("setKeymapShowTopRow", () => {
expect(Config.setKeymapShowTopRow("always")).toBe(true);
expect(Config.setKeymapShowTopRow("never")).toBe(true);
expect(Config.setKeymapShowTopRow("invalid" as any)).toBe(false);
});
it("setCustomBackgroundSize", () => {
expect(Config.setCustomBackgroundSize("contain")).toBe(true);
expect(Config.setCustomBackgroundSize("cover")).toBe(true);
expect(Config.setCustomBackgroundSize("invalid" as any)).toBe(false);
});
it("setCustomBackgroundFilter", () => {
expect(Config.setCustomBackgroundFilter([0, 1, 2, 3])).toBe(true);
//gets converted
expect(Config.setCustomBackgroundFilter([0, 1, 2, 3, 4] as any)).toBe(true);
expect(Config.setCustomBackgroundFilter([] as any)).toBe(false);
expect(Config.setCustomBackgroundFilter(["invalid"] as any)).toBe(false);
expect(Config.setCustomBackgroundFilter([1, 2, 3, 4, 5, 6] as any)).toBe(
false
);
});
it("setMonkeyPowerLevel", () => {
expect(Config.setMonkeyPowerLevel("2")).toBe(true);
expect(Config.setMonkeyPowerLevel("off")).toBe(true);
expect(Config.setMonkeyPowerLevel("invalid" as any)).toBe(false);
});
it("setCustomThemeColors", () => {
expect(Config.setCustomThemeColors(customThemeColors(10))).toBe(true);
//gets converted
expect(Config.setCustomThemeColors(customThemeColors(9))).toBe(true);
expect(Config.setCustomThemeColors([] as any)).toBe(false);
expect(Config.setCustomThemeColors(["invalid"] as any)).toBe(false);
expect(Config.setCustomThemeColors(customThemeColors(5))).toBe(false);
expect(Config.setCustomThemeColors(customThemeColors(11))).toBe(false);
const tenColors = customThemeColors(10);
tenColors[0] = "black";
expect(Config.setCustomThemeColors(tenColors)).toBe(false);
tenColors[0] = "#123456";
expect(Config.setCustomThemeColors(tenColors)).toBe(true);
tenColors[0] = "#1234";
expect(Config.setCustomThemeColors(tenColors)).toBe(false);
});
it("setNumbers", () => {
testBoolean(Config.setNumbers);
});
it("setPunctuation", () => {
testBoolean(Config.setPunctuation);
});
it("setBlindMode", () => {
testBoolean(Config.setBlindMode);
});
it("setAccountChartResults", () => {
testBoolean(Config.setAccountChartResults);
});
it("setAccountChartAccuracy", () => {
testBoolean(Config.setAccountChartAccuracy);
});
it("setAccountChartAvg10", () => {
testBoolean(Config.setAccountChartAvg10);
});
it("setAccountChartAvg100", () => {
testBoolean(Config.setAccountChartAvg100);
});
it("setAlwaysShowDecimalPlaces", () => {
testBoolean(Config.setAlwaysShowDecimalPlaces);
});
it("setShowOutOfFocusWarning", () => {
testBoolean(Config.setShowOutOfFocusWarning);
});
it("setAlwaysShowWordsHistory", () => {
testBoolean(Config.setAlwaysShowWordsHistory);
});
it("setCapsLockWarning", () => {
testBoolean(Config.setCapsLockWarning);
});
it("setShowAllLines", () => {
testBoolean(Config.setShowAllLines);
});
it("setQuickEnd", () => {
testBoolean(Config.setQuickEnd);
});
it("setFlipTestColors", () => {
testBoolean(Config.setFlipTestColors);
});
it("setColorfulMode", () => {
testBoolean(Config.setColorfulMode);
});
it("setStrictSpace", () => {
testBoolean(Config.setStrictSpace);
});
it("setHideExtraLetters", () => {
testBoolean(Config.setHideExtraLetters);
});
it("setKeyTips", () => {
testBoolean(Config.setKeyTips);
});
it("setStartGraphsAtZero", () => {
testBoolean(Config.setStartGraphsAtZero);
});
it("setSmoothLineScroll", () => {
testBoolean(Config.setSmoothLineScroll);
});
it("setFreedomMode", () => {
testBoolean(Config.setFreedomMode);
});
it("setAutoSwitchTheme", () => {
testBoolean(Config.setAutoSwitchTheme);
});
it("setCustomTheme", () => {
testBoolean(Config.setCustomTheme);
});
it("setBritishEnglish", () => {
testBoolean(Config.setBritishEnglish);
});
it("setLazyMode", () => {
testBoolean(Config.setLazyMode);
});
it("setMonkey", () => {
testBoolean(Config.setMonkey);
});
it("setBurstHeatmap", () => {
testBoolean(Config.setBurstHeatmap);
});
it("setRepeatedPace", () => {
testBoolean(Config.setRepeatedPace);
});
it("setFavThemes", () => {
expect(Config.setFavThemes([])).toBe(true);
expect(Config.setFavThemes(["test"])).toBe(true);
expect(Config.setFavThemes([stringOfLength(50)])).toBe(true);
expect(Config.setFavThemes("invalid" as any)).toBe(false);
expect(Config.setFavThemes([stringOfLength(51)])).toBe(false);
});
it("setFunbox", () => {
expect(Config.setFunbox("one")).toBe(true);
expect(Config.setFunbox("one#two")).toBe(true);
expect(Config.setFunbox("one#two#")).toBe(true);
expect(Config.setFunbox(stringOfLength(100))).toBe(true);
expect(Config.setFunbox(stringOfLength(101))).toBe(false);
});
it("setPaceCaretCustomSpeed", () => {
expect(Config.setPaceCaretCustomSpeed(0)).toBe(true);
expect(Config.setPaceCaretCustomSpeed(1)).toBe(true);
expect(Config.setPaceCaretCustomSpeed(11.11)).toBe(true);
expect(Config.setPaceCaretCustomSpeed("invalid" as any)).toBe(false);
expect(Config.setPaceCaretCustomSpeed(-1)).toBe(false);
});
it("setMinWpmCustomSpeed", () => {
expect(Config.setMinWpmCustomSpeed(0)).toBe(true);
expect(Config.setMinWpmCustomSpeed(1)).toBe(true);
expect(Config.setMinWpmCustomSpeed(11.11)).toBe(true);
expect(Config.setMinWpmCustomSpeed("invalid" as any)).toBe(false);
expect(Config.setMinWpmCustomSpeed(-1)).toBe(false);
});
it("setMinAccCustom", () => {
expect(Config.setMinAccCustom(0)).toBe(true);
expect(Config.setMinAccCustom(1)).toBe(true);
expect(Config.setMinAccCustom(11.11)).toBe(true);
//gets converted
expect(Config.setMinAccCustom(120)).toBe(true);
expect(Config.setMinAccCustom("invalid" as any)).toBe(false);
expect(Config.setMinAccCustom(-1)).toBe(false);
});
it("setMinBurstCustomSpeed", () => {
expect(Config.setMinBurstCustomSpeed(0)).toBe(true);
expect(Config.setMinBurstCustomSpeed(1)).toBe(true);
expect(Config.setMinBurstCustomSpeed(11.11)).toBe(true);
expect(Config.setMinBurstCustomSpeed("invalid" as any)).toBe(false);
expect(Config.setMinBurstCustomSpeed(-1)).toBe(false);
});
it("setTimeConfig", () => {
expect(Config.setTimeConfig(0)).toBe(true);
expect(Config.setTimeConfig(1)).toBe(true);
//gets converted
expect(Config.setTimeConfig("invalid" as any)).toBe(true);
expect(Config.setTimeConfig(-1)).toBe(true);
expect(Config.setTimeConfig(11.11)).toBe(false);
});
it("setWordCount", () => {
expect(Config.setWordCount(0)).toBe(true);
expect(Config.setWordCount(1)).toBe(true);
//gets converted
expect(Config.setWordCount(-1)).toBe(true);
expect(Config.setWordCount("invalid" as any)).toBe(false);
expect(Config.setWordCount(11.11)).toBe(false);
});
it("setFontFamily", () => {
expect(Config.setFontFamily("Arial")).toBe(true);
expect(Config.setFontFamily("roboto_mono")).toBe(true);
expect(Config.setFontFamily("test_font")).toBe(true);
expect(Config.setFontFamily(stringOfLength(50))).toBe(true);
expect(Config.setFontFamily(stringOfLength(51))).toBe(false);
expect(Config.setFontFamily("test font")).toBe(false);
expect(Config.setFontFamily("test!font")).toBe(false);
});
it("setTheme", () => {
expect(Config.setTheme("serika")).toBe(true);
expect(Config.setTheme("serika_dark")).toBe(true);
expect(Config.setTheme(stringOfLength(50))).toBe(true);
expect(Config.setTheme("serika dark")).toBe(false);
expect(Config.setTheme("serika-dark")).toBe(false);
expect(Config.setTheme(stringOfLength(51))).toBe(false);
});
it("setThemeLight", () => {
expect(Config.setThemeLight("serika")).toBe(true);
expect(Config.setThemeLight("serika_dark")).toBe(true);
expect(Config.setThemeLight(stringOfLength(50))).toBe(true);
expect(Config.setThemeLight("serika dark")).toBe(false);
expect(Config.setThemeLight("serika-dark")).toBe(false);
expect(Config.setThemeLight(stringOfLength(51))).toBe(false);
});
it("setThemeDark", () => {
expect(Config.setThemeDark("serika")).toBe(true);
expect(Config.setThemeDark("serika_dark")).toBe(true);
expect(Config.setThemeDark(stringOfLength(50))).toBe(true);
expect(Config.setThemeDark("serika dark")).toBe(false);
expect(Config.setThemeDark("serika-dark")).toBe(false);
expect(Config.setThemeDark(stringOfLength(51))).toBe(false);
});
it("setLanguage", () => {
expect(Config.setLanguage("english")).toBe(true);
expect(Config.setLanguage("english_1k")).toBe(true);
expect(Config.setLanguage(stringOfLength(50))).toBe(true);
expect(Config.setLanguage("english 1k")).toBe(false);
expect(Config.setLanguage("english-1k")).toBe(false);
expect(Config.setLanguage(stringOfLength(51))).toBe(false);
});
it("setKeymapLayout", () => {
expect(Config.setKeymapLayout("overrideSync")).toBe(true);
expect(Config.setKeymapLayout("override_sync")).toBe(true);
expect(Config.setKeymapLayout("override sync")).toBe(true);
expect(Config.setKeymapLayout("override-sync!")).toBe(true);
expect(Config.setKeymapLayout(stringOfLength(50))).toBe(true);
expect(Config.setKeymapLayout(stringOfLength(51))).toBe(false);
});
it("setLayout", () => {
expect(Config.setLayout("semimak")).toBe(true);
expect(Config.setLayout("semi_mak")).toBe(true);
expect(Config.setLayout(stringOfLength(50))).toBe(true);
expect(Config.setLayout("semi mak")).toBe(false);
expect(Config.setLayout("semi-mak")).toBe(false);
expect(Config.setLayout(stringOfLength(51))).toBe(false);
});
it("setFontSize", () => {
expect(Config.setFontSize(1)).toBe(true);
//gets converted
expect(Config.setFontSize(-1)).toBe(true);
expect(Config.setFontSize("1" as any)).toBe(true);
expect(Config.setFontSize("125" as any)).toBe(true);
expect(Config.setFontSize("15" as any)).toBe(true);
expect(Config.setFontSize("2" as any)).toBe(true);
expect(Config.setFontSize("3" as any)).toBe(true);
expect(Config.setFontSize("4" as any)).toBe(true);
expect(Config.setFontSize(0)).toBe(false);
expect(Config.setFontSize("5" as any)).toBe(false);
expect(Config.setFontSize("invalid" as any)).toBe(false);
});
it("setMaxLineWidth", () => {
expect(Config.setMaxLineWidth(0)).toBe(true);
expect(Config.setMaxLineWidth(50)).toBe(true);
expect(Config.setMaxLineWidth(50.5)).toBe(true);
//gets converted
expect(Config.setMaxLineWidth(10)).toBe(true);
expect(Config.setMaxLineWidth(10_000)).toBe(true);
expect(Config.setMaxLineWidth("invalid" as any)).toBe(false);
});
it("setCustomBackground", () => {
expect(Config.setCustomBackground("http://example.com/test.png")).toBe(
true
);
expect(Config.setCustomBackground("https://www.example.com/test.gif")).toBe(
true
);
expect(Config.setCustomBackground("https://example.com/test.jpg")).toBe(
true
);
expect(Config.setCustomBackground("http://www.example.com/test.jpeg")).toBe(
true
);
//gets converted
expect(
Config.setCustomBackground(" http://example.com/test.png ")
).toBe(true);
expect(Config.setCustomBackground("http://www.example.com/test.webp")).toBe(
false
);
expect(
Config.setCustomBackground("http://www.example.com/test?test=foo&bar=baz")
).toBe(false);
expect(Config.setCustomBackground("invalid")).toBe(false);
});
it("setQuoteLength", () => {
expect(Config.setQuoteLength(0)).toBe(true);
expect(Config.setQuoteLength(-3)).toBe(true);
expect(Config.setQuoteLength(3)).toBe(true);
expect(Config.setQuoteLength(-4 as any)).toBe(false);
expect(Config.setQuoteLength(4 as any)).toBe(false);
expect(Config.setQuoteLength([0, -3, 2])).toBe(true);
expect(Config.setQuoteLength([-4 as any, 5 as any])).toBe(false);
});
});
function customThemeColors(n: number): CustomThemeColors {
return new Array(n).fill("#000") as CustomThemeColors;
}
function testBoolean(fn: (val: boolean) => boolean): void {
expect(fn(true)).toBe(true);
expect(fn(false)).toBe(true);
expect(fn("true" as any)).toBe(false);
expect(fn("0" as any)).toBe(false);
expect(fn("invalid" as any)).toBe(false);
}
function stringOfLength(length: number): string {
return randomBytes(Math.ceil(length / 2))
.toString("hex")
.slice(0, length);
}

View file

@ -1,18 +1,8 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"incremental": true,
"module": "commonjs",
"target": "es6",
"sourceMap": false,
"allowJs": true,
"checkJs": true,
"outDir": "build",
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strictNullChecks": true,
"skipLibCheck": true,
"moduleResolution": "Bundler",
"module": "ESNext",
"noEmit": true,
"types": ["vitest/globals"]
},

View file

@ -11,7 +11,7 @@
"ts-check": "tsc --noEmit",
"build": "npm run madge && vite build",
"madge": " madge --circular --extensions ts ./src",
"live": "npm run build && vite preview --port 3000",
"start": "vite preview --port 3000",
"dev": "vite dev",
"deploy-live": "npm run validate-json && npm run build && firebase deploy -P live --only hosting",
"deploy-preview": "npm run validate-json && npm run build && firebase hosting:channel:deploy preview -P live --expires 2h",
@ -71,6 +71,7 @@
},
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "*",
"axios": "1.6.4",
"canvas-confetti": "1.5.1",
"chart.js": "3.7.1",

View file

@ -0,0 +1,74 @@
import { AppRouter, initClient, type ApiFetcherArgs } from "@ts-rest/core";
import { Method } from "axios";
import { getIdToken } from "firebase/auth";
import { envConfig } from "../../constants/env-config";
import { getAuthenticatedUser, isAuthenticated } from "../../firebase";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
status: number;
body: unknown;
headers: Headers;
}> {
return async (request: ApiFetcherArgs) => {
const isPublicEndpoint =
(request.route.metadata as EndpointMetadata | undefined)
?.authenticationOptions?.isPublic ?? false;
try {
const headers: HeadersInit = {
...request.headers,
"X-Client-Version": envConfig.clientVersion,
};
if (!isPublicEndpoint) {
const token = isAuthenticated()
? await getIdToken(getAuthenticatedUser())
: "";
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(request.path, {
signal: AbortSignal.timeout(timeout),
method: request.method as Method,
headers,
body: request.body,
});
const body = await response.json();
if (response.status >= 400) {
console.error(`${request.method} ${request.path} failed`, {
status: response.status,
...body,
});
}
return {
status: response.status,
body,
headers: response.headers ?? new Headers(),
};
} catch (e: Error | unknown) {
return {
status: 500,
body: { message: e },
headers: new Headers(),
};
}
};
}
/* eslint-disable @typescript-eslint/explicit-function-return-type */
export function buildClient<T extends AppRouter>(
contract: T,
baseUrl: string,
timeout: number = 10_000
) {
return initClient(contract, {
baseUrl: baseUrl,
jsonQuery: true,
api: buildApi(timeout),
baseHeaders: {
Accept: "application/json",
},
});
}
/* eslint-enable @typescript-eslint/explicit-function-return-type */

View file

@ -1,17 +0,0 @@
import { Config } from "@monkeytype/shared-types/config";
const BASE_PATH = "/configs";
export default class Configs {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(): Ape.EndpointResponse<Ape.Configs.GetConfig> {
return await this.httpClient.get(BASE_PATH);
}
async save(config: Config): Ape.EndpointResponse<Ape.Configs.PostConfig> {
return await this.httpClient.patch(BASE_PATH, { payload: { config } });
}
}

View file

@ -1,4 +1,3 @@
import Configs from "./configs";
import Leaderboards from "./leaderboards";
import Presets from "./presets";
import Psas from "./psas";
@ -11,7 +10,6 @@ import Configuration from "./configuration";
import Dev from "./dev";
export default {
Configs,
Leaderboards,
Presets,
Psas,

View file

@ -1,17 +1,19 @@
import endpoints from "./endpoints";
import { buildHttpClient } from "./adapters/axios-adapter";
import { envConfig } from "../constants/env-config";
import { buildClient } from "./adapters/ts-rest-adapter";
import { configsContract } from "@monkeytype/contracts/configs";
const API_PATH = "";
const BASE_URL = envConfig.backendUrl;
const API_URL = `${BASE_URL}${API_PATH}`;
const httpClient = buildHttpClient(API_URL, 10000);
const httpClient = buildHttpClient(API_URL, 10_000);
// API Endpoints
const Ape = {
users: new endpoints.Users(httpClient),
configs: new endpoints.Configs(httpClient),
configs: buildClient(configsContract, BASE_URL, 10_000),
results: new endpoints.Results(httpClient),
psas: new endpoints.Psas(httpClient),
quotes: new endpoints.Quotes(httpClient),

View file

@ -1,10 +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.Configs {
type GetConfig = {
_id: string;
uid: string;
config: Partial<Config>;
};
type PostConfig = null;
}

View file

@ -1,18 +1,7 @@
import * as Misc from "./utils/misc";
import * as JSONData from "./utils/json-data";
import * as Notifications from "./elements/notifications";
type PossibleType =
| "string"
| "number"
| "numberArray"
| "boolean"
| "undefined"
| "null"
| "stringArray"
| "layoutfluid"
| string[]
| number[];
import { ZodSchema, z } from "zod";
type PossibleTypeAsync = "layoutfluid";
@ -38,70 +27,19 @@ function invalid(key: string, val: unknown, customMessage?: string): void {
console.error(`Invalid value key ${key} value ${val} type ${typeof val}`);
}
function isArray(val: unknown): val is unknown[] {
return val instanceof Array;
}
export function isConfigValueValid(
export function isConfigValueValid<T>(
key: string,
val: unknown,
possibleTypes: PossibleType[]
val: T,
schema: ZodSchema<T>
): boolean {
let isValid = false;
// might be used in the future
// eslint-disable-next-line
let customMessage: string | undefined = undefined;
for (const possibleType of possibleTypes) {
switch (possibleType) {
case "boolean":
if (typeof val === "boolean") isValid = true;
break;
case "null":
if (val === null) isValid = true;
break;
case "number":
if (typeof val === "number" && !isNaN(val)) isValid = true;
break;
case "numberArray":
if (
isArray(val) &&
val.every((v) => typeof v === "number" && !isNaN(v))
) {
isValid = true;
}
break;
case "string":
if (typeof val === "string") isValid = true;
break;
case "stringArray":
if (isArray(val) && val.every((v) => typeof v === "string")) {
isValid = true;
}
break;
case "undefined":
if (typeof val === "undefined" || val === undefined) isValid = true;
break;
default:
if (isArray(possibleType)) {
if (possibleType.includes(val as never)) isValid = true;
}
break;
}
}
if (!isValid) invalid(key, val, customMessage);
const isValid = schema.safeParse(val).success;
if (!isValid) invalid(key, val, undefined);
return isValid;
}
export function isConfigValueValidBoolean(key: string, val: boolean): boolean {
return isConfigValueValid(key, val, z.boolean());
}
export async function isConfigValueValidAsync(
key: string,

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,7 @@
import { Config } from "@monkeytype/shared-types/config";
import {
Config,
CustomThemeColors,
} from "@monkeytype/contracts/schemas/configs";
export default {
theme: "serika_dark",
@ -17,7 +20,7 @@ export default {
"#7e2a33",
"#ca4754",
"#7e2a33",
],
] as CustomThemeColors,
favThemes: [],
showKeyTips: true,
smoothCaret: "medium",
@ -85,7 +88,7 @@ export default {
oppositeShiftMode: "off",
customBackground: "",
customBackgroundSize: "cover",
customBackgroundFilter: [0, 1, 1, 1, 1],
customBackgroundFilter: [0, 1, 1, 1],
customLayoutfluid: "qwerty#dvorak#colemak",
monkeyPowerLevel: "off",
minBurst: "off",

View file

@ -85,7 +85,7 @@ export async function initSnapshot(): Promise<
if (configResponse.status !== 200) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw {
message: `${configResponse.message} (config)`,
message: `${configResponse.body.message} (config)`,
responseCode: configResponse.status,
};
}
@ -98,7 +98,7 @@ export async function initSnapshot(): Promise<
}
const userData = userResponse.data;
const configData = configResponse.data;
const configData = configResponse.body.data;
const presetsData = presetsResponse.data;
if (userData === null) {
@ -109,7 +109,7 @@ export async function initSnapshot(): Promise<
};
}
if (configData !== null && !("config" in configData)) {
if (configData !== null && "config" in configData) {
throw new Error(
"Config data is not in the correct format. Please refresh the page or contact support."
);
@ -179,7 +179,7 @@ export async function initSnapshot(): Promise<
...DefaultConfig,
};
} else {
snap.config = mergeWithDefaultConfig(configData.config);
snap.config = mergeWithDefaultConfig(configData);
}
// if (ActivePage.get() === "loading") {
// LoadingPage.updateBar(67.5);
@ -894,9 +894,18 @@ export async function updateLbMemory<M extends Mode>(
export async function saveConfig(config: Config): Promise<void> {
if (isAuthenticated()) {
const response = await Ape.configs.save(config);
const response = await Ape.configs.save({ body: config });
if (response.status !== 200) {
Notifications.add("Failed to save config: " + response.message, -1);
Notifications.add("Failed to save config: " + response.body.message, -1);
}
}
}
export async function resetConfig(): Promise<void> {
if (isAuthenticated()) {
const response = await Ape.configs.delete();
if (response.status !== 200) {
Notifications.add("Failed to reset config: " + response.body.message, -1);
}
}
}

View file

@ -135,7 +135,7 @@ function buildPbHtml(
if (pbData === undefined) throw new Error("No PB data found");
const date = new Date(pbData.timestamp);
if (pbData.timestamp) {
if (pbData.timestamp !== undefined && pbData.timestamp > 0) {
dateText = dateFormat(date, "dd MMM yyyy");
}

View file

@ -11,6 +11,7 @@ import * as DB from "../../db";
import * as ConfigEvent from "../../observables/config-event";
import { isAuthenticated } from "../../firebase";
import * as ActivePage from "../../states/active-page";
import { CustomThemeColors } from "@monkeytype/contracts/schemas/configs";
function updateActiveButton(): void {
let activeThemeName = Config.theme;
@ -281,7 +282,7 @@ function saveCustomThemeColors(): void {
).attr("value") as string
);
}
UpdateConfig.setCustomThemeColors(newColors);
UpdateConfig.setCustomThemeColors(newColors as CustomThemeColors);
Notifications.add("Custom theme saved", 1);
}

View file

@ -33,6 +33,7 @@ import AnimatedModal, {
} from "../utils/animated-modal";
import { format as dateFormat } from "date-fns/format";
import { Attributes, buildTag } from "../utils/tag-builder";
import { CustomThemeColors } from "@monkeytype/contracts/schemas/configs";
type CommonInput<TType, TValue> = {
type: TType;
@ -1705,7 +1706,7 @@ list.updateCustomTheme = new SimpleModal({
const newTheme = {
name: name.replaceAll(" ", "_"),
colors: newColors,
colors: newColors as CustomThemeColors,
};
const validation = await DB.editCustomTheme(customTheme._id, newTheme);
if (!validation) {
@ -1714,7 +1715,7 @@ list.updateCustomTheme = new SimpleModal({
message: "Failed to update custom theme",
};
}
UpdateConfig.setCustomThemeColors(newColors);
UpdateConfig.setCustomThemeColors(newColors as CustomThemeColors);
void ThemePicker.refreshButtons();
return {

View file

@ -182,7 +182,7 @@ declare namespace MonkeyTypes {
type RawCustomTheme = {
name: string;
colors: string[];
colors: import("@monkeytype/contracts/schemas/configs").CustomThemeColors;
};
type CustomTheme = {

View file

@ -1,8 +1,9 @@
import { Config, ConfigValue } from "@monkeytype/shared-types/config";
import DefaultConfig from "../constants/default-config";
import { typedKeys } from "./misc";
import { PartialConfig } from "@monkeytype/contracts/schemas/configs";
export function mergeWithDefaultConfig(config: Partial<Config>): Config {
export function mergeWithDefaultConfig(config: PartialConfig): Config {
const mergedConfig = {} as Config;
for (const key of typedKeys(DefaultConfig)) {
const newValue = config[key] ?? (DefaultConfig[key] as ConfigValue);

View file

@ -8,6 +8,10 @@
"name": "frontend",
"path": "frontend"
},
{
"name": "contracts",
"path": "packages/contracts"
},
{
"name": "packages",
"path": "packages"
@ -21,7 +25,8 @@
"files.exclude": {
"frontend": true,
"backend": true,
"packages": true
"packages": true,
"contracts": true
},
"search.exclude": {
//defaults

1664
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,9 @@
"dev-be": "turbo run dev --force --filter @monkeytype/backend",
"dev-fe": "turbo run dev --force --filter @monkeytype/frontend",
"dev-pkg": "turbo run dev ---force -filter=\"./packages/*\"",
"live": "concurrently --kill-others \"cd frontend && npm run live\" \"cd backend && npm run start\"",
"start": "turbo run start",
"start-be": "turbo run start --filter @monkeytype/backend",
"start-fe": "turbo run start --filter @monkeytype/frontend",
"docker": "cd backend && docker compose up",
"audit-fe": "cd frontend && npm run audit",
"release": "release-it -c .release-it.json",

View file

@ -0,0 +1,5 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@monkeytype/eslint-config"],
};

View file

@ -0,0 +1,94 @@
const esbuild = require("esbuild");
const { readdirSync, statSync } = require("fs");
const { join, extname } = require("path");
const chokidar = require("chokidar");
//check if watch parameter is passed
const isWatch = process.argv.includes("--watch");
// Recursive function to get all .ts files in a directory
const getAllFiles = (dirPath, arrayOfFiles = []) => {
const files = readdirSync(dirPath);
files.forEach((file) => {
const filePath = join(dirPath, file);
if (statSync(filePath).isDirectory()) {
arrayOfFiles = getAllFiles(filePath, arrayOfFiles);
} else if (extname(file) === ".ts") {
arrayOfFiles.push(filePath);
}
});
return arrayOfFiles;
};
// Get all TypeScript files from the src directory and subdirectories
const entryPoints = getAllFiles("./src");
// Function to generate output file names
const getOutfile = (entryPoint, format) => {
const relativePath = entryPoint.replace("src/", "");
const fileBaseName = relativePath.replace(".ts", "");
return `./dist/${fileBaseName}.${format === "esm" ? "mjs" : "cjs"}`;
};
// Common build settings
const commonSettings = {
bundle: true,
sourcemap: true,
minify: true,
};
function buildAll(silent, stopOnError) {
console.log("Building all files...");
entryPoints.forEach((entry) => {
build(entry, silent, stopOnError);
});
}
function build(entry, silent, stopOnError) {
if (!silent) console.log("Building", entry);
// ESM build
esbuild
.build({
...commonSettings,
entryPoints: [entry],
format: "esm",
outfile: getOutfile(entry, "esm"),
})
.catch((e) => {
console.log(`Failed to build ${entry} to ESM:`, e);
if (stopOnError) process.exit(1);
});
// CommonJS build
esbuild
.build({
...commonSettings,
entryPoints: [entry],
format: "cjs",
outfile: getOutfile(entry, "cjs"),
})
.catch((e) => {
console.log(`Failed to build ${entry} to CJS:`, e);
if (stopOnError) process.exit(1);
});
}
if (isWatch) {
buildAll(true, false);
console.log("Starting watch mode...");
chokidar.watch("./src/**/*.ts").on(
"change",
(path) => {
console.log("File change detected...");
build(path, false, false);
},
{
ignoreInitial: true,
}
);
} else {
buildAll(false, true);
}

View file

@ -0,0 +1,35 @@
{
"name": "@monkeytype/contracts",
"private": true,
"scripts": {
"dev": "rimraf ./dist && node esbuild.config.js --watch",
"build": "rimraf ./dist && node esbuild.config.js",
"ts-check": "tsc --noEmit",
"lint": "eslint \"./**/*.ts\""
},
"dependencies": {
"@ts-rest/core": "3.45.2",
"zod": "3.23.8"
},
"devDependencies": {
"@monkeytype/eslint-config": "*",
"@monkeytype/typescript-config": "*",
"chokidar": "3.6.0",
"esbuild": "0.23.0",
"eslint": "8.57.0",
"rimraf": "5.0.9",
"typescript": "5.5.3"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./*": {
"types": "./src/*.ts",
"import": "./dist/*.mjs",
"require": "./dist/*.cjs"
}
}
}

View file

@ -0,0 +1,61 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
EndpointMetadata,
MonkeyResponseSchema,
responseWithNullableData,
} from "./schemas/api";
import { PartialConfigSchema } from "./schemas/configs";
export const GetConfigResponseSchema =
responseWithNullableData(PartialConfigSchema);
export type GetConfigResponse = z.infer<typeof GetConfigResponseSchema>;
const c = initContract();
export const configsContract = c.router(
{
get: {
summary: "get config",
description: "Get config of the current user.",
method: "GET",
path: "/",
responses: {
200: GetConfigResponseSchema,
},
},
save: {
method: "PATCH",
path: "/",
body: PartialConfigSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
summary: "update config",
description:
"Update the config of the current user. Only provided values will be updated while the missing values will be unchanged.",
},
delete: {
method: "DELETE",
path: "/",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
summary: "delete config",
description: "Delete/reset the config for the current user.",
},
},
{
pathPrefix: "/configs",
strictStatusCodes: true,
metadata: {
openApiTags: "configs",
} as EndpointMetadata,
commonResponses: CommonResponses,
}
);

View file

@ -0,0 +1,8 @@
import { initContract } from "@ts-rest/core";
import { configsContract } from "./configs";
const c = initContract();
export const contract = c.router({
configs: configsContract,
});

View file

@ -0,0 +1,77 @@
import { z, ZodSchema } from "zod";
export type OperationTag = "configs";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */
authenticationOptions?: RequestAuthenticationOptions;
openApiTags?: OperationTag | OperationTag[];
};
export type RequestAuthenticationOptions = {
/** Endpoint is accessible without any authentication. If `false` bearer authentication is required. */
isPublic?: boolean;
/** Endpoint is accessible with ape key authentication in _addition_ to the bearer authentication. */
acceptApeKeys?: boolean;
/** Endpoint requires an authentication token which is younger than one minute. */
requireFreshToken?: boolean;
noCache?: boolean;
};
export const MonkeyResponseSchema = z.object({
message: z.string(),
});
export type MonkeyResponseType = z.infer<typeof MonkeyResponseSchema>;
export const MonkeyValidationErrorSchema = MonkeyResponseSchema.extend({
validationErrors: z.array(z.string()).nonempty(),
});
export type MonkeyValidationError = z.infer<typeof MonkeyValidationErrorSchema>;
export const MonkeyClientError = MonkeyResponseSchema;
export const MonkeyServerError = MonkeyClientError.extend({
errorId: z.string(),
uid: z.string().optional(),
});
export type MonkeyServerErrorType = z.infer<typeof MonkeyServerError>;
export function responseWithNullableData<T extends ZodSchema>(
dataSchema: T
): z.ZodObject<
z.objectUtil.extendShape<
typeof MonkeyResponseSchema.shape,
{
data: z.ZodNullable<T>;
}
>
> {
return MonkeyResponseSchema.extend({
data: dataSchema.nullable(),
});
}
export function responseWithData<T extends ZodSchema>(
dataSchema: T
): z.ZodObject<
z.objectUtil.extendShape<
typeof MonkeyResponseSchema.shape,
{
data: T;
}
>
> {
return MonkeyResponseSchema.extend({
data: dataSchema,
});
}
export const CommonResponses = {
400: MonkeyClientError.describe("Generic client error"),
401: MonkeyClientError.describe(
"Authentication required but not provided or invalid"
),
403: MonkeyClientError.describe("Operation not permitted"),
422: MonkeyValidationErrorSchema.describe("Request validation failed"),
429: MonkeyClientError.describe("Rate limit exceeded"),
500: MonkeyServerError.describe("Generic server error"),
};

View file

@ -0,0 +1,390 @@
import { z } from "zod";
import { token } from "./util";
export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]);
export type SmoothCaret = z.infer<typeof SmoothCaretSchema>;
export const QuickRestartSchema = z.enum(["off", "esc", "tab", "enter"]);
export type QuickRestart = z.infer<typeof QuickRestartSchema>;
export const QuoteLengthSchema = z.union([
z.literal(-3),
z.literal(-2),
z.literal(-1),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
]);
export type QuoteLength = z.infer<typeof QuoteLengthSchema>;
export const QuoteLengthConfigSchema = z.array(QuoteLengthSchema);
export type QuoteLengthConfig = z.infer<typeof QuoteLengthConfigSchema>;
export const CaretStyleSchema = z.enum([
"off",
"default",
"block",
"outline",
"underline",
"carrot",
"banana",
]);
export type CaretStyle = z.infer<typeof CaretStyleSchema>;
export const ConfidenceModeSchema = z.enum(["off", "on", "max"]);
export type ConfidenceMode = z.infer<typeof ConfidenceModeSchema>;
export const IndicateTyposSchema = z.enum(["off", "below", "replace"]);
export type IndicateTypos = z.infer<typeof IndicateTyposSchema>;
export const TimerStyleSchema = z.enum(["off", "bar", "text", "mini"]);
export type TimerStyle = z.infer<typeof TimerStyleSchema>;
export const LiveSpeedAccBurstStyleSchema = z.enum(["off", "text", "mini"]);
export type LiveSpeedAccBurstStyle = z.infer<
typeof LiveSpeedAccBurstStyleSchema
>;
export const RandomThemeSchema = z.enum([
"off",
"on",
"fav",
"light",
"dark",
"custom",
]);
export type RandomTheme = z.infer<typeof RandomThemeSchema>;
export const TimerColorSchema = z.enum(["black", "sub", "text", "main"]);
export type TimerColor = z.infer<typeof TimerColorSchema>;
export const TimerOpacitySchema = z.enum(["0.25", "0.5", "0.75", "1"]);
export type TimerOpacity = z.infer<typeof TimerOpacitySchema>;
export const StopOnErrorSchema = z.enum(["off", "word", "letter"]);
export type StopOnError = z.infer<typeof StopOnErrorSchema>;
export const KeymapModeSchema = z.enum(["off", "static", "react", "next"]);
export type KeymapMode = z.infer<typeof KeymapModeSchema>;
export const KeymapStyleSchema = z.enum([
"staggered",
"alice",
"matrix",
"split",
"split_matrix",
"steno",
"steno_matrix",
]);
export type KeymapStyle = z.infer<typeof KeymapStyleSchema>;
export const KeymapLegendStyleSchema = z.enum([
"lowercase",
"uppercase",
"blank",
"dynamic",
]);
export type KeymapLegendStyle = z.infer<typeof KeymapLegendStyleSchema>;
export const KeymapShowTopRowSchema = z.enum(["always", "layout", "never"]);
export type KeymapShowTopRow = z.infer<typeof KeymapShowTopRowSchema>;
export const SingleListCommandLineSchema = z.enum(["manual", "on"]);
export type SingleListCommandLine = z.infer<typeof SingleListCommandLineSchema>;
export const PlaySoundOnErrorSchema = z.enum(["off", "1", "2", "3", "4"]);
export type PlaySoundOnError = z.infer<typeof PlaySoundOnErrorSchema>;
export const PlaySoundOnClickSchema = z.enum([
"off",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
]);
export type PlaySoundOnClick = z.infer<typeof PlaySoundOnClickSchema>;
export const SoundVolumeSchema = z.enum(["0.1", "0.5", "1.0"]);
export type SoundVolume = z.infer<typeof SoundVolumeSchema>;
export const PaceCaretSchema = z.enum([
"off",
"average",
"pb",
"last",
"custom",
"daily",
]);
export type PaceCaret = z.infer<typeof PaceCaretSchema>;
export const AccountChartSchema = z.tuple([
z.enum(["on", "off"]),
z.enum(["on", "off"]),
z.enum(["on", "off"]),
z.enum(["on", "off"]),
]);
export type AccountChart = z.infer<typeof AccountChartSchema>;
export const MinimumWordsPerMinuteSchema = z.enum(["off", "custom"]);
export type MinimumWordsPerMinute = z.infer<typeof MinimumWordsPerMinuteSchema>;
export const HighlightModeSchema = z.enum([
"off",
"letter",
"word",
"next_word",
"next_two_words",
"next_three_words",
]);
export type HighlightMode = z.infer<typeof HighlightModeSchema>;
export const TapeModeSchema = z.enum(["off", "letter", "word"]);
export type TapeMode = z.infer<typeof TapeModeSchema>;
export const TypingSpeedUnitSchema = z.enum([
"wpm",
"cpm",
"wps",
"cps",
"wph",
]);
export type TypingSpeedUnit = z.infer<typeof TypingSpeedUnitSchema>;
export const AdsSchema = z.enum(["off", "result", "on", "sellout"]);
export type Ads = z.infer<typeof AdsSchema>;
export const MinimumAccuracySchema = z.enum(["off", "custom"]);
export type MinimumAccuracy = z.infer<typeof MinimumAccuracySchema>;
export const RepeatQuotesSchema = z.enum(["off", "typing"]);
export type RepeatQuotes = z.infer<typeof RepeatQuotesSchema>;
export const OppositeShiftModeSchema = z.enum(["off", "on", "keymap"]);
export type OppositeShiftMode = z.infer<typeof OppositeShiftModeSchema>;
export const CustomBackgroundSizeSchema = z.enum(["cover", "contain", "max"]);
export type CustomBackgroundSize = z.infer<typeof CustomBackgroundSizeSchema>;
export const CustomBackgroundFilterSchema = z.tuple([
z.number(),
z.number(),
z.number(),
z.number(),
]);
export type CustomBackgroundFilter = z.infer<
typeof CustomBackgroundFilterSchema
>;
export const CustomLayoutFluidSchema = z.string().regex(/^[0-9a-zA-Z_#]+$/); //TODO better regex
export type CustomLayoutFluid = z.infer<typeof CustomLayoutFluidSchema>;
export const MonkeyPowerLevelSchema = z.enum(["off", "1", "2", "3", "4"]);
export type MonkeyPowerLevel = z.infer<typeof MonkeyPowerLevelSchema>;
export const MinimumBurstSchema = z.enum(["off", "fixed", "flex"]);
export type MinimumBurst = z.infer<typeof MinimumBurstSchema>;
export const ShowAverageSchema = z.enum(["off", "speed", "acc", "both"]);
export type ShowAverage = z.infer<typeof ShowAverageSchema>;
export const ColorHexValueSchema = z.string().regex(/^#([\da-f]{3}){1,2}$/i);
export type ColorHexValue = z.infer<typeof ColorHexValueSchema>;
export const DifficultySchema = z.enum(["normal", "expert", "master"]);
export type Difficulty = z.infer<typeof DifficultySchema>;
export const NumberModeSchema = z.enum(["time", "words", "quote"]);
export const CustomModeSchema = z.enum(["custom"]);
export const ZenModeSchema = z.enum(["zen"]);
export const ModeSchema = z.union([
NumberModeSchema,
CustomModeSchema,
ZenModeSchema,
]);
export type Mode = z.infer<typeof ModeSchema>;
export const CustomThemeColorsSchema = z.tuple([
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
]);
export type CustomThemeColors = z.infer<typeof CustomThemeColorsSchema>;
export const FavThemesSchema = z.array(token().max(50));
export type FavThemes = z.infer<typeof FavThemesSchema>;
export const FunboxSchema = z
.string()
.max(100)
.regex(/[\w#]+/);
export type Funbox = z.infer<typeof FunboxSchema>;
export const PaceCaretCustomSpeedSchema = z.number().nonnegative();
export type PaceCaretCustomSpeed = z.infer<typeof PaceCaretCustomSpeedSchema>;
export const MinWpmCustomSpeedSchema = z.number().nonnegative();
export type MinWpmCustomSpeed = z.infer<typeof MinWpmCustomSpeedSchema>;
export const MinimumAccuracyCustomSchema = z.number().nonnegative().max(100);
export type MinimumAccuracyCustom = z.infer<typeof MinimumAccuracyCustomSchema>;
export const MinimumBurstCustomSpeedSchema = z.number().nonnegative();
export type MinimumBurstCustomSpeed = z.infer<
typeof MinimumBurstCustomSpeedSchema
>;
export const TimeConfigSchema = z.number().int().nonnegative();
export type TimeConfig = z.infer<typeof TimeConfigSchema>;
export const WordCountSchema = z.number().int().nonnegative();
export type WordCount = z.infer<typeof WordCountSchema>;
export const FontFamilySchema = z
.string()
.max(50)
.regex(/^[a-zA-Z0-9_\-+.]+$/);
export type FontFamily = z.infer<typeof FontFamilySchema>;
export const ThemeNameSchema = token().max(50);
export type ThemeName = z.infer<typeof ThemeNameSchema>;
export const LanguageSchema = z
.string()
.max(50)
.regex(/^[a-zA-Z0-9_+]+$/);
export type Language = z.infer<typeof LanguageSchema>;
export const KeymapLayoutSchema = z
.string()
.max(50)
.regex(/[\w\-_]+/);
export type KeymapLayout = z.infer<typeof KeymapLayoutSchema>;
export const LayoutSchema = token().max(50);
export type Layout = z.infer<typeof LayoutSchema>;
export const FontSizeSchema = z.number().positive();
export type FontSize = z.infer<typeof FontSizeSchema>;
export const MaxLineWidthSchema = z.number().min(20).max(1000).or(z.literal(0));
export type MaxLineWidth = z.infer<typeof MaxLineWidthSchema>;
export const CustomBackgroundSchema = z
.string()
.regex(/(https|http):\/\/(www\.|).+\..+\/.+(\.png|\.gif|\.jpeg|\.jpg)/gi)
.or(z.literal(""));
export type CustomBackground = z.infer<typeof CustomBackgroundSchema>;
export const ConfigSchema = z
.object({
theme: ThemeNameSchema,
themeLight: ThemeNameSchema,
themeDark: ThemeNameSchema,
autoSwitchTheme: z.boolean(),
customTheme: z.boolean(),
//customThemeId: token().nonnegative().max(24),
customThemeColors: CustomThemeColorsSchema,
favThemes: FavThemesSchema,
showKeyTips: z.boolean(),
smoothCaret: SmoothCaretSchema,
quickRestart: QuickRestartSchema,
punctuation: z.boolean(),
numbers: z.boolean(),
words: WordCountSchema,
time: TimeConfigSchema,
mode: ModeSchema,
quoteLength: QuoteLengthConfigSchema,
language: LanguageSchema,
fontSize: FontSizeSchema,
freedomMode: z.boolean(),
difficulty: DifficultySchema,
blindMode: z.boolean(),
quickEnd: z.boolean(),
caretStyle: CaretStyleSchema,
paceCaretStyle: CaretStyleSchema,
flipTestColors: z.boolean(),
layout: LayoutSchema,
funbox: FunboxSchema,
confidenceMode: ConfidenceModeSchema,
indicateTypos: IndicateTyposSchema,
timerStyle: TimerStyleSchema,
liveSpeedStyle: LiveSpeedAccBurstStyleSchema,
liveAccStyle: LiveSpeedAccBurstStyleSchema,
liveBurstStyle: LiveSpeedAccBurstStyleSchema,
colorfulMode: z.boolean(),
randomTheme: RandomThemeSchema,
timerColor: TimerColorSchema,
timerOpacity: TimerOpacitySchema,
stopOnError: StopOnErrorSchema,
showAllLines: z.boolean(),
keymapMode: KeymapModeSchema,
keymapStyle: KeymapStyleSchema,
keymapLegendStyle: KeymapLegendStyleSchema,
keymapLayout: KeymapLayoutSchema,
keymapShowTopRow: KeymapShowTopRowSchema,
fontFamily: FontFamilySchema,
smoothLineScroll: z.boolean(),
alwaysShowDecimalPlaces: z.boolean(),
alwaysShowWordsHistory: z.boolean(),
singleListCommandLine: SingleListCommandLineSchema,
capsLockWarning: z.boolean(),
playSoundOnError: PlaySoundOnErrorSchema,
playSoundOnClick: PlaySoundOnClickSchema,
soundVolume: SoundVolumeSchema,
startGraphsAtZero: z.boolean(),
showOutOfFocusWarning: z.boolean(),
paceCaret: PaceCaretSchema,
paceCaretCustomSpeed: PaceCaretCustomSpeedSchema,
repeatedPace: z.boolean(),
accountChart: AccountChartSchema,
minWpm: MinimumWordsPerMinuteSchema,
minWpmCustomSpeed: MinWpmCustomSpeedSchema,
highlightMode: HighlightModeSchema,
tapeMode: TapeModeSchema,
typingSpeedUnit: TypingSpeedUnitSchema,
ads: AdsSchema,
hideExtraLetters: z.boolean(),
strictSpace: z.boolean(),
minAcc: MinimumAccuracySchema,
minAccCustom: MinimumAccuracyCustomSchema,
monkey: z.boolean(),
repeatQuotes: RepeatQuotesSchema,
oppositeShiftMode: OppositeShiftModeSchema,
customBackground: CustomBackgroundSchema,
customBackgroundSize: CustomBackgroundSizeSchema,
customBackgroundFilter: CustomBackgroundFilterSchema,
customLayoutfluid: CustomLayoutFluidSchema,
monkeyPowerLevel: MonkeyPowerLevelSchema,
minBurst: MinimumBurstSchema,
minBurstCustomSpeed: MinimumBurstCustomSpeedSchema,
burstHeatmap: z.boolean(),
britishEnglish: z.boolean(),
lazyMode: z.boolean(),
showAverage: ShowAverageSchema,
maxLineWidth: MaxLineWidthSchema,
})
.strict();
export type Config = z.infer<typeof ConfigSchema>;
export const PartialConfigSchema = ConfigSchema.partial();
export type PartialConfig = z.infer<typeof PartialConfigSchema>;

View file

@ -0,0 +1,29 @@
import { z } from "zod";
import { DifficultySchema } from "./configs";
import { StringNumberSchema } from "./util";
export const PersonalBestSchema = z.object({
acc: z.number().nonnegative().max(100),
consistency: z.number().nonnegative().max(100),
difficulty: DifficultySchema,
lazyMode: z.boolean().optional(),
language: z
.string()
.max(100)
.regex(/[\w+]+/),
punctuation: z.boolean().optional(),
numbers: z.boolean().optional(),
raw: z.number().nonnegative(),
wpm: z.number().nonnegative(),
timestamp: z.number().nonnegative(),
});
export type PersonalBest = z.infer<typeof PersonalBestSchema>;
export const PersonalBestsSchema = z.object({
time: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
words: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
quote: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
custom: z.record(z.literal("custom"), z.array(PersonalBestSchema)),
zen: z.record(z.literal("zen"), z.array(PersonalBestSchema)),
});
export type PersonalBests = z.infer<typeof PersonalBestsSchema>;

View file

@ -0,0 +1,9 @@
import { z, ZodString } from "zod";
export const StringNumberSchema = z.custom<`${number}`>((val) => {
return typeof val === "string" ? /^\d+$/.test(val) : false;
});
export type StringNumber = z.infer<typeof StringNumberSchema>;
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);

View file

@ -0,0 +1,13 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"moduleResolution": "Node",
"module": "ES6"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -7,6 +7,9 @@
"ts-check": "tsc --noEmit",
"lint": "eslint \"./**/*.ts\""
},
"dependencies": {
"@monkeytype/contracts": "*"
},
"devDependencies": {
"@monkeytype/eslint-config": "*",
"@monkeytype/typescript-config": "*",

View file

@ -1,174 +1,84 @@
import { PersonalBests } from "./user";
import { PersonalBests } from "@monkeytype/contracts/schemas/users";
export type SmoothCaret = "off" | "slow" | "medium" | "fast";
export type QuickRestart = "off" | "esc" | "tab" | "enter";
export type QuoteLength = -3 | -2 | -1 | 0 | 1 | 2 | 3;
export type SmoothCaret =
import("@monkeytype/contracts/schemas/configs").SmoothCaret;
export type QuickRestart =
import("@monkeytype/contracts/schemas/configs").QuickRestart;
export type QuoteLength =
import("@monkeytype/contracts/schemas/configs").QuoteLength;
export type CaretStyle =
| "off"
| "default"
| "block"
| "outline"
| "underline"
| "carrot"
| "banana";
export type Difficulty = "normal" | "expert" | "master";
export type Mode = "time" | "words" | "quote" | "custom" | "zen";
import("@monkeytype/contracts/schemas/configs").CaretStyle;
export type Difficulty =
import("@monkeytype/contracts/schemas/configs").Difficulty;
export type Mode = import("@monkeytype/contracts/schemas/configs").Mode;
export type Mode2<M extends Mode> = M extends M
? keyof PersonalBests[M]
: never;
export type Mode2Custom<M extends Mode> = Mode2<M> | "custom";
export type ConfidenceMode = "off" | "on" | "max";
export type IndicateTypos = "off" | "below" | "replace";
export type TimerStyle = "off" | "bar" | "text" | "mini";
export type LiveSpeedAccBurstStyle = "off" | "text" | "mini";
export type RandomTheme = "off" | "on" | "fav" | "light" | "dark" | "custom";
export type TimerColor = "black" | "sub" | "text" | "main";
export type TimerOpacity = "0.25" | "0.5" | "0.75" | "1";
export type StopOnError = "off" | "word" | "letter";
export type KeymapMode = "off" | "static" | "react" | "next";
export type ConfidenceMode =
import("@monkeytype/contracts/schemas/configs").ConfidenceMode;
export type IndicateTypos =
import("@monkeytype/contracts/schemas/configs").IndicateTypos;
export type TimerStyle =
import("@monkeytype/contracts/schemas/configs").TimerStyle;
export type LiveSpeedAccBurstStyle =
import("@monkeytype/contracts/schemas/configs").LiveSpeedAccBurstStyle;
export type RandomTheme =
import("@monkeytype/contracts/schemas/configs").RandomTheme;
export type TimerColor =
import("@monkeytype/contracts/schemas/configs").TimerColor;
export type TimerOpacity =
import("@monkeytype/contracts/schemas/configs").TimerOpacity;
export type StopOnError =
import("@monkeytype/contracts/schemas/configs").StopOnError;
export type KeymapMode =
import("@monkeytype/contracts/schemas/configs").KeymapMode;
export type KeymapStyle =
| "staggered"
| "alice"
| "matrix"
| "split"
| "split_matrix"
| "steno"
| "steno_matrix";
export type KeymapLegendStyle = "lowercase" | "uppercase" | "blank" | "dynamic";
export type KeymapShowTopRow = "always" | "layout" | "never";
export type SingleListCommandLine = "manual" | "on";
import("@monkeytype/contracts/schemas/configs").KeymapStyle;
export type KeymapLegendStyle =
import("@monkeytype/contracts/schemas/configs").KeymapLegendStyle;
export type KeymapShowTopRow =
import("@monkeytype/contracts/schemas/configs").KeymapShowTopRow;
export type SingleListCommandLine =
import("@monkeytype/contracts/schemas/configs").SingleListCommandLine;
export type PlaySoundOnClick =
| "off"
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12"
| "13"
| "14"
| "15";
export type PlaySoundOnError = "off" | "1" | "2" | "3" | "4";
export type SoundVolume = "0.1" | "0.5" | "1.0";
export type PaceCaret = "off" | "average" | "pb" | "last" | "custom" | "daily";
export type AccountChart = [
"off" | "on",
"off" | "on",
"off" | "on",
"off" | "on"
];
export type MinimumWordsPerMinute = "off" | "custom";
import("@monkeytype/contracts/schemas/configs").PlaySoundOnClick;
export type PlaySoundOnError =
import("@monkeytype/contracts/schemas/configs").PlaySoundOnError;
export type SoundVolume =
import("@monkeytype/contracts/schemas/configs").SoundVolume;
export type PaceCaret =
import("@monkeytype/contracts/schemas/configs").PaceCaret;
export type AccountChart =
import("@monkeytype/contracts/schemas/configs").AccountChart;
export type MinimumWordsPerMinute =
import("@monkeytype/contracts/schemas/configs").MinimumWordsPerMinute;
export type HighlightMode =
| "off"
| "letter"
| "word"
| "next_word"
| "next_two_words"
| "next_three_words";
export type TypingSpeedUnit = "wpm" | "cpm" | "wps" | "cps" | "wph";
export type Ads = "off" | "result" | "on" | "sellout";
export type MinimumAccuracy = "off" | "custom";
export type RepeatQuotes = "off" | "typing";
export type OppositeShiftMode = "off" | "on" | "keymap";
export type CustomBackgroundSize = "cover" | "contain" | "max";
export type CustomBackgroundFilter = [number, number, number, number, number];
export type CustomLayoutFluid = `${string}#${string}#${string}`;
export type MonkeyPowerLevel = "off" | "1" | "2" | "3" | "4";
export type MinimumBurst = "off" | "fixed" | "flex";
export type ShowAverage = "off" | "speed" | "acc" | "both";
export type TapeMode = "off" | "letter" | "word";
export type Config = {
theme: string;
themeLight: string;
themeDark: string;
autoSwitchTheme: boolean;
customTheme: boolean;
customThemeColors: string[];
favThemes: string[];
showKeyTips: boolean;
smoothCaret: SmoothCaret;
quickRestart: QuickRestart;
punctuation: boolean;
numbers: boolean;
words: number;
time: number;
mode: Mode;
quoteLength: QuoteLength[];
language: string;
fontSize: number;
freedomMode: boolean;
difficulty: Difficulty;
blindMode: boolean;
quickEnd: boolean;
caretStyle: CaretStyle;
paceCaretStyle: CaretStyle;
flipTestColors: boolean;
layout: string;
funbox: string;
confidenceMode: ConfidenceMode;
indicateTypos: IndicateTypos;
timerStyle: TimerStyle;
liveSpeedStyle: LiveSpeedAccBurstStyle;
liveAccStyle: LiveSpeedAccBurstStyle;
liveBurstStyle: LiveSpeedAccBurstStyle;
colorfulMode: boolean;
randomTheme: RandomTheme;
timerColor: TimerColor;
timerOpacity: TimerOpacity;
stopOnError: StopOnError;
showAllLines: boolean;
keymapMode: KeymapMode;
keymapStyle: KeymapStyle;
keymapLegendStyle: KeymapLegendStyle;
keymapLayout: string;
keymapShowTopRow: KeymapShowTopRow;
fontFamily: string;
smoothLineScroll: boolean;
alwaysShowDecimalPlaces: boolean;
alwaysShowWordsHistory: boolean;
singleListCommandLine: SingleListCommandLine;
capsLockWarning: boolean;
playSoundOnError: PlaySoundOnError;
playSoundOnClick: PlaySoundOnClick;
soundVolume: SoundVolume;
startGraphsAtZero: boolean;
showOutOfFocusWarning: boolean;
paceCaret: PaceCaret;
paceCaretCustomSpeed: number;
repeatedPace: boolean;
accountChart: AccountChart;
minWpm: MinimumWordsPerMinute;
minWpmCustomSpeed: number;
highlightMode: HighlightMode;
typingSpeedUnit: TypingSpeedUnit;
ads: Ads;
hideExtraLetters: boolean;
strictSpace: boolean;
minAcc: MinimumAccuracy;
minAccCustom: number;
monkey: boolean;
repeatQuotes: RepeatQuotes;
oppositeShiftMode: OppositeShiftMode;
customBackground: string;
customBackgroundSize: CustomBackgroundSize;
customBackgroundFilter: CustomBackgroundFilter;
customLayoutfluid: CustomLayoutFluid;
monkeyPowerLevel: MonkeyPowerLevel;
minBurst: MinimumBurst;
minBurstCustomSpeed: number;
burstHeatmap: boolean;
britishEnglish: boolean;
lazyMode: boolean;
showAverage: ShowAverage;
tapeMode: TapeMode;
maxLineWidth: number;
};
import("@monkeytype/contracts/schemas/configs").HighlightMode;
export type TypingSpeedUnit =
import("@monkeytype/contracts/schemas/configs").TypingSpeedUnit;
export type Ads = import("@monkeytype/contracts/schemas/configs").Ads;
export type MinimumAccuracy =
import("@monkeytype/contracts/schemas/configs").MinimumAccuracy;
export type RepeatQuotes =
import("@monkeytype/contracts/schemas/configs").RepeatQuotes;
export type OppositeShiftMode =
import("@monkeytype/contracts/schemas/configs").OppositeShiftMode;
export type CustomBackgroundSize =
import("@monkeytype/contracts/schemas/configs").CustomBackgroundSize;
export type CustomBackgroundFilter =
import("@monkeytype/contracts/schemas/configs").CustomBackgroundFilter;
export type CustomLayoutFluid =
import("@monkeytype/contracts/schemas/configs").CustomLayoutFluid;
export type MonkeyPowerLevel =
import("@monkeytype/contracts/schemas/configs").MonkeyPowerLevel;
export type MinimumBurst =
import("@monkeytype/contracts/schemas/configs").MinimumBurst;
export type ShowAverage =
import("@monkeytype/contracts/schemas/configs").ShowAverage;
export type TapeMode = import("@monkeytype/contracts/schemas/configs").TapeMode;
export type CustomThemeColors =
import("@monkeytype/contracts/schemas/configs").CustomThemeColors;
export type Config = import("@monkeytype/contracts/schemas/configs").Config;
export type ConfigValue = Config[keyof Config];

View file

@ -1,4 +1,9 @@
import { Config, Difficulty, Mode, Mode2 } from "./config";
import {
Config,
Difficulty,
Mode,
} from "@monkeytype/contracts/schemas/configs";
import { Mode2 } from "./config";
import { PersonalBest, PersonalBests } from "./user";
export type ValidModeRule = {
@ -389,7 +394,7 @@ export type UserProfileDetails = {
export type CustomTheme = {
_id: string;
name: string;
colors: string[];
colors: import("@monkeytype/contracts/schemas/configs").CustomThemeColors;
};
export type PremiumInfo = {

View file

@ -1,23 +1,5 @@
import { Difficulty } from "./config";
import { StringNumber } from "./util";
export type PersonalBest =
import("@monkeytype/contracts/schemas/users").PersonalBest;
export type PersonalBest = {
acc: number;
consistency?: number;
difficulty: Difficulty;
lazyMode?: boolean;
language: string;
punctuation?: boolean;
numbers?: boolean;
raw: number;
wpm: number;
timestamp: number;
};
export type PersonalBests = {
time: Record<StringNumber, PersonalBest[]>;
words: Record<StringNumber, PersonalBest[]>;
quote: Record<StringNumber, PersonalBest[]>;
custom: Partial<Record<"custom", PersonalBest[]>>;
zen: Partial<Record<"zen", PersonalBest[]>>;
};
export type PersonalBests =
import("@monkeytype/contracts/schemas/users").PersonalBests;

View file

@ -1 +1,2 @@
export type StringNumber = `${number}`;
export type StringNumber =
import("@monkeytype/contracts/schemas/util").StringNumber;

View file

@ -4,9 +4,7 @@
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"moduleResolution": "Node",
"module": "ES6"
"declarationMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]

View file

@ -23,6 +23,9 @@
"persistent": true,
"cache": false
},
"start": {
"dependsOn": ["build"]
},
"@monkeytype/frontend#validate-json": {
"dependsOn": ["^parallel"]
},