adding monkeytype
Some checks failed
Mark Stale PRs / stale (push) Has been cancelled

This commit is contained in:
Benjamin Falch
2026-04-23 13:53:44 +02:00
parent e214a2fd35
commit 2bc741fb78
1930 changed files with 7590652 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"ignorePatterns": ["node_modules", "dist", ".turbo"],
"extends": [
"../oxlint-config/index.jsonc"
// "@monkeytype/oxlint-config"
]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import * as Validation from "@monkeytype/schemas/validation/validation";
const containsDisallowedWords = Validation.__testing.containsDisallowedWords;
describe("validation", () => {
it("containsDisallowedWords", () => {
const testCases = [
{
text: "https://www.fuckyou.com",
expected: true,
},
{
text: "fucking_profane",
expected: true,
},
{
text: "fucker",
expected: true,
},
{
text: "Hello world!",
expected: false,
},
{
text: "I fucking hate you",
expected: true,
},
{
text: "I love you",
expected: false,
},
{
text: "\n.fuck!",
expected: true,
},
];
testCases.forEach((testCase) => {
expect(containsDisallowedWords(testCase.text, "substring")).toBe(
testCase.expected,
);
});
});
});

View File

@@ -0,0 +1,42 @@
{
"name": "@monkeytype/contracts",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./*": {
"types": "./src/*.ts",
"import": "./dist/*.mjs",
"require": "./dist/*.js"
}
},
"scripts": {
"dev": "tsup-node --watch",
"build": "npm run madge && tsup-node",
"test": "vitest run",
"madge": " madge --circular --extensions ts ./src",
"ts-check": "tsc --noEmit",
"lint": "oxlint . --type-aware --type-check",
"lint-fast": "oxlint ."
},
"dependencies": {
"@monkeytype/schemas": "workspace:*"
},
"devDependencies": {
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"tsup": "8.4.0",
"typescript": "6.0.2",
"vitest": "4.1.0"
},
"peerDependencies": {
"@ts-rest/core": "3.52.1",
"zod": "3.23.8"
}
}

View File

@@ -0,0 +1,142 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { IdSchema } from "@monkeytype/schemas/util";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
export const ToggleBanRequestSchema = z
.object({
uid: IdSchema,
})
.strict();
export type ToggleBanRequest = z.infer<typeof ToggleBanRequestSchema>;
export const ClearStreakHourOffsetRequestSchema = z
.object({
uid: IdSchema,
})
.strict();
export type ClearStreakHourOffsetRequest = z.infer<
typeof ClearStreakHourOffsetRequestSchema
>;
export const ToggleBanResponseSchema = responseWithData(
z.object({
banned: z.boolean(),
}),
).strict();
export type ToggleBanResponse = z.infer<typeof ToggleBanResponseSchema>;
export const AcceptReportsRequestSchema = z
.object({
reports: z.array(z.object({ reportId: z.string() }).strict()).nonempty(),
})
.strict();
export type AcceptReportsRequest = z.infer<typeof AcceptReportsRequestSchema>;
export const RejectReportsRequestSchema = z
.object({
reports: z
.array(
z
.object({ reportId: z.string(), reason: z.string().optional() })
.strict(),
)
.nonempty(),
})
.strict();
export type RejectReportsRequest = z.infer<typeof RejectReportsRequestSchema>;
export const SendForgotPasswordEmailRequestSchema = z
.object({
email: z.string().email(),
})
.strict();
export type SendForgotPasswordEmailRequest = z.infer<
typeof SendForgotPasswordEmailRequestSchema
>;
const c = initContract();
export const adminContract = c.router(
{
test: {
summary: "test permission",
description: "Check for admin permission for the current user",
method: "GET",
path: "",
responses: {
200: MonkeyResponseSchema,
},
},
toggleBan: {
summary: "toggle user ban",
description: "Ban an unbanned user or unban a banned user.",
method: "POST",
path: "/toggleBan",
body: ToggleBanRequestSchema,
responses: {
200: ToggleBanResponseSchema,
},
},
clearStreakHourOffset: {
summary: "clear streak hour offset",
description: "Clear the streak hour offset for a user",
method: "POST",
path: "/clearStreakHourOffset",
body: ClearStreakHourOffsetRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
acceptReports: {
summary: "accept reports",
description: "Accept one or many reports",
method: "POST",
path: "/report/accept",
body: AcceptReportsRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
rejectReports: {
summary: "reject reports",
description: "Reject one or many reports",
method: "POST",
path: "/report/reject",
body: RejectReportsRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
sendForgotPasswordEmail: {
summary: "send forgot password email",
description: "Send a forgot password email to the given user email",
method: "POST",
path: "/sendForgotPasswordEmail",
body: SendForgotPasswordEmailRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
},
{
pathPrefix: "/admin",
strictStatusCodes: true,
metadata: meta({
openApiTags: "admin",
authenticationOptions: { noCache: true },
rateLimit: "adminLimit",
requirePermission: "admin",
requireConfiguration: {
path: "admin.endpointsEnabled",
invalidMessage: "Admin endpoints are currently disabled.",
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,110 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import {
ApeKeySchema,
ApeKeysSchema,
ApeKeyUserDefinedSchema,
} from "@monkeytype/schemas/ape-keys";
import { IdSchema } from "@monkeytype/schemas/util";
export const GetApeKeyResponseSchema = responseWithData(ApeKeysSchema);
export type GetApeKeyResponse = z.infer<typeof GetApeKeyResponseSchema>;
export const AddApeKeyRequestSchema = ApeKeyUserDefinedSchema;
export type AddApeKeyRequest = z.infer<typeof AddApeKeyRequestSchema>;
export const AddApeKeyResponseSchema = responseWithData(
z.object({
apeKeyId: IdSchema,
apeKey: z.string().base64(),
apeKeyDetails: ApeKeySchema,
}),
);
export type AddApeKeyResponse = z.infer<typeof AddApeKeyResponseSchema>;
export const EditApeKeyRequestSchema = AddApeKeyRequestSchema.partial();
export type EditApeKeyRequest = z.infer<typeof EditApeKeyRequestSchema>;
export const ApeKeyParamsSchema = z.object({
apeKeyId: IdSchema,
});
export type ApeKeyParams = z.infer<typeof ApeKeyParamsSchema>;
const c = initContract();
export const apeKeysContract = c.router(
{
get: {
summary: "get ape keys",
description: "Get ape keys of the current user.",
method: "GET",
path: "",
responses: {
200: GetApeKeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGet",
}),
},
add: {
summary: "add ape key",
description: "Add an ape key for the current user.",
method: "POST",
path: "",
body: AddApeKeyRequestSchema.strict(),
responses: {
200: AddApeKeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGenerate",
}),
},
save: {
summary: "update ape key",
description: "Update an existing ape key for the current user.",
method: "PATCH",
path: "/:apeKeyId",
pathParams: ApeKeyParamsSchema,
body: EditApeKeyRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGenerate",
}),
},
delete: {
summary: "delete ape key",
description: "Delete ape key by id.",
method: "DELETE",
path: "/:apeKeyId",
pathParams: ApeKeyParamsSchema,
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGenerate",
}),
},
},
{
pathPrefix: "/ape-keys",
strictStatusCodes: true,
metadata: meta({
openApiTags: "ape-keys",
requirePermission: "canManageApeKeys",
requireConfiguration: {
path: "apeKeys.endpointsEnabled",
invalidMessage: "ApeKeys are currently disabled.",
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,69 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { PartialConfigSchema } from "@monkeytype/schemas/configs";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithNullableData,
} from "./util/api";
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,
},
metadata: meta({
rateLimit: "configGet",
}),
},
save: {
summary: "update config",
description:
"Update the config of the current user. Only provided values will be updated while the missing values will be unchanged.",
method: "PATCH",
path: "",
body: PartialConfigSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "configUpdate",
}),
},
delete: {
summary: "delete config",
description: "Delete/reset the config for the current user.",
method: "DELETE",
path: "",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "configDelete",
}),
},
},
{
pathPrefix: "/configs",
strictStatusCodes: true,
metadata: meta({
openApiTags: "configs",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,102 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { ConfigurationSchema } from "@monkeytype/schemas/configuration";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
export const GetConfigurationResponseSchema =
responseWithData(ConfigurationSchema);
export type GetConfigurationResponse = z.infer<
typeof GetConfigurationResponseSchema
>;
// marked as deprecated but zod team might reconsider according to https://github.com/colinhacks/zod/issues/2854#issuecomment-3100623150
// oxlint-disable-next-line no-deprecated
export const PartialConfigurationSchema = ConfigurationSchema.deepPartial();
export type PartialConfiguration = z.infer<typeof PartialConfigurationSchema>;
export const PatchConfigurationRequestSchema = z
.object({
configuration: PartialConfigurationSchema.strict(),
})
.strict();
export type PatchConfigurationRequest = z.infer<
typeof PatchConfigurationRequestSchema
>;
export const ConfigurationSchemaResponseSchema = responseWithData(z.object({})); //TODO define schema?
export type ConfigurationSchemaResponse = z.infer<
typeof ConfigurationSchemaResponseSchema
>;
const c = initContract();
export const configurationContract = c.router(
{
get: {
summary: "get configuration",
description: "Get server configuration",
method: "GET",
path: "",
responses: {
200: GetConfigurationResponseSchema,
},
metadata: meta({
authenticationOptions: {
isPublic: true,
},
}),
},
update: {
summary: "update configuration",
description:
"Update the server configuration. Only provided values will be updated while the missing values will be unchanged.",
method: "PATCH",
path: "",
body: PatchConfigurationRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: {
noCache: true,
isPublicOnDev: true,
},
rateLimit: "adminLimit",
requirePermission: "admin",
}),
},
getSchema: {
summary: "get configuration schema",
description: "Get schema definition of the server configuration.",
method: "GET",
path: "/schema",
responses: {
200: ConfigurationSchemaResponseSchema,
},
metadata: meta({
authenticationOptions: {
isPublicOnDev: true,
noCache: true,
},
rateLimit: "adminLimit",
requirePermission: "admin",
}),
},
},
{
pathPrefix: "/configuration",
strictStatusCodes: true,
metadata: meta({
openApiTags: "configuration",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,136 @@
import { initContract } from "@ts-rest/core";
import {
ConnectionSchema,
ConnectionStatusSchema,
ConnectionTypeSchema,
} from "@monkeytype/schemas/connections";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import { IdSchema } from "@monkeytype/schemas/util";
const c = initContract();
export const GetConnectionsResponseSchema = responseWithData(
z.array(ConnectionSchema),
);
export type GetConnectionsResponse = z.infer<
typeof GetConnectionsResponseSchema
>;
export const GetConnectionsQuerySchema = z.object({
status: z
.array(ConnectionStatusSchema)
.or(ConnectionStatusSchema.transform((it) => [it]))
.optional(),
type: z
.array(ConnectionTypeSchema)
.or(ConnectionTypeSchema.transform((it) => [it]))
.optional(),
});
export type GetConnectionsQuery = z.infer<typeof GetConnectionsQuerySchema>;
export const CreateConnectionRequestSchema = ConnectionSchema.pick({
receiverName: true,
});
export type CreateConnectionRequest = z.infer<
typeof CreateConnectionRequestSchema
>;
export const CreateConnectionResponseSchema =
responseWithData(ConnectionSchema);
export type CreateConnectionResponse = z.infer<
typeof CreateConnectionResponseSchema
>;
export const IdPathParamsSchema = z.object({
id: IdSchema,
});
export type IdPathParams = z.infer<typeof IdPathParamsSchema>;
export const UpdateConnectionRequestSchema = z.object({
status: ConnectionStatusSchema.exclude(["pending"]),
});
export type UpdateConnectionRequest = z.infer<
typeof UpdateConnectionRequestSchema
>;
export const connectionsContract = c.router(
{
get: {
summary: "get connections",
description: "Get connections of the current user",
method: "GET",
path: "/",
query: GetConnectionsQuerySchema.strict(),
responses: {
200: GetConnectionsResponseSchema,
},
metadata: meta({
rateLimit: "connectionGet",
}),
},
create: {
summary: "create connection",
description: "Request a connection to a user ",
method: "POST",
path: "/",
body: CreateConnectionRequestSchema.strict(),
responses: {
200: CreateConnectionResponseSchema,
404: MonkeyResponseSchema.describe("ReceiverUid unknown"),
409: MonkeyResponseSchema.describe(
"Duplicate connection, blocked or max connections reached",
),
},
metadata: meta({
rateLimit: "connectionCreate",
}),
},
delete: {
summary: "delete connection",
description: "Delete a connection",
method: "DELETE",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionDelete",
}),
},
update: {
summary: "update connection",
description: "Update a connection status",
method: "PATCH",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: UpdateConnectionRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionUpdate",
}),
},
},
{
pathPrefix: "/connections",
strictStatusCodes: true,
metadata: meta({
openApiTags: "connections",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Connections are not available at this time.",
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,79 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import { IdSchema } from "@monkeytype/schemas/util";
export const GenerateDataRequestSchema = z.object({
username: z.string(),
createUser: z
.boolean()
.optional()
.describe(
"If `true` create user with <username>@example.com and password `password`. If false user has to exist.",
),
firstTestTimestamp: z.number().int().nonnegative().optional(),
lastTestTimestamp: z.number().int().nonnegative().optional(),
minTestsPerDay: z.number().int().nonnegative().optional(),
maxTestsPerDay: z.number().int().nonnegative().optional(),
});
export type GenerateDataRequest = z.infer<typeof GenerateDataRequestSchema>;
export const GenerateDataResponseSchema = responseWithData(
z.object({
uid: IdSchema,
email: z.string().email(),
}),
);
export type GenerateDataResponse = z.infer<typeof GenerateDataResponseSchema>;
export const AddDebugInboxItemRequestSchema = z.object({
rewardType: z.enum(["xp", "badge", "none"]),
});
export type AddDebugInboxItemRequest = z.infer<
typeof AddDebugInboxItemRequestSchema
>;
const c = initContract();
export const devContract = c.router(
{
generateData: {
summary: "generate data",
description: "Generate test results for the given user.",
method: "POST",
path: "/generateData",
body: GenerateDataRequestSchema.strict(),
responses: {
200: GenerateDataResponseSchema,
},
},
addDebugInboxItem: {
summary: "add debug inbox item",
description: "Add a debug inbox item with optional reward.",
method: "POST",
path: "/addDebugInboxItem",
body: AddDebugInboxItemRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
openApiTags: "development",
}),
},
},
{
pathPrefix: "/dev",
strictStatusCodes: true,
metadata: meta({
openApiTags: "development",
authenticationOptions: {
isPublicOnDev: true,
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,41 @@
import { initContract } from "@ts-rest/core";
import { adminContract } from "./admin";
import { apeKeysContract } from "./ape-keys";
import { configsContract } from "./configs";
import { presetsContract } from "./presets";
import { psasContract } from "./psas";
import { publicContract } from "./public";
import { leaderboardsContract } from "./leaderboards";
import { resultsContract } from "./results";
import { configurationContract } from "./configuration";
import { devContract } from "./dev";
import { usersContract } from "./users";
import { quotesContract } from "./quotes";
import { webhooksContract } from "./webhooks";
import { connectionsContract } from "./connections";
const c = initContract();
export const contract = c.router({
admin: adminContract,
apeKeys: apeKeysContract,
configs: configsContract,
presets: presetsContract,
psas: psasContract,
public: publicContract,
leaderboards: leaderboardsContract,
results: resultsContract,
configuration: configurationContract,
dev: devContract,
users: usersContract,
quotes: quotesContract,
webhooks: webhooksContract,
connections: connectionsContract,
});
/**
* Whenever there is a breaking change with old frontend clients increase this number.
* This will inform the frontend to refresh.
*/
export const COMPATIBILITY_CHECK = 4;
export const COMPATIBILITY_CHECK_HEADER = "X-Compatibility-Check";

View File

@@ -0,0 +1,260 @@
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyClientError,
responseWithData,
responseWithNullableData,
} from "./util/api";
import {
LeaderboardEntrySchema,
XpLeaderboardEntrySchema,
} from "@monkeytype/schemas/leaderboards";
import { Mode2Schema, ModeSchema } from "@monkeytype/schemas/shared";
import { initContract } from "@ts-rest/core";
import { LanguageSchema } from "@monkeytype/schemas/languages";
const LanguageAndModeQuerySchema = z.object({
language: LanguageSchema,
mode: ModeSchema,
mode2: Mode2Schema,
});
const PaginationQuerySchema = z.object({
page: z.number().int().safe().nonnegative().default(0),
pageSize: z.number().int().safe().positive().min(10).max(200).default(50),
});
export type PaginationQuery = z.infer<typeof PaginationQuerySchema>;
const FriendsOnlyQuerySchema = z.object({
friendsOnly: z
.boolean()
.optional()
.describe("include only users from your friends list, defaults to false."),
});
export type FriendsOnlyQuery = z.infer<typeof FriendsOnlyQuerySchema>;
const LeaderboardResponseSchema = z.object({
count: z.number().int().nonnegative(),
pageSize: z.number().int().positive(),
});
//--------------------------------------------------------------------------
export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge(
PaginationQuerySchema,
).merge(FriendsOnlyQuerySchema);
export type GetLeaderboardQuery = z.infer<typeof GetLeaderboardQuerySchema>;
export const GetLeaderboardResponseSchema = responseWithData(
LeaderboardResponseSchema.extend({
entries: z.array(LeaderboardEntrySchema),
}),
);
export type GetLeaderboardResponse = z.infer<
typeof GetLeaderboardResponseSchema
>;
//--------------------------------------------------------------------------
export const GetLeaderboardRankQuerySchema = LanguageAndModeQuerySchema.merge(
FriendsOnlyQuerySchema,
);
export type GetLeaderboardRankQuery = z.infer<
typeof GetLeaderboardRankQuerySchema
>;
export const GetLeaderboardRankResponseSchema = responseWithNullableData(
LeaderboardEntrySchema,
);
export type GetLeaderboardRankResponse = z.infer<
typeof GetLeaderboardRankResponseSchema
>;
//--------------------------------------------------------------------------
export const DailyLeaderboardQuerySchema = LanguageAndModeQuerySchema.extend({
daysBefore: z.literal(1).optional(),
}).merge(FriendsOnlyQuerySchema);
export type DailyLeaderboardQuery = z.infer<typeof DailyLeaderboardQuerySchema>;
export const GetDailyLeaderboardQuerySchema = DailyLeaderboardQuerySchema.merge(
PaginationQuerySchema,
);
export type GetDailyLeaderboardQuery = z.infer<
typeof GetDailyLeaderboardQuerySchema
>;
export const GetDailyLeaderboardResponseSchema = responseWithData(
LeaderboardResponseSchema.extend({
entries: z.array(LeaderboardEntrySchema),
minWpm: z.number().nonnegative(),
}),
);
export type GetDailyLeaderboardResponse = z.infer<
typeof GetDailyLeaderboardResponseSchema
>;
//--------------------------------------------------------------------------
export const GetDailyLeaderboardRankQuerySchema = DailyLeaderboardQuerySchema;
export type GetDailyLeaderboardRankQuery = z.infer<
typeof GetDailyLeaderboardRankQuerySchema
>;
export const GetLeaderboardDailyRankResponseSchema = responseWithNullableData(
LeaderboardEntrySchema,
);
export type GetLeaderboardDailyRankResponse = z.infer<
typeof GetLeaderboardDailyRankResponseSchema
>;
//--------------------------------------------------------------------------
const WeeklyXpLeaderboardQuerySchema = z
.object({
weeksBefore: z.literal(1).optional(),
})
.merge(FriendsOnlyQuerySchema);
export const GetWeeklyXpLeaderboardQuerySchema =
WeeklyXpLeaderboardQuerySchema.merge(PaginationQuerySchema);
export type GetWeeklyXpLeaderboardQuery = z.infer<
typeof GetWeeklyXpLeaderboardQuerySchema
>;
export const GetWeeklyXpLeaderboardResponseSchema = responseWithData(
LeaderboardResponseSchema.extend({
entries: z.array(XpLeaderboardEntrySchema),
}),
);
export type GetWeeklyXpLeaderboardResponse = z.infer<
typeof GetWeeklyXpLeaderboardResponseSchema
>;
//--------------------------------------------------------------------------
export const GetWeeklyXpLeaderboardRankQuerySchema =
WeeklyXpLeaderboardQuerySchema;
export type GetWeeklyXpLeaderboardRankQuery = z.infer<
typeof GetWeeklyXpLeaderboardRankQuerySchema
>;
export const GetWeeklyXpLeaderboardRankResponseSchema =
responseWithNullableData(XpLeaderboardEntrySchema);
export type GetWeeklyXpLeaderboardRankResponse = z.infer<
typeof GetWeeklyXpLeaderboardRankResponseSchema
>;
//--------------------------------------------------------------------------
const c = initContract();
export const leaderboardsContract = c.router(
{
get: {
summary: "get leaderboard",
description: "Get all-time leaderboard.",
method: "GET",
path: "",
query: GetLeaderboardQuerySchema.strict(),
responses: {
200: GetLeaderboardResponseSchema,
404: MonkeyClientError,
},
metadata: meta({
authenticationOptions: { isPublic: true },
}),
},
getRank: {
summary: "get leaderboard rank",
description:
"Get the rank of the current user on the all-time leaderboard",
method: "GET",
path: "/rank",
query: GetLeaderboardRankQuerySchema.strict(),
responses: {
200: GetLeaderboardRankResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
}),
},
getDaily: {
summary: "get daily leaderboard",
description: "Get daily leaderboard.",
method: "GET",
path: "/daily",
query: GetDailyLeaderboardQuerySchema.strict(),
responses: {
200: GetDailyLeaderboardResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
requireConfiguration: {
path: "dailyLeaderboards.enabled",
invalidMessage: "Daily leaderboards are not available at this time.",
},
}),
},
getDailyRank: {
summary: "get daily leaderboard rank",
description: "Get the rank of the current user on the daily leaderboard",
method: "GET",
path: "/daily/rank",
query: GetDailyLeaderboardRankQuerySchema.strict(),
responses: {
200: GetLeaderboardDailyRankResponseSchema,
},
metadata: meta({
requireConfiguration: {
path: "dailyLeaderboards.enabled",
invalidMessage: "Daily leaderboards are not available at this time.",
},
}),
},
getWeeklyXp: {
summary: "get weekly xp leaderboard",
description: "Get weekly xp leaderboard",
method: "GET",
path: "/xp/weekly",
query: GetWeeklyXpLeaderboardQuerySchema.strict(),
responses: {
200: GetWeeklyXpLeaderboardResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
requireConfiguration: {
path: "leaderboards.weeklyXp.enabled",
invalidMessage:
"Weekly XP leaderboards are not available at this time.",
},
}),
},
getWeeklyXpRank: {
summary: "get weekly xp leaderboard rank",
description:
"Get the rank of the current user on the weekly xp leaderboard",
method: "GET",
path: "/xp/weekly/rank",
query: GetWeeklyXpLeaderboardRankQuerySchema.strict(),
responses: {
200: GetWeeklyXpLeaderboardRankResponseSchema,
},
metadata: meta({
requireConfiguration: {
path: "leaderboards.weeklyXp.enabled",
invalidMessage:
"Weekly XP leaderboards are not available at this time.",
},
}),
},
},
{
pathPrefix: "/leaderboards",
strictStatusCodes: true,
metadata: meta({
openApiTags: "leaderboards",
rateLimit: "leaderboardsGet",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,98 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import {
EditPresetRequestSchema,
PresetSchema,
} from "@monkeytype/schemas/presets";
import { IdSchema } from "@monkeytype/schemas/util";
export const GetPresetResponseSchema = responseWithData(z.array(PresetSchema));
export type GetPresetResponse = z.infer<typeof GetPresetResponseSchema>;
export const AddPresetRequestSchema = PresetSchema.omit({ _id: true });
export type AddPresetRequest = z.infer<typeof AddPresetRequestSchema>;
export const AddPresetResponseSchemna = responseWithData(
z.object({ presetId: IdSchema }),
);
export type AddPresetResponse = z.infer<typeof AddPresetResponseSchemna>;
export const DeletePresetsParamsSchema = z.object({
presetId: IdSchema,
});
export type DeletePresetsParams = z.infer<typeof DeletePresetsParamsSchema>;
const c = initContract();
export const presetsContract = c.router(
{
get: {
summary: "get presets",
description: "Get presets of the current user.",
method: "GET",
path: "",
responses: {
200: GetPresetResponseSchema,
},
metadata: meta({
rateLimit: "presetsGet",
}),
},
add: {
summary: "add preset",
description: "Add a new preset for the current user.",
method: "POST",
path: "",
body: AddPresetRequestSchema.strict(),
responses: {
200: AddPresetResponseSchemna,
},
metadata: meta({
rateLimit: "presetsAdd",
}),
},
save: {
summary: "update preset",
description: "Update an existing preset for the current user.",
method: "PATCH",
path: "",
body: EditPresetRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "presetsEdit",
}),
},
delete: {
summary: "delete preset",
description: "Delete preset by id.",
method: "DELETE",
path: "/:presetId",
pathParams: DeletePresetsParamsSchema,
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "presetsRemove",
}),
},
},
{
pathPrefix: "/presets",
strictStatusCodes: true,
metadata: meta({
openApiTags: "presets",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,34 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { PSASchema } from "@monkeytype/schemas/psas";
import { CommonResponses, meta, responseWithData } from "./util/api";
export const GetPsaResponseSchema = responseWithData(z.array(PSASchema));
export type GetPsaResponse = z.infer<typeof GetPsaResponseSchema>;
const c = initContract();
export const psasContract = c.router(
{
get: {
summary: "get psas",
description: "Get list of public service announcements",
method: "GET",
path: "",
responses: {
200: GetPsaResponseSchema,
},
},
},
{
pathPrefix: "/psas",
strictStatusCodes: true,
metadata: meta({
openApiTags: "psas",
authenticationOptions: {
isPublic: true,
},
rateLimit: "psaGet",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,70 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { CommonResponses, meta, responseWithData } from "./util/api";
import {
SpeedHistogramSchema,
TypingStatsSchema,
} from "@monkeytype/schemas/public";
import { Mode2Schema, ModeSchema } from "@monkeytype/schemas/shared";
import { LanguageSchema } from "@monkeytype/schemas/languages";
export const GetSpeedHistogramQuerySchema = z
.object({
language: LanguageSchema,
mode: ModeSchema,
mode2: Mode2Schema,
})
.strict();
export type GetSpeedHistogramQuery = z.infer<
typeof GetSpeedHistogramQuerySchema
>;
export const GetSpeedHistogramResponseSchema =
responseWithData(SpeedHistogramSchema);
export type GetSpeedHistogramResponse = z.infer<
typeof GetSpeedHistogramResponseSchema
>;
export const GetTypingStatsResponseSchema = responseWithData(TypingStatsSchema);
export type GetTypingStatsResponse = z.infer<
typeof GetTypingStatsResponseSchema
>;
const c = initContract();
export const publicContract = c.router(
{
getSpeedHistogram: {
summary: "get speed histogram",
description:
"get number of users personal bests grouped by wpm level (multiples of ten)",
method: "GET",
path: "/speedHistogram",
query: GetSpeedHistogramQuerySchema,
responses: {
200: GetSpeedHistogramResponseSchema,
},
},
getTypingStats: {
summary: "get typing stats",
description: "get number of tests and time users spend typing.",
method: "GET",
path: "/typingStats",
responses: {
200: GetTypingStatsResponseSchema,
},
},
},
{
pathPrefix: "/public",
strictStatusCodes: true,
metadata: meta({
openApiTags: "public",
authenticationOptions: {
isPublic: true,
},
rateLimit: "publicStatsGet",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,217 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
responseWithNullableData,
} from "./util/api";
import {
ApproveQuoteSchema,
QuoteIdSchema,
QuoteRatingSchema,
QuoteReportReasonSchema,
QuoteSchema,
} from "@monkeytype/schemas/quotes";
import { IdSchema, NullableStringSchema } from "@monkeytype/schemas/util";
import { LanguageSchema } from "@monkeytype/schemas/languages";
export const GetQuotesResponseSchema = responseWithData(z.array(QuoteSchema));
export type GetQuotesResponse = z.infer<typeof GetQuotesResponseSchema>;
export const IsSubmissionEnabledResponseSchema = responseWithData(
z.object({
isEnabled: z.boolean(),
}),
);
export type IsSubmissionEnabledResponse = z.infer<
typeof IsSubmissionEnabledResponseSchema
>;
export const AddQuoteRequestSchema = z.object({
text: z.string().min(60),
source: z.string(),
language: LanguageSchema,
captcha: z.string(), //we don't generate the captcha so there should be no validation
});
export type AddQuoteRequest = z.infer<typeof AddQuoteRequestSchema>;
export const ApproveQuoteRequestSchema = z.object({
quoteId: IdSchema,
editText: NullableStringSchema,
editSource: NullableStringSchema,
});
export type ApproveQuoteRequest = z.infer<typeof ApproveQuoteRequestSchema>;
export const ApproveQuoteResponseSchema = responseWithData(ApproveQuoteSchema);
export type ApproveQuoteResponse = z.infer<typeof ApproveQuoteResponseSchema>;
export const RejectQuoteRequestSchema = z.object({
quoteId: IdSchema,
});
export type RejectQuoteRequest = z.infer<typeof RejectQuoteRequestSchema>;
export const GetQuoteRatingQuerySchema = z.object({
quoteId: QuoteIdSchema,
language: LanguageSchema,
});
export type GetQuoteRatingQuery = z.infer<typeof GetQuoteRatingQuerySchema>;
export const GetQuoteRatingResponseSchema =
responseWithNullableData(QuoteRatingSchema);
export type GetQuoteRatingResponse = z.infer<
typeof GetQuoteRatingResponseSchema
>;
export const AddQuoteRatingRequestSchema = z.object({
quoteId: QuoteIdSchema,
language: LanguageSchema,
rating: z.number().int().min(1).max(5),
});
export type AddQuoteRatingRequest = z.infer<typeof AddQuoteRatingRequestSchema>;
export const ReportQuoteRequestSchema = z.object({
quoteId: QuoteIdSchema,
quoteLanguage: LanguageSchema,
reason: QuoteReportReasonSchema,
comment: z
.string()
.regex(/^([.]|[^/<>])+$/)
.max(250)
.optional()
.or(z.string().length(0)),
captcha: z.string(), //we don't generate the captcha so there should be no validation
});
export type ReportQuoteRequest = z.infer<typeof ReportQuoteRequestSchema>;
const c = initContract();
export const quotesContract = c.router(
{
get: {
summary: "get quote submissions",
description: "Get list of quote submissions",
method: "GET",
path: "",
responses: {
200: GetQuotesResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesGet",
requirePermission: "quoteMod",
}),
},
isSubmissionEnabled: {
summary: "is submission enabled",
description: "Check if submissions are enabled.",
method: "GET",
path: "/isSubmissionEnabled",
responses: {
200: IsSubmissionEnabledResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "newQuotesIsSubmissionEnabled",
}),
},
add: {
summary: "submit quote",
description: "Add a quote submission",
method: "POST",
path: "",
body: AddQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesAdd",
requireConfiguration: {
path: "quotes.submissionsEnabled",
invalidMessage:
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.",
},
}),
},
approveSubmission: {
summary: "submit quote",
description: "Add a quote submission",
method: "POST",
path: "/approve",
body: ApproveQuoteRequestSchema.strict(),
responses: {
200: ApproveQuoteResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesAction",
requirePermission: "quoteMod",
}),
},
rejectSubmission: {
summary: "reject quote",
description: "Reject a quote submission",
method: "POST",
path: "/reject",
body: RejectQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesAction",
requirePermission: "quoteMod",
}),
},
getRating: {
summary: "get rating",
description: "Get quote rating",
method: "GET",
path: "/rating",
query: GetQuoteRatingQuerySchema.strict(),
responses: {
200: GetQuoteRatingResponseSchema,
},
metadata: meta({
rateLimit: "quoteRatingsGet",
}),
},
addRating: {
summary: "add rating",
description: "Add a quote rating",
method: "POST",
path: "/rating",
body: AddQuoteRatingRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteRatingsSubmit",
}),
},
report: {
summary: "report quote",
description: "Report a quote",
method: "POST",
path: "/report",
body: ReportQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteReportSubmit",
requirePermission: "canReport",
requireConfiguration: {
path: "quotes.reporting.enabled",
invalidMessage: "Quote reporting is unavailable.",
},
}),
},
},
{
pathPrefix: "/quotes",
strictStatusCodes: true,
metadata: meta({
openApiTags: "quotes",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,411 @@
export type Window = "second" | "minute" | "hour" | "day" | number;
export type RateLimitOptions = {
/** Timeframe or time in milliseconds */
window: Window;
/** Max request within the given window */
max: number;
};
export const limits = {
defaultApeRateLimit: {
window: "minute",
max: 30,
},
adminLimit: {
window: 5000,
max: 1,
},
// Config Routing
configUpdate: {
window: "hour",
max: 500,
},
configGet: {
window: "hour",
max: 120,
},
configDelete: {
window: "hour",
max: 120,
},
// Leaderboards Routing
leaderboardsGet: {
window: "hour",
max: 500,
},
// New Quotes Routing
newQuotesGet: {
window: "hour",
max: 500,
},
newQuotesIsSubmissionEnabled: {
window: "minute",
max: 60,
},
newQuotesAdd: {
window: "hour",
max: 60,
},
newQuotesAction: {
window: "hour",
max: 500,
},
// Quote Ratings Routing
quoteRatingsGet: {
window: "hour",
max: 500,
},
quoteRatingsSubmit: {
window: "hour",
max: 500,
},
// Quote reporting
quoteReportSubmit: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
// Quote favorites
quoteFavoriteGet: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
quoteFavoritePost: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
quoteFavoriteDelete: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
// Presets Routing
presetsGet: {
window: "hour",
max: 60,
},
presetsAdd: {
window: "hour",
max: 60,
},
presetsRemove: {
window: "hour",
max: 60,
},
presetsEdit: {
window: "hour",
max: 60,
},
// PSA (Public Service Announcement) Routing
psaGet: {
window: "minute",
max: 60,
},
// get public speed stats
publicStatsGet: {
window: "minute",
max: 60,
},
// Results Routing
resultsGet: {
window: "hour",
max: 60,
},
// Results Routing
resultsGetApe: {
window: "day",
max: 30,
},
// Result by id
resultByIdGet: {
window: "hour",
max: 300,
},
// Result by id
resultByIdGetApe: {
window: "hour",
max: 60,
},
resultsAdd: {
window: "hour",
max: 300,
},
resultsTagsUpdate: {
window: "hour",
max: 100,
},
resultsDeleteAll: {
window: "hour",
max: 10,
},
resultsLeaderboardGet: {
window: "hour",
max: 60,
},
resultsLeaderboardQualificationGet: {
window: "hour",
max: 60,
},
// Users Routing
userGet: {
window: "hour",
max: 60,
},
setStreakHourOffset: {
window: "hour",
max: 5,
},
userSignup: {
window: "day",
max: 2,
},
userDelete: {
window: "day",
max: 3,
},
userReset: {
window: "day",
max: 3,
},
userCheckName: {
window: "minute",
max: 60,
},
userUpdateName: {
window: "day",
max: 3,
},
userUpdateLBMemory: {
window: "minute",
max: 60,
},
userUpdateEmail: {
window: "hour",
max: 60,
},
userClearPB: {
window: "hour",
max: 60,
},
userOptOutOfLeaderboards: {
window: "hour",
max: 10,
},
userCustomFilterAdd: {
window: "hour",
max: 60,
},
userCustomFilterRemove: {
window: "hour",
max: 60,
},
userTagsGet: {
window: "hour",
max: 60,
},
userTagsRemove: {
window: "hour",
max: 30,
},
userTagsClearPB: {
window: "hour",
max: 60,
},
userTagsEdit: {
window: "hour",
max: 30,
},
userTagsAdd: {
window: "hour",
max: 30,
},
userCustomThemeGet: {
window: "hour",
max: 30,
},
userCustomThemeAdd: {
window: "hour",
max: 30,
},
userCustomThemeRemove: {
window: "hour",
max: 30,
},
userCustomThemeEdit: {
window: "hour",
max: 30,
},
userDiscordLink: {
window: "hour",
max: 15,
},
userDiscordUnlink: {
window: "hour",
max: 15,
},
userRequestVerificationEmail: {
window: 15 * 60 * 1000, //15 minutes
max: 1,
},
userForgotPasswordEmail: {
window: "minute",
max: 1,
},
userRevokeAllTokens: {
window: "hour",
max: 10,
},
userProfileGet: {
window: "hour",
max: 100,
},
userProfileUpdate: {
window: "hour",
max: 60,
},
userMailGet: {
window: "hour",
max: 60,
},
userMailUpdate: {
window: "hour",
max: 60,
},
userTestActivity: {
window: "hour",
max: 60,
},
userCurrentTestActivity: {
window: "hour",
max: 60,
},
userStreak: {
window: "hour",
max: 60,
},
userFriendGet: {
window: "hour",
max: 60,
},
// ApeKeys Routing
apeKeysGet: {
window: "hour",
max: 120,
},
apeKeysGenerate: {
window: "hour",
max: 15,
},
webhookLimit: {
window: "second",
max: 1,
},
connectionGet: {
window: "hour",
max: 60,
},
connectionCreate: {
window: "hour",
max: 60,
},
connectionDelete: {
window: "hour",
max: 60,
},
connectionUpdate: {
window: "hour",
max: 60,
},
} satisfies Record<string, RateLimitOptions>;
export type RateLimiterId = keyof typeof limits;
export type RateLimitIds = {
/** rate limiter options for non-apeKey requests */
normal: RateLimiterId;
/** Rate limiter options for apeKey requests */
apeKey: RateLimiterId;
};
export function getLimits(limit: RateLimiterId | RateLimitIds): {
limiter: RateLimitOptions;
apeKeyLimiter?: RateLimitOptions;
} {
const isApeKeyLimiter = typeof limit === "object";
const limiter = isApeKeyLimiter ? limit.normal : limit;
const apeLimiter = isApeKeyLimiter ? limit.apeKey : undefined;
return {
limiter: limits[limiter],
apeKeyLimiter: apeLimiter !== undefined ? limits[apeLimiter] : undefined,
};
}

View File

@@ -0,0 +1,27 @@
import { Configuration } from "@monkeytype/schemas/configuration";
type BooleanPaths<T, P extends string = ""> = {
[K in keyof T]: T[K] extends boolean
? P extends ""
? K
: `${P}.${Extract<K, string | number>}`
: T[K] extends object
? `${P}.${Extract<K, string | number>}` extends infer D
? D extends string
? BooleanPaths<T[K], D>
: never
: never
: never;
}[keyof T];
// Helper type to remove leading dot
type RemoveLeadingDot<T> = T extends `.${infer U}` ? U : T;
export type ConfigurationPath = RemoveLeadingDot<BooleanPaths<Configuration>>;
export type RequireConfiguration = {
/** path to the configuration, needs to be a boolean value */
path: ConfigurationPath;
/** message of the ErrorResponse in case the value is `false` */
invalidMessage?: string;
};

View File

@@ -0,0 +1,204 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyClientError,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import {
CompletedEventSchema,
PostResultResponseSchema,
ResultMinifiedSchema,
ResultSchema,
} from "@monkeytype/schemas/results";
import { IdSchema } from "@monkeytype/schemas/util";
export const GetResultsQuerySchema = z.object({
onOrAfterTimestamp: z
.number()
.int()
.min(1589428800000)
.optional()
.describe(
"Timestamp of the earliest result to fetch. If omitted the most recent results are fetched.",
),
offset: z
.number()
.int()
.nonnegative()
.optional()
.describe("Offset of the item at which to begin the response."),
limit: z
.number()
.int()
.nonnegative()
.max(1000)
.optional()
.describe("Limit results to the given amount."),
});
export type GetResultsQuery = z.infer<typeof GetResultsQuerySchema>;
export const GetResultsResponseSchema = responseWithData(
z.array(ResultMinifiedSchema),
);
export type GetResultsResponse = z.infer<typeof GetResultsResponseSchema>;
export const GetResultByIdPathSchema = z.object({
resultId: IdSchema,
});
export type GetResultByIdPath = z.infer<typeof GetResultByIdPathSchema>;
export const GetResultByIdResponseSchema = responseWithData(ResultSchema);
export type GetResultByIdResponse = z.infer<typeof GetResultByIdResponseSchema>;
export const AddResultRequestSchema = z.object({
result: CompletedEventSchema,
});
export type AddResultRequest = z.infer<typeof AddResultRequestSchema>;
export const AddResultResponseSchema = responseWithData(
PostResultResponseSchema,
);
export type AddResultResponse = z.infer<typeof AddResultResponseSchema>;
export const UpdateResultTagsRequestSchema = z.object({
tagIds: z.array(IdSchema),
resultId: IdSchema,
});
export type UpdateResultTagsRequest = z.infer<
typeof UpdateResultTagsRequestSchema
>;
export const UpdateResultTagsResponseSchema = responseWithData(
z.object({
tagPbs: z.array(IdSchema),
}),
);
export type UpdateResultTagsResponse = z.infer<
typeof UpdateResultTagsResponseSchema
>;
export const GetLastResultResponseSchema = responseWithData(ResultSchema);
export type GetLastResultResponse = z.infer<typeof GetLastResultResponseSchema>;
const c = initContract();
export const resultsContract = c.router(
{
get: {
summary: "get results",
description: "Gets up to 1000 results",
method: "GET",
path: "",
query: GetResultsQuerySchema.strict(),
responses: {
200: GetResultsResponseSchema,
},
metadata: meta({
authenticationOptions: {
acceptApeKeys: true,
},
rateLimit: {
normal: "resultsGet",
apeKey: "resultsGetApe",
},
}),
},
getById: {
summary: "get result by id",
description: "Get result by id",
method: "GET",
path: "/id/:resultId",
pathParams: GetResultByIdPathSchema,
responses: {
200: GetResultByIdResponseSchema,
},
metadata: meta({
authenticationOptions: {
acceptApeKeys: true,
},
rateLimit: {
normal: "resultByIdGet",
apeKey: "resultByIdGetApe",
},
}),
},
add: {
summary: "add result",
description: "Add a test result for the current user",
method: "POST",
path: "",
body: AddResultRequestSchema.strict(),
responses: {
200: AddResultResponseSchema,
460: MonkeyClientError.describe("Test too short"),
461: MonkeyClientError.describe("Result hash invalid"),
462: MonkeyClientError.describe("Result spacing invalid"),
463: MonkeyClientError.describe("Result data invalid"),
464: MonkeyClientError.describe("Missing key data"),
465: MonkeyClientError.describe("Bot detected"),
466: MonkeyClientError.describe("Duplicate result"),
},
metadata: meta({
rateLimit: "resultsAdd",
requireConfiguration: {
path: "results.savingEnabled",
invalidMessage: "Results are not being saved at this time.",
},
}),
},
updateTags: {
summary: "update result tags",
description: "Labels a result with the specified tags",
method: "PATCH",
path: "/tags",
body: UpdateResultTagsRequestSchema.strict(),
responses: {
200: UpdateResultTagsResponseSchema,
},
metadata: meta({
rateLimit: "resultsTagsUpdate",
}),
},
deleteAll: {
summary: "delete all results",
description: "Delete all results for the current user",
method: "DELETE",
path: "",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: {
requireFreshToken: true,
},
rateLimit: "resultsDeleteAll",
}),
},
getLast: {
summary: "get last result",
description: "Gets a user's last saved result",
path: "/last",
method: "GET",
responses: {
200: GetLastResultResponseSchema,
},
metadata: meta({
authenticationOptions: {
acceptApeKeys: true,
},
rateLimit: "resultsGet",
}),
},
},
{
pathPrefix: "/results",
strictStatusCodes: true,
metadata: meta({
openApiTags: "results",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,965 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyClientError,
MonkeyResponseSchema,
responseWithData,
responseWithNullableData,
} from "./util/api";
import {
CountByYearAndDaySchema,
CustomThemeNameSchema,
CustomThemeSchema,
FavoriteQuotesSchema,
MonkeyMailSchema,
ResultFiltersSchema,
StreakHourOffsetSchema,
TagNameSchema,
TestActivitySchema,
UserProfileDetailsSchema,
UserProfileSchema,
ReportUserReasonSchema,
UserSchema,
UserStreakSchema,
UserTagSchema,
UserEmailSchema,
UserNameSchema,
FriendSchema,
} from "@monkeytype/schemas/users";
import {
Mode2Schema,
ModeSchema,
PersonalBestSchema,
} from "@monkeytype/schemas/shared";
import { IdSchema, StringNumberSchema } from "@monkeytype/schemas/util";
import { LanguageSchema } from "@monkeytype/schemas/languages";
import { CustomThemeColorsSchema } from "@monkeytype/schemas/configs";
export const GetUserResponseSchema = responseWithData(
UserSchema.extend({
inboxUnreadSize: z.number().int().nonnegative(),
}),
);
export type GetUserResponse = z.infer<typeof GetUserResponseSchema>;
export const CreateUserRequestSchema = z.object({
email: UserEmailSchema.optional(),
name: UserNameSchema,
uid: z.string().optional(), //defined by firebase, no validation should be applied
captcha: z.string(), //defined by google recaptcha, no validation should be applied
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const CheckNamePathParametersSchema = z.object({
name: UserNameSchema,
});
export type CheckNamePathParameters = z.infer<
typeof CheckNamePathParametersSchema
>;
export const CheckNameResponseSchema = responseWithData(
z.object({
available: z.boolean(),
}),
);
export type CheckNameResponse = z.infer<typeof CheckNameResponseSchema>;
export const UpdateUserNameRequestSchema = z.object({
name: UserNameSchema,
});
export type UpdateUserNameRequest = z.infer<typeof UpdateUserNameRequestSchema>;
export const UpdateLeaderboardMemoryRequestSchema = z.object({
mode: ModeSchema,
mode2: Mode2Schema,
language: LanguageSchema,
rank: z.number().int().nonnegative(),
});
export type UpdateLeaderboardMemoryRequest = z.infer<
typeof UpdateLeaderboardMemoryRequestSchema
>;
export const UpdateEmailRequestSchema = z.object({
newEmail: UserEmailSchema,
previousEmail: UserEmailSchema,
});
export type UpdateEmailRequest = z.infer<typeof UpdateEmailRequestSchema>;
export const UpdatePasswordRequestSchema = z.object({
newPassword: z.string().min(6),
});
export type UpdatePasswordRequest = z.infer<typeof UpdatePasswordRequestSchema>;
export const GetPersonalBestsQuerySchema = z.object({
mode: ModeSchema,
mode2: Mode2Schema.optional(),
});
export type GetPersonalBestsQuery = z.infer<typeof GetPersonalBestsQuerySchema>;
export const GetPersonalBestsResponseSchema =
responseWithNullableData(PersonalBestSchema);
export type GetPersonalBestsResponse = z.infer<
typeof GetPersonalBestsResponseSchema
>;
export const AddResultFilterPresetRequestSchema = ResultFiltersSchema;
export type AddResultFilterPresetRequest = z.infer<
typeof AddResultFilterPresetRequestSchema
>;
export const AddResultFilterPresetResponseSchema = responseWithData(
IdSchema.describe("Id of the created result filter preset"),
);
export type AddResultFilterPresetResponse = z.infer<
typeof AddResultFilterPresetResponseSchema
>;
export const RemoveResultFilterPresetPathParamsSchema = z.object({
presetId: IdSchema,
});
export type RemoveResultFilterPresetPathParams = z.infer<
typeof RemoveResultFilterPresetPathParamsSchema
>;
export const GetTagsResponseSchema = responseWithData(z.array(UserTagSchema));
export type GetTagsResponse = z.infer<typeof GetTagsResponseSchema>;
export const AddTagRequestSchema = z.object({
tagName: TagNameSchema,
});
export type AddTagRequest = z.infer<typeof AddTagRequestSchema>;
export const AddTagResponseSchema = responseWithData(UserTagSchema);
export type AddTagResponse = z.infer<typeof AddTagResponseSchema>;
export const EditTagRequestSchema = z.object({
tagId: IdSchema,
newName: TagNameSchema,
});
export type EditTagRequest = z.infer<typeof EditTagRequestSchema>;
export const TagIdPathParamsSchema = z.object({
tagId: IdSchema,
});
export type TagIdPathParams = z.infer<typeof TagIdPathParamsSchema>;
export const GetCustomThemesResponseSchema = responseWithData(
z.array(CustomThemeSchema),
);
export type GetCustomThemesResponse = z.infer<
typeof GetCustomThemesResponseSchema
>;
export const AddCustomThemeRequestSchema = z.object({
name: CustomThemeNameSchema,
colors: CustomThemeColorsSchema,
});
export type AddCustomThemeRequest = z.infer<typeof AddCustomThemeRequestSchema>;
export const AddCustomThemeResponseSchema = responseWithData(
CustomThemeSchema.pick({ _id: true, name: true }),
);
export type AddCustomThemeResponse = z.infer<
typeof AddCustomThemeResponseSchema
>;
export const DeleteCustomThemeRequestSchema = z.object({
themeId: IdSchema,
});
export type DeleteCustomThemeRequest = z.infer<
typeof DeleteCustomThemeRequestSchema
>;
export const EditCustomThemeRequstSchema = z.object({
themeId: IdSchema,
theme: CustomThemeSchema.pick({ name: true, colors: true }),
});
export type EditCustomThemeRequst = z.infer<typeof EditCustomThemeRequstSchema>;
export const GetDiscordOauthLinkResponseSchema = responseWithData(
z.object({
url: z.string().url(),
}),
);
export type GetDiscordOauthLinkResponse = z.infer<
typeof GetDiscordOauthLinkResponseSchema
>;
export const LinkDiscordRequestSchema = z.object({
tokenType: z.string(),
accessToken: z.string(),
state: z.string().length(20),
});
export type LinkDiscordRequest = z.infer<typeof LinkDiscordRequestSchema>;
export const LinkDiscordResponseSchema = responseWithData(
UserSchema.pick({ discordId: true, discordAvatar: true }),
);
export type LinkDiscordResponse = z.infer<typeof LinkDiscordResponseSchema>;
export const GetStatsResponseSchema = responseWithData(
UserSchema.pick({
completedTests: true,
startedTests: true,
timeTyping: true,
}),
);
export type GetStatsResponse = z.infer<typeof GetStatsResponseSchema>;
export const SetStreakHourOffsetRequestSchema = z.object({
hourOffset: StreakHourOffsetSchema,
});
export type SetStreakHourOffsetRequest = z.infer<
typeof SetStreakHourOffsetRequestSchema
>;
export const GetFavoriteQuotesResponseSchema =
responseWithData(FavoriteQuotesSchema);
export type GetFavoriteQuotesResponse = z.infer<
typeof GetFavoriteQuotesResponseSchema
>;
export const AddFavoriteQuoteRequestSchema = z.object({
language: LanguageSchema,
quoteId: StringNumberSchema,
});
export type AddFavoriteQuoteRequest = z.infer<
typeof AddFavoriteQuoteRequestSchema
>;
export const RemoveFavoriteQuoteRequestSchema = z.object({
language: LanguageSchema,
quoteId: StringNumberSchema,
});
export type RemoveFavoriteQuoteRequest = z.infer<
typeof RemoveFavoriteQuoteRequestSchema
>;
export const GetProfilePathParamsSchema = z.object({
uidOrName: z.string(),
});
export type GetProfilePathParams = z.infer<typeof GetProfilePathParamsSchema>;
//TODO test?!
export const GetProfileQuerySchema = z.object({
isUid: z
.string()
.length(0)
.transform((it) => it === "")
.or(z.boolean())
.default(false),
});
export type GetProfileQuery = z.infer<typeof GetProfileQuerySchema>;
export const GetProfileResponseSchema = responseWithData(UserProfileSchema);
export type GetProfileResponse = z.infer<typeof GetProfileResponseSchema>;
export const UpdateUserProfileRequestSchema = UserProfileDetailsSchema.extend({
selectedBadgeId: z
.number()
.int()
.nonnegative()
.optional()
.or(z.literal(-1).describe("no badge selected")), //TODO remove the -1, use optional?
});
export type UpdateUserProfileRequest = z.infer<
typeof UpdateUserProfileRequestSchema
>;
export const UpdateUserProfileResponseSchema = responseWithData(
UserProfileDetailsSchema,
);
export type UpdateUserProfileResponse = z.infer<
typeof UpdateUserProfileResponseSchema
>;
export const GetUserInboxResponseSchema = responseWithData(
z.object({
inbox: z.array(MonkeyMailSchema),
maxMail: z.number().int(),
}),
);
export type GetUserInboxResponse = z.infer<typeof GetUserInboxResponseSchema>;
export const UpdateUserInboxRequestSchema = z.object({
mailIdsToDelete: z.array(z.string().uuid()).min(1).optional(),
mailIdsToMarkRead: z.array(z.string().uuid()).min(1).optional(),
});
export type UpdateUserInboxRequest = z.infer<
typeof UpdateUserInboxRequestSchema
>;
export const ReportUserRequestSchema = z.object({
uid: z.string(),
reason: ReportUserReasonSchema,
comment: z
.string()
.regex(/^([.]|[^/<>])+$/)
.max(250)
.optional()
.or(z.string().length(0)),
captcha: z.string(), //we don't generate the captcha so there should be no validation
});
export type ReportUserRequest = z.infer<typeof ReportUserRequestSchema>;
export const ForgotPasswordEmailRequestSchema = z.object({
captcha: z.string(),
email: UserEmailSchema,
});
export type ForgotPasswordEmailRequest = z.infer<
typeof ForgotPasswordEmailRequestSchema
>;
export const GetTestActivityResponseSchema = responseWithNullableData(
CountByYearAndDaySchema,
);
export type GetTestActivityResponse = z.infer<
typeof GetTestActivityResponseSchema
>;
export const GetCurrentTestActivityResponseSchema =
responseWithNullableData(TestActivitySchema);
export type GetCurrentTestActivityResponse = z.infer<
typeof GetCurrentTestActivityResponseSchema
>;
export const GetStreakResponseSchema =
responseWithNullableData(UserStreakSchema);
export type GetStreakResponse = z.infer<typeof GetStreakResponseSchema>;
export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema));
export type GetFriendsResponse = z.infer<typeof GetFriendsResponseSchema>;
const c = initContract();
export const usersContract = c.router(
{
get: {
summary: "get user",
description: "Get a user's data.",
method: "GET",
path: "",
responses: {
200: GetUserResponseSchema,
},
metadata: meta({
rateLimit: "userGet",
}),
},
create: {
summary: "create user",
description: "Creates a new user",
method: "POST",
path: "/signup",
body: CreateUserRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userSignup",
requireConfiguration: {
path: "users.signUp",
invalidMessage: "Sign up is temporarily disabled",
},
}),
},
getNameAvailability: {
summary: "check name",
description: "Checks to see if a username is available",
method: "GET",
path: "/checkName/:name",
pathParams: CheckNamePathParametersSchema.strict(),
responses: {
200: CheckNameResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userCheckName",
}),
},
delete: {
summary: "delete user",
description: "Deletes a user's account",
method: "DELETE",
path: "",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userDelete",
}),
},
reset: {
summary: "reset user",
description: "Completely resets a user's account to a blank state",
method: "PATCH",
path: "/reset",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userReset",
}),
},
updateName: {
summary: "update username",
description: "Updates a user's name",
method: "PATCH",
path: "/name",
body: UpdateUserNameRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userUpdateName",
}),
},
updateLeaderboardMemory: {
summary: "update lbMemory",
description: "Updates a user's cached leaderboard state",
method: "PATCH",
path: "/leaderboardMemory",
body: UpdateLeaderboardMemoryRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userUpdateLBMemory",
}),
},
updateEmail: {
summary: "update email",
description: "Updates a user's email",
method: "PATCH",
path: "/email",
body: UpdateEmailRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userUpdateEmail",
}),
},
updatePassword: {
summary: "update password",
description: "Updates a user's password",
method: "PATCH",
path: "/password",
body: UpdatePasswordRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userUpdateEmail",
}),
},
getPersonalBests: {
summary: "get personal bests",
description: "Get user's personal bests",
method: "GET",
path: "/personalBests",
query: GetPersonalBestsQuerySchema.strict(),
responses: {
200: GetPersonalBestsResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userGet",
}),
},
deletePersonalBests: {
summary: "delete personal bests",
description: "Deletes a user's personal bests",
method: "DELETE",
path: "/personalBests",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userClearPB",
}),
},
optOutOfLeaderboards: {
summary: "leaderboards opt out",
description: "Opt out of the leaderboards",
method: "POST",
path: "/optOutOfLeaderboards",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userOptOutOfLeaderboards",
}),
},
addResultFilterPreset: {
summary: "add result filter preset",
description: "Add a result filter preset",
method: "POST",
path: "/resultFilterPresets",
body: AddResultFilterPresetRequestSchema.strict(),
responses: {
200: AddResultFilterPresetResponseSchema,
},
metadata: meta({
rateLimit: "userCustomFilterAdd",
requireConfiguration: {
path: "results.filterPresets.enabled",
invalidMessage:
"Result filter presets are not available at this time.",
},
}),
},
removeResultFilterPreset: {
summary: "remove result filter preset",
description: "Remove a result filter preset",
method: "DELETE",
path: "/resultFilterPresets/:presetId",
pathParams: RemoveResultFilterPresetPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userCustomFilterRemove",
requireConfiguration: {
path: "results.filterPresets.enabled",
invalidMessage:
"Result filter presets are not available at this time.",
},
}),
},
getTags: {
summary: "get tags",
description: "Get the users tags",
method: "GET",
path: "/tags",
responses: {
200: GetTagsResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userTagsGet",
}),
},
createTag: {
summary: "add tag",
description: "Add a tag for the current user",
method: "POST",
path: "/tags",
body: AddTagRequestSchema.strict(),
responses: {
200: AddTagResponseSchema,
},
metadata: meta({
rateLimit: "userTagsAdd",
}),
},
editTag: {
summary: "edit tag",
description: "Edit a tag",
method: "PATCH",
path: "/tags",
body: EditTagRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userTagsEdit",
}),
},
deleteTag: {
summary: "delete tag",
description: "Delete a tag",
method: "DELETE",
path: "/tags/:tagId",
pathParams: TagIdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userTagsRemove",
}),
},
deleteTagPersonalBest: {
summary: "delete tag PBs",
description: "Delete personal bests of a tag",
method: "DELETE",
path: "/tags/:tagId/personalBest",
pathParams: TagIdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userTagsClearPB",
}),
},
getCustomThemes: {
summary: "get custom themes",
description: "Get custom themes for the current user",
method: "GET",
path: "/customThemes",
responses: {
200: GetCustomThemesResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeGet",
}),
},
addCustomTheme: {
summary: "add custom themes",
description: "Add a custom theme for the current user",
method: "POST",
path: "/customThemes",
body: AddCustomThemeRequestSchema.strict(),
responses: {
200: AddCustomThemeResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeAdd",
}),
},
deleteCustomTheme: {
summary: "delete custom themes",
description: "Delete a custom theme",
method: "DELETE",
path: "/customThemes",
body: DeleteCustomThemeRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeRemove",
}),
},
editCustomTheme: {
summary: "edit custom themes",
description: "Edit a custom theme",
method: "PATCH",
path: "/customThemes",
body: EditCustomThemeRequstSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeEdit",
}),
},
getDiscordOAuth: {
summary: "discord oauth",
description: "Start OAuth authentication with discord",
method: "GET",
path: "/discord/oauth",
responses: {
200: GetDiscordOauthLinkResponseSchema,
},
metadata: meta({
rateLimit: "userDiscordLink",
requireConfiguration: {
path: "users.discordIntegration.enabled",
invalidMessage: "Discord integration is not available at this time",
},
}),
},
linkDiscord: {
summary: "link with discord",
description: "Links a user's account with a discord account",
method: "POST",
path: "/discord/link",
body: LinkDiscordRequestSchema.strict(),
responses: {
200: LinkDiscordResponseSchema,
},
metadata: meta({
rateLimit: "userDiscordLink",
requireConfiguration: {
path: "users.discordIntegration.enabled",
invalidMessage: "Discord integration is not available at this time",
},
}),
},
unlinkDiscord: {
summary: "unlink discord",
description: "Unlinks a user's account with a discord account",
method: "POST",
path: "/discord/unlink",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userDiscordUnlink",
}),
},
getStats: {
summary: "get stats",
description: "Gets a user's typing stats data",
method: "GET",
path: "/stats",
responses: {
200: GetStatsResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userGet",
}),
},
setStreakHourOffset: {
summary: "set streak hour offset",
description: "Sets a user's streak hour offset",
method: "POST",
path: "/setStreakHourOffset",
body: SetStreakHourOffsetRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "setStreakHourOffset",
}),
},
getFavoriteQuotes: {
summary: "get favorite quotes",
description: "Gets a user's favorite quotes",
method: "GET",
path: "/favoriteQuotes",
responses: {
200: GetFavoriteQuotesResponseSchema,
},
metadata: meta({
rateLimit: "quoteFavoriteGet",
}),
},
addQuoteToFavorites: {
summary: "add favorite quotes",
description: "Add a quote to the user's favorite quotes",
method: "POST",
path: "/favoriteQuotes",
body: AddFavoriteQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteFavoritePost",
}),
},
removeQuoteFromFavorites: {
summary: "remove favorite quotes",
description: "Remove a quote to the user's favorite quotes",
method: "DELETE",
path: "/favoriteQuotes",
body: RemoveFavoriteQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteFavoriteDelete",
}),
},
getProfile: {
summary: "get profile",
description: "Gets a user's profile",
method: "GET",
path: "/:uidOrName/profile",
pathParams: GetProfilePathParamsSchema.strict(),
query: GetProfileQuerySchema.strict(),
responses: {
200: GetProfileResponseSchema,
404: MonkeyClientError.describe("User not found"),
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userProfileGet",
requireConfiguration: {
path: "users.profiles.enabled",
invalidMessage: "Profiles are not available at this time",
},
}),
},
updateProfile: {
summary: "update profile",
description: "Update a user's profile",
method: "PATCH",
path: "/profile",
body: UpdateUserProfileRequestSchema.strict(),
responses: {
200: UpdateUserProfileResponseSchema,
},
metadata: meta({
rateLimit: "userProfileUpdate",
requireConfiguration: {
path: "users.profiles.enabled",
invalidMessage: "Profiles are not available at this time",
},
}),
},
getInbox: {
summary: "get inbox",
description: "Gets the user's inbox",
method: "GET",
path: "/inbox",
responses: {
200: GetUserInboxResponseSchema,
},
metadata: meta({
rateLimit: "userMailGet",
requireConfiguration: {
path: "users.inbox.enabled",
invalidMessage: "Your inbox is not available at this time.",
},
}),
},
updateInbox: {
summary: "update inbox",
description: "Updates the user's inbox",
method: "PATCH",
body: UpdateUserInboxRequestSchema.strict(),
path: "/inbox",
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userMailUpdate",
requireConfiguration: {
path: "users.inbox.enabled",
invalidMessage: "Your inbox is not available at this time.",
},
}),
},
report: {
summary: "report user",
description: "Report a user",
method: "POST",
path: "/report",
body: ReportUserRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteReportSubmit",
requirePermission: "canReport",
requireConfiguration: {
path: "quotes.reporting.enabled",
invalidMessage: "User reporting is unavailable.",
},
}),
},
verificationEmail: {
summary: "send verification email",
description: "Send a verification email",
method: "GET",
path: "/verificationEmail",
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { noCache: true },
rateLimit: "userRequestVerificationEmail",
}),
},
forgotPasswordEmail: {
summary: "send forgot password email",
description: "Send a forgot password email",
method: "POST",
path: "/forgotPasswordEmail",
body: ForgotPasswordEmailRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userForgotPasswordEmail",
}),
},
revokeAllTokens: {
summary: "revoke all tokens",
description: "Revoke all tokens for the current user.",
method: "POST",
path: "/revokeAllTokens",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true, noCache: true },
rateLimit: "userRevokeAllTokens",
}),
},
getTestActivity: {
summary: "get test activity",
description: "Get user's test activity",
method: "GET",
path: "/testActivity",
responses: {
200: GetTestActivityResponseSchema,
},
metadata: meta({
rateLimit: "userTestActivity",
}),
},
getCurrentTestActivity: {
summary: "get current test activity",
description:
"Get test activity for the last up to 372 days for the current user ",
method: "GET",
path: "/currentTestActivity",
responses: {
200: GetCurrentTestActivityResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userCurrentTestActivity",
}),
},
getStreak: {
summary: "get streak",
description: "Get user's streak data",
method: "GET",
path: "/streak",
responses: {
200: GetStreakResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userStreak",
}),
},
getFriends: {
summary: "get friends",
description: "get friends list",
method: "GET",
path: "/friends",
responses: {
200: GetFriendsResponseSchema,
},
metadata: meta({
rateLimit: "userFriendGet",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Connections are not available at this time.",
},
}),
},
},
{
pathPrefix: "/users",
strictStatusCodes: true,
metadata: meta({
openApiTags: "users",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,148 @@
import { z, ZodSchema } from "zod";
import { RateLimitIds, RateLimiterId } from "../rate-limit";
import { RequireConfiguration } from "../require-configuration";
export type OpenApiTag =
| "configs"
| "presets"
| "ape-keys"
| "admin"
| "psas"
| "public"
| "leaderboards"
| "results"
| "configuration"
| "development"
| "users"
| "quotes"
| "webhooks"
| "connections";
export type PermissionId =
| "quoteMod"
| "canReport"
| "canManageApeKeys"
| "admin";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */
authenticationOptions?: RequestAuthenticationOptions;
openApiTags?: OpenApiTag | OpenApiTag[];
/** RateLimitId or RateLimitIds.
* Only specifying RateLimiterId will use a default limiter with 30 requests/minute for ApeKey requests.
*/
rateLimit?: RateLimiterId | RateLimitIds;
/** Role/Rples needed to access the endpoint*/
requirePermission?: PermissionId | PermissionId[];
/** Endpoint is only available if configuration allows it */
requireConfiguration?: RequireConfiguration | RequireConfiguration[];
};
/**
*
* @param metadata Ensure the type of metadata is `EndpointMetadata`.
* Ts-rest does not allow to specify the type of `metadata`.
* @returns
*/
export function meta(metadata: EndpointMetadata): EndpointMetadata {
return metadata;
}
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;
/** Allow unauthenticated requests on dev */
isPublicOnDev?: boolean;
/** Endpoint is a webhook only to be called by Github */
isGithubWebhook?: 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()),
});
export type MonkeyValidationError = z.infer<typeof MonkeyValidationErrorSchema>;
export const MonkeyClientError = MonkeyResponseSchema;
export type MonkeyClientErrorType = z.infer<typeof MonkeyClientError>;
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"),
470: MonkeyClientError.describe("Invalid ApeKey"),
471: MonkeyClientError.describe("ApeKey is inactive"),
472: MonkeyClientError.describe("ApeKey is malformed"),
479: MonkeyClientError.describe("ApeKey rate limit exceeded"),
500: MonkeyServerError.describe("Generic server error"),
503: MonkeyServerError.describe(
"Endpoint disabled or server is under maintenance",
),
};
export type CommonResponsesType =
| {
status: 400 | 401 | 403 | 429 | 470 | 471 | 472 | 479;
body: MonkeyClientErrorType;
}
| {
status: 422;
body: MonkeyValidationError;
}
| {
status: 500 | 503;
body: MonkeyServerErrorType;
};

View File

@@ -0,0 +1,60 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { PSASchema } from "@monkeytype/schemas/psas";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
/**
*Schema: https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=published#release
We only specify the values we read and don't validate any other values.
*/
export const PostGithubReleaseRequestSchema = z.object({
action: z.literal("published").or(z.string()),
release: z
.object({
id: z.string().or(z.number().transform(String)), //we use string, github defines this as a number.
})
.optional(),
});
export type PostGithubReleaseRequest = z.infer<
typeof PostGithubReleaseRequestSchema
>;
export const GetPsaResponseSchema = responseWithData(z.array(PSASchema));
export type GetPsaResponse = z.infer<typeof GetPsaResponseSchema>;
const c = initContract();
export const webhooksContract = c.router(
{
postGithubRelease: {
summary: "Github release",
description: "Announce github release.",
method: "POST",
path: "/githubRelease",
body: PostGithubReleaseRequestSchema, //don't use strict
headers: z.object({
"x-hub-signature-256": z.string(),
}),
responses: {
200: MonkeyResponseSchema,
},
},
},
{
pathPrefix: "/webhooks",
strictStatusCodes: true,
metadata: meta({
openApiTags: "webhooks",
authenticationOptions: {
isGithubWebhook: true,
},
rateLimit: "webhookLimit",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,10 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "ES6"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
import { extendConfig } from "@monkeytype/tsup-config";
export default extendConfig();

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
coverage: {
include: ["**/*.ts"],
},
},
});