impr: move configuration checks to contracts (@fehmer) (#5851)

!nuf
This commit is contained in:
Christian Fehmer 2024-09-11 11:26:12 +02:00 committed by GitHub
parent c6daef0e9d
commit b315836dee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 409 additions and 167 deletions

View file

@ -0,0 +1,185 @@
import { RequireConfiguration } from "@monkeytype/contracts/require-configuration/index";
import { verifyRequiredConfiguration } from "../../src/middlewares/configuration";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { Response } from "express";
import MonkeyError from "../../src/utils/error";
describe("configuration middleware", () => {
const handler = verifyRequiredConfiguration();
const res: Response = {} as any;
const next = vi.fn();
beforeEach(() => {
next.mockReset();
});
afterEach(() => {
//next function must only be called once
expect(next).toHaveBeenCalledOnce();
});
it("should pass without requireConfiguration", async () => {
//GIVEN
const req = { tsRestRoute: { metadata: {} } } as any;
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should pass for enabled configuration", async () => {
//GIVEN
const req = givenRequest({ path: "maintenance" }, { maintenance: true });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should pass for enabled configuration with complex path", async () => {
//GIVEN
const req = givenRequest(
{ path: "users.xp.streak.enabled" },
{ users: { xp: { streak: { enabled: true } as any } as any } as any }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should fail for disabled configuration", async () => {
//GIVEN
const req = givenRequest({ path: "maintenance" }, { maintenance: false });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(503, "This endpoint is currently unavailable.")
);
});
it("should fail for disabled configuration and custom message", async () => {
//GIVEN
const req = givenRequest(
{ path: "maintenance", invalidMessage: "Feature not enabled." },
{ maintenance: false }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(503, "Feature not enabled.")
);
});
it("should fail for invalid path", async () => {
//GIVEN
const req = givenRequest({ path: "invalid.path" as any }, {});
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(503, 'Invalid configuration path: "invalid.path"')
);
});
it("should fail for undefined value", async () => {
//GIVEN
const req = givenRequest(
{ path: "admin.endpointsEnabled" },
{ admin: {} as any }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
500,
'Required configuration doesnt exist: "admin.endpointsEnabled"'
)
);
});
it("should fail for null value", async () => {
//GIVEN
const req = givenRequest(
{ path: "admin.endpointsEnabled" },
{ admin: { endpointsEnabled: null as any } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
500,
'Required configuration doesnt exist: "admin.endpointsEnabled"'
)
);
});
it("should fail for non booean value", async () => {
//GIVEN
const req = givenRequest(
{ path: "admin.endpointsEnabled" },
{ admin: { endpointsEnabled: "disabled" as any } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
500,
'Required configuration is not a boolean: "admin.endpointsEnabled"'
)
);
});
it("should pass for multiple configurations", async () => {
//GIVEN
const req = givenRequest(
[{ path: "maintenance" }, { path: "admin.endpointsEnabled" }],
{ maintenance: true, admin: { endpointsEnabled: true } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should fail for multiple configurations", async () => {
//GIVEN
const req = givenRequest(
[
{ path: "maintenance", invalidMessage: "maintenance mode" },
{ path: "admin.endpointsEnabled", invalidMessage: "admin disabled" },
],
{ maintenance: true, admin: { endpointsEnabled: false } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(new MonkeyError(503, "admin disabled"));
});
});
function givenRequest(
requireConfiguration: RequireConfiguration | RequireConfiguration[],
configuration: Partial<Configuration>
): TsRestRequest {
return {
tsRestRoute: { metadata: { requireConfiguration } },
ctx: { configuration },
} as any;
}

View file

@ -3,7 +3,7 @@ import { contract } from "@monkeytype/contracts/index";
import { writeFileSync, mkdirSync } from "fs";
import {
EndpointMetadata,
Permission,
PermissionId,
} from "@monkeytype/contracts/schemas/api";
import type { OpenAPIObject, OperationObject } from "openapi3-ts";
import {
@ -142,15 +142,15 @@ export function getOpenApi(): OpenAPIObject {
setOperationId: "concatenated-path",
operationMapper: (operation, route) => {
const metadata = route.metadata as EndpointMetadata;
if (!operation.description?.trim()?.endsWith("."))
if (!operation.description?.trim()?.endsWith(".")) {
operation.description += ".";
}
operation.description += "\n\n";
addAuth(operation, metadata);
addRateLimit(operation, metadata);
addRequiredConfiguration(operation, metadata);
addTags(operation, metadata);
return operation;
},
}
@ -262,6 +262,16 @@ function formatWindow(window: Window): string {
return "per " + window;
}
function addRequiredConfiguration(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.requireConfiguration === undefined)
return;
operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`;
}
//detect if we run this as a main
if (require.main === module) {
const args = process.argv.slice(2);

View file

@ -1,41 +1,25 @@
// import joi from "joi";
import * as AdminController from "../controllers/admin";
import { adminContract } from "@monkeytype/contracts/admin";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import { callController } from "../ts-rest-adapter";
const commonMiddleware = [
validate({
criteria: (configuration) => {
return configuration.admin.endpointsEnabled;
},
invalidMessage: "Admin endpoints are currently disabled.",
}),
];
const s = initServer();
export default s.router(adminContract, {
test: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.test)(r),
},
toggleBan: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.toggleBan)(r),
},
acceptReports: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.acceptReports)(r),
},
rejectReports: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.rejectReports)(r),
},
sendForgotPasswordEmail: {
middleware: commonMiddleware,
handler: async (r) =>
callController(AdminController.sendForgotPasswordEmail)(r),
},

View file

@ -3,33 +3,18 @@ import { initServer } from "@ts-rest/express";
import * as ApeKeyController from "../controllers/ape-key";
import { callController } from "../ts-rest-adapter";
import { validate } from "../../middlewares/configuration";
const commonMiddleware = [
validate({
criteria: (configuration) => {
return configuration.apeKeys.endpointsEnabled;
},
invalidMessage: "ApeKeys are currently disabled.",
}),
];
const s = initServer();
export default s.router(apeKeysContract, {
get: {
middleware: commonMiddleware,
handler: async (r) => callController(ApeKeyController.getApeKeys)(r),
},
add: {
middleware: commonMiddleware,
handler: async (r) => callController(ApeKeyController.generateApeKey)(r),
},
save: {
middleware: commonMiddleware,
handler: async (r) => callController(ApeKeyController.editApeKey)(r),
},
delete: {
middleware: commonMiddleware,
handler: async (r) => callController(ApeKeyController.deleteApeKey)(r),
},
});

View file

@ -36,6 +36,7 @@ import { MonkeyValidationError } from "@monkeytype/contracts/schemas/api";
import { authenticateTsRestRequest } from "../../middlewares/auth";
import { rateLimitRequest } from "../../middlewares/rate-limit";
import { verifyPermissions } from "../../middlewares/permission";
import { verifyRequiredConfiguration } from "../../middlewares/configuration";
const pathOverride = process.env["API_PATH_OVERRIDE"];
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
@ -116,6 +117,7 @@ function applyTsRestApiRoutes(app: IRouter): void {
globalMiddleware: [
authenticateTsRestRequest(),
rateLimitRequest(),
verifyRequiredConfiguration(),
verifyPermissions(),
],
});

View file

@ -1,24 +1,8 @@
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import * as LeaderboardController from "../controllers/leaderboard";
import { leaderboardsContract } from "@monkeytype/contracts/leaderboards";
import { callController } from "../ts-rest-adapter";
const requireDailyLeaderboardsEnabled = validate({
criteria: (configuration) => {
return configuration.dailyLeaderboards.enabled;
},
invalidMessage: "Daily leaderboards are not available at this time.",
});
const requireWeeklyXpLeaderboardEnabled = validate({
criteria: (configuration) => {
return configuration.leaderboards.weeklyXp.enabled;
},
invalidMessage: "Weekly XP leaderboards are not available at this time.",
});
const s = initServer();
export default s.router(leaderboardsContract, {
get: {
@ -30,22 +14,18 @@ export default s.router(leaderboardsContract, {
callController(LeaderboardController.getRankFromLeaderboard)(r),
},
getDaily: {
middleware: [requireDailyLeaderboardsEnabled],
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboard)(r),
},
getDailyRank: {
middleware: [requireDailyLeaderboardsEnabled],
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboardRank)(r),
},
getWeeklyXp: {
middleware: [requireWeeklyXpLeaderboardEnabled],
handler: async (r) =>
callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r),
},
getWeeklyXpRank: {
middleware: [requireWeeklyXpLeaderboardEnabled],
handler: async (r) =>
callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r),
},

View file

@ -1,6 +1,5 @@
import { quotesContract } from "@monkeytype/contracts/quotes";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import * as QuoteController from "../controllers/quote";
import { callController } from "../ts-rest-adapter";
@ -14,15 +13,6 @@ export default s.router(quotesContract, {
callController(QuoteController.isSubmissionEnabled)(r),
},
add: {
middleware: [
validate({
criteria: (configuration) => {
return configuration.quotes.submissionsEnabled;
},
invalidMessage:
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.",
}),
],
handler: async (r) => callController(QuoteController.addQuote)(r),
},
approveSubmission: {
@ -38,14 +28,6 @@ export default s.router(quotesContract, {
handler: async (r) => callController(QuoteController.submitRating)(r),
},
report: {
middleware: [
validate({
criteria: (configuration) => {
return configuration.quotes.reporting.enabled;
},
invalidMessage: "Quote reporting is unavailable.",
}),
],
handler: async (r) => callController(QuoteController.reportQuote)(r),
},
});

View file

@ -1,23 +1,14 @@
import { resultsContract } from "@monkeytype/contracts/results";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import * as ResultController from "../controllers/result";
import { callController } from "../ts-rest-adapter";
const validateResultSavingEnabled = validate({
criteria: (configuration) => {
return configuration.results.savingEnabled;
},
invalidMessage: "Results are not being saved at this time.",
});
const s = initServer();
export default s.router(resultsContract, {
get: {
handler: async (r) => callController(ResultController.getResults)(r),
},
add: {
middleware: [validateResultSavingEnabled],
handler: async (r) => callController(ResultController.addResult)(r),
},
updateTags: {

View file

@ -1,51 +1,14 @@
import { usersContract } from "@monkeytype/contracts/users";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import * as UserController from "../controllers/user";
import { callController } from "../ts-rest-adapter";
const requireFilterPresetsEnabled = validate({
criteria: (configuration) => {
return configuration.results.filterPresets.enabled;
},
invalidMessage: "Result filter presets are not available at this time.",
});
const requireDiscordIntegrationEnabled = validate({
criteria: (configuration) => {
return configuration.users.discordIntegration.enabled;
},
invalidMessage: "Discord integration is not available at this time",
});
const requireProfilesEnabled = validate({
criteria: (configuration) => {
return configuration.users.profiles.enabled;
},
invalidMessage: "Profiles are not available at this time",
});
const requireInboxEnabled = validate({
criteria: (configuration) => {
return configuration.users.inbox.enabled;
},
invalidMessage: "Your inbox is not available at this time.",
});
const s = initServer();
export default s.router(usersContract, {
get: {
handler: async (r) => callController(UserController.getUser)(r),
},
create: {
middleware: [
validate({
criteria: (configuration) => {
return configuration.users.signUp;
},
invalidMessage: "Sign up is temporarily disabled",
}),
],
handler: async (r) => callController(UserController.createNewUser)(r),
},
getNameAvailability: {
@ -80,12 +43,10 @@ export default s.router(usersContract, {
callController(UserController.optOutOfLeaderboards)(r),
},
addResultFilterPreset: {
middleware: [requireFilterPresetsEnabled],
handler: async (r) =>
callController(UserController.addResultFilterPreset)(r),
},
removeResultFilterPreset: {
middleware: [requireFilterPresetsEnabled],
handler: async (r) =>
callController(UserController.removeResultFilterPreset)(r),
},
@ -117,11 +78,9 @@ export default s.router(usersContract, {
handler: async (r) => callController(UserController.editCustomTheme)(r),
},
getDiscordOAuth: {
middleware: [requireDiscordIntegrationEnabled],
handler: async (r) => callController(UserController.getOauthLink)(r),
},
linkDiscord: {
middleware: [requireDiscordIntegrationEnabled],
handler: async (r) => callController(UserController.linkDiscord)(r),
},
unlinkDiscord: {
@ -143,30 +102,18 @@ export default s.router(usersContract, {
handler: async (r) => callController(UserController.removeFavoriteQuote)(r),
},
getProfile: {
middleware: [requireProfilesEnabled],
handler: async (r) => callController(UserController.getProfile)(r),
},
updateProfile: {
middleware: [requireProfilesEnabled],
handler: async (r) => callController(UserController.updateProfile)(r),
},
getInbox: {
middleware: [requireInboxEnabled],
handler: async (r) => callController(UserController.getInbox)(r),
},
updateInbox: {
middleware: [requireInboxEnabled],
handler: async (r) => callController(UserController.updateInbox)(r),
},
report: {
middleware: [
validate({
criteria: (configuration) => {
return configuration.quotes.reporting.enabled;
},
invalidMessage: "User reporting is unavailable.",
}),
],
handler: async (r) => callController(UserController.reportUser)(r),
},
verificationEmail: {

View file

@ -1,33 +1,86 @@
import type { Response, NextFunction } from "express";
import { TsRestRequestWithCtx } from "./auth";
import { TsRestRequestHandler } from "@ts-rest/express";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
import MonkeyError from "../utils/error";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { TsRestRequestWithCtx } from "./auth";
import {
ConfigurationPath,
RequireConfiguration,
} from "@monkeytype/contracts/require-configuration/index";
export type ValidationOptions<T> = {
criteria: (data: T) => boolean;
invalidMessage?: string;
};
export function verifyRequiredConfiguration<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: TsRestRequestWithCtx,
_res: Response,
next: NextFunction
): Promise<void> => {
const requiredConfigurations = getRequireConfigurations(
req.tsRestRoute["metadata"]
);
/**
* This utility checks that the server's configuration matches
* the criteria.
*/
export function validate(
options: ValidationOptions<Configuration>
): MonkeyTypes.RequestHandler {
const {
criteria,
invalidMessage = "This service is currently unavailable.",
} = options;
return (req: TsRestRequestWithCtx, _res: Response, next: NextFunction) => {
const configuration = req.ctx.configuration;
const validated = criteria(configuration);
if (!validated) {
throw new MonkeyError(503, invalidMessage);
if (requiredConfigurations === undefined) {
next();
return;
}
try {
for (const requireConfiguration of requiredConfigurations) {
const value = getValue(
req.ctx.configuration,
requireConfiguration.path
);
if (!value) {
throw new MonkeyError(
503,
requireConfiguration.invalidMessage ??
"This endpoint is currently unavailable."
);
}
}
next();
return;
} catch (e) {
next(e);
return;
}
next();
};
}
function getValue(
configuration: Configuration,
path: ConfigurationPath
): boolean {
const keys = (path as string).split(".");
let result = configuration;
for (const key of keys) {
if (result === undefined || result === null)
throw new MonkeyError(500, `Invalid configuration path: "${path}"`);
result = result[key];
}
if (result === undefined || result === null)
throw new MonkeyError(
500,
`Required configuration doesnt exist: "${path}"`
);
if (typeof result !== "boolean")
throw new MonkeyError(
500,
`Required configuration is not a boolean: "${path}"`
);
return result;
}
function getRequireConfigurations(
metadata: EndpointMetadata | undefined
): RequireConfiguration[] | undefined {
if (metadata === undefined || metadata.requireConfiguration === undefined)
return undefined;
if (Array.isArray(metadata.requireConfiguration))
return metadata.requireConfiguration;
return [metadata.requireConfiguration];
}

View file

@ -2,8 +2,9 @@ import _ from "lodash";
import type { Request, Response, NextFunction, RequestHandler } from "express";
import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response";
import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus";
import { validate } from "./configuration";
import { isDevEnvironment } from "../utils/misc";
import MonkeyError from "../utils/error";
import { TsRestRequestWithCtx } from "./auth";
export const emptyMiddleware = (
_req: MonkeyTypes.Request,
@ -53,10 +54,16 @@ export function recordClientVersion(): RequestHandler {
}
export function onlyAvailableOnDev(): MonkeyTypes.RequestHandler {
return validate({
criteria: () => {
return isDevEnvironment();
},
invalidMessage: "Development endpoints are only available in DEV mode.",
});
return (_req: TsRestRequestWithCtx, _res: Response, next: NextFunction) => {
if (!isDevEnvironment()) {
next(
new MonkeyError(
503,
"Development endpoints are only available in DEV mode."
)
);
} else {
next();
}
};
}

View file

@ -112,6 +112,10 @@ export const adminContract = c.router(
authenticationOptions: { noCache: true },
rateLimit: "adminLimit",
requirePermission: "admin",
requireConfiguration: {
path: "admin.endpointsEnabled",
invalidMessage: "Admin endpoints are currently disabled.",
},
}),
commonResponses: CommonResponses,

View file

@ -102,6 +102,10 @@ export const apeKeysContract = c.router(
metadata: meta({
openApiTags: "ape-keys",
requirePermission: "canManageApeKeys",
requireConfiguration: {
path: "apeKeys.endpointsEnabled",
invalidMessage: "ApeKeys are currently disabled.",
},
}),
commonResponses: CommonResponses,

View file

@ -127,6 +127,10 @@ export const leaderboardsContract = c.router(
},
metadata: meta({
authenticationOptions: { isPublic: true },
requireConfiguration: {
path: "dailyLeaderboards.enabled",
invalidMessage: "Daily leaderboards are not available at this time.",
},
}),
},
getDailyRank: {
@ -138,6 +142,12 @@ export const leaderboardsContract = c.router(
responses: {
200: GetLeaderboardDailyRankResponseSchema,
},
metadata: meta({
requireConfiguration: {
path: "dailyLeaderboards.enabled",
invalidMessage: "Daily leaderboards are not available at this time.",
},
}),
},
getWeeklyXp: {
summary: "get weekly xp leaderboard",
@ -150,6 +160,11 @@ export const leaderboardsContract = c.router(
},
metadata: meta({
authenticationOptions: { isPublic: true },
requireConfiguration: {
path: "leaderboards.weeklyXp.enabled",
invalidMessage:
"Weekly XP leaderboards are not available at this time.",
},
}),
},
getWeeklyXpRank: {
@ -161,6 +176,13 @@ export const leaderboardsContract = c.router(
responses: {
200: GetWeeklyXpLeaderboardRankResponseSchema,
},
metadata: meta({
requireConfiguration: {
path: "leaderboards.weeklyXp.enabled",
invalidMessage:
"Weekly XP leaderboards are not available at this time.",
},
}),
},
},
{

View file

@ -125,6 +125,11 @@ export const quotesContract = c.router(
},
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: {
@ -193,6 +198,10 @@ export const quotesContract = c.router(
metadata: meta({
rateLimit: "quoteReportSubmit",
requirePermission: "canReport",
requireConfiguration: {
path: "quotes.reporting.enabled",
invalidMessage: "Quote reporting is unavailable.",
},
}),
},
},

View file

@ -0,0 +1,27 @@
import { Configuration } from "../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

@ -103,6 +103,10 @@ export const resultsContract = c.router(
},
metadata: meta({
rateLimit: "resultsAdd",
requireConfiguration: {
path: "results.savingEnabled",
invalidMessage: "Results are not being saved at this time.",
},
}),
},
updateTags: {

View file

@ -1,5 +1,6 @@
import { z, ZodSchema } from "zod";
import { RateLimitIds, RateLimiterId } from "../rate-limit";
import { RequireConfiguration } from "../require-configuration";
export type OpenApiTag =
| "configs"
@ -34,6 +35,9 @@ export type EndpointMetadata = {
/** Role/Rples needed to access the endpoint*/
requirePermission?: PermissionId | PermissionId[];
/** Endpoint is only available if configuration allows it */
requireConfiguration?: RequireConfiguration | RequireConfiguration[];
};
/**

View file

@ -349,6 +349,10 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userSignup",
requireConfiguration: {
path: "users.signUp",
invalidMessage: "Sign up is temporarily disabled",
},
}),
},
getNameAvailability: {
@ -502,6 +506,11 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userCustomFilterAdd",
requireConfiguration: {
path: "results.filterPresets.enabled",
invalidMessage:
"Result filter presets are not available at this time.",
},
}),
},
removeResultFilterPreset: {
@ -516,6 +525,11 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userCustomFilterRemove",
requireConfiguration: {
path: "results.filterPresets.enabled",
invalidMessage:
"Result filter presets are not available at this time.",
},
}),
},
getTags: {
@ -646,6 +660,10 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userDiscordLink",
requireConfiguration: {
path: "users.discordIntegration.enabled",
invalidMessage: "Discord integration is not available at this time",
},
}),
},
linkDiscord: {
@ -659,6 +677,10 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userDiscordLink",
requireConfiguration: {
path: "users.discordIntegration.enabled",
invalidMessage: "Discord integration is not available at this time",
},
}),
},
unlinkDiscord: {
@ -752,6 +774,10 @@ export const usersContract = c.router(
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userProfileGet",
requireConfiguration: {
path: "users.profiles.enabled",
invalidMessage: "Profiles are not available at this time",
},
}),
},
updateProfile: {
@ -765,6 +791,10 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userProfileUpdate",
requireConfiguration: {
path: "users.profiles.enabled",
invalidMessage: "Profiles are not available at this time",
},
}),
},
getInbox: {
@ -777,6 +807,10 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userMailGet",
requireConfiguration: {
path: "users.inbox.enabled",
invalidMessage: "Your inbox is not available at this time.",
},
}),
},
updateInbox: {
@ -790,6 +824,10 @@ export const usersContract = c.router(
},
metadata: meta({
rateLimit: "userMailUpdate",
requireConfiguration: {
path: "users.inbox.enabled",
invalidMessage: "Your inbox is not available at this time.",
},
}),
},
report: {
@ -804,6 +842,10 @@ export const usersContract = c.router(
metadata: meta({
rateLimit: "quoteReportSubmit",
requirePermission: "canReport",
requireConfiguration: {
path: "quotes.reporting.enabled",
invalidMessage: "User reporting is unavailable.",
},
}),
},
verificationEmail: {