mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
parent
d96832c927
commit
7f9f704dcd
|
@ -1,3 +1,4 @@
|
|||
backend/dist
|
||||
backend/__migration__
|
||||
docker
|
||||
docker
|
||||
backend/scripts
|
||||
|
|
132
backend/__tests__/api/controllers/config.spec.ts
Normal file
132
backend/__tests__/api/controllers/config.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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"]
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
46
backend/redocly.yaml
Normal 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
120
backend/scripts/openapi.ts
Normal 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));
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
40
backend/src/api/routes/docs.ts
Normal file
40
backend/src/api/routes/docs.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"),
|
||||
|
|
73
backend/src/api/ts-rest-adapter.ts
Normal file
73
backend/src/api/ts-rest-adapter.ts
Normal 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>);
|
|
@ -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";
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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,
|
||||
|
|
18
backend/src/types/types.d.ts
vendored
18
backend/src/types/types.d.ts
vendored
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
533
frontend/__tests__/root/config.spec.ts
Normal file
533
frontend/__tests__/root/config.spec.ts
Normal 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);
|
||||
}
|
|
@ -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"]
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
74
frontend/src/ts/ape/adapters/ts-rest-adapter.ts
Normal file
74
frontend/src/ts/ape/adapters/ts-rest-adapter.ts
Normal 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 */
|
|
@ -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 } });
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
10
frontend/src/ts/ape/types/configs.d.ts
vendored
10
frontend/src/ts/ape/types/configs.d.ts
vendored
|
@ -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;
|
||||
}
|
|
@ -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
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
2
frontend/src/ts/types/types.d.ts
vendored
2
frontend/src/ts/types/types.d.ts
vendored
|
@ -182,7 +182,7 @@ declare namespace MonkeyTypes {
|
|||
|
||||
type RawCustomTheme = {
|
||||
name: string;
|
||||
colors: string[];
|
||||
colors: import("@monkeytype/contracts/schemas/configs").CustomThemeColors;
|
||||
};
|
||||
|
||||
type CustomTheme = {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
1664
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
5
packages/contracts/.eslintrc.cjs
Normal file
5
packages/contracts/.eslintrc.cjs
Normal file
|
@ -0,0 +1,5 @@
|
|||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@monkeytype/eslint-config"],
|
||||
};
|
94
packages/contracts/esbuild.config.js
Normal file
94
packages/contracts/esbuild.config.js
Normal 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);
|
||||
}
|
35
packages/contracts/package.json
Normal file
35
packages/contracts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
61
packages/contracts/src/configs.ts
Normal file
61
packages/contracts/src/configs.ts
Normal 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,
|
||||
}
|
||||
);
|
8
packages/contracts/src/index.ts
Normal file
8
packages/contracts/src/index.ts
Normal 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,
|
||||
});
|
77
packages/contracts/src/schemas/api.ts
Normal file
77
packages/contracts/src/schemas/api.ts
Normal 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"),
|
||||
};
|
390
packages/contracts/src/schemas/configs.ts
Normal file
390
packages/contracts/src/schemas/configs.ts
Normal 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>;
|
29
packages/contracts/src/schemas/users.ts
Normal file
29
packages/contracts/src/schemas/users.ts
Normal 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>;
|
9
packages/contracts/src/schemas/util.ts
Normal file
9
packages/contracts/src/schemas/util.ts
Normal 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_]+$/);
|
13
packages/contracts/tsconfig.json
Normal file
13
packages/contracts/tsconfig.json
Normal 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"]
|
||||
}
|
|
@ -7,6 +7,9 @@
|
|||
"ts-check": "tsc --noEmit",
|
||||
"lint": "eslint \"./**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@monkeytype/contracts": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@monkeytype/eslint-config": "*",
|
||||
"@monkeytype/typescript-config": "*",
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export type StringNumber = `${number}`;
|
||||
export type StringNumber =
|
||||
import("@monkeytype/contracts/schemas/util").StringNumber;
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"moduleResolution": "Node",
|
||||
"module": "ES6"
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
|
|
@ -23,6 +23,9 @@
|
|||
"persistent": true,
|
||||
"cache": false
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
"@monkeytype/frontend#validate-json": {
|
||||
"dependsOn": ["^parallel"]
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue