diff --git a/backend/__tests__/middlewares/configuration.spec.ts b/backend/__tests__/middlewares/configuration.spec.ts new file mode 100644 index 000000000..733235a51 --- /dev/null +++ b/backend/__tests__/middlewares/configuration.spec.ts @@ -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 +): TsRestRequest { + return { + tsRestRoute: { metadata: { requireConfiguration } }, + ctx: { configuration }, + } as any; +} diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 83c78277a..5798b28c9 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -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); diff --git a/backend/src/api/routes/admin.ts b/backend/src/api/routes/admin.ts index 805fd7d28..90c07f9aa 100644 --- a/backend/src/api/routes/admin.ts +++ b/backend/src/api/routes/admin.ts @@ -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), }, diff --git a/backend/src/api/routes/ape-keys.ts b/backend/src/api/routes/ape-keys.ts index 08ff067b6..e5c027548 100644 --- a/backend/src/api/routes/ape-keys.ts +++ b/backend/src/api/routes/ape-keys.ts @@ -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), }, }); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index c969eed59..e415e6de7 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -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(), ], }); diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 171fa09e6..1533fd562 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -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), }, diff --git a/backend/src/api/routes/quotes.ts b/backend/src/api/routes/quotes.ts index bc48cd4d1..33ecf29fa 100644 --- a/backend/src/api/routes/quotes.ts +++ b/backend/src/api/routes/quotes.ts @@ -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), }, }); diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 210a5951b..bc3d6772a 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -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: { diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index af6ecf82c..8e34831a8 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -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: { diff --git a/backend/src/middlewares/configuration.ts b/backend/src/middlewares/configuration.ts index 042728ced..9eabbf8e7 100644 --- a/backend/src/middlewares/configuration.ts +++ b/backend/src/middlewares/configuration.ts @@ -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 = { - criteria: (data: T) => boolean; - invalidMessage?: string; -}; +export function verifyRequiredConfiguration< + T extends AppRouter | AppRoute +>(): TsRestRequestHandler { + return async ( + req: TsRestRequestWithCtx, + _res: Response, + next: NextFunction + ): Promise => { + const requiredConfigurations = getRequireConfigurations( + req.tsRestRoute["metadata"] + ); -/** - * This utility checks that the server's configuration matches - * the criteria. - */ -export function validate( - options: ValidationOptions -): 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]; +} diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index 0ae2386e7..3af1a0fdd 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -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(); + } + }; } diff --git a/packages/contracts/src/admin.ts b/packages/contracts/src/admin.ts index 093d6cb63..12f127047 100644 --- a/packages/contracts/src/admin.ts +++ b/packages/contracts/src/admin.ts @@ -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, diff --git a/packages/contracts/src/ape-keys.ts b/packages/contracts/src/ape-keys.ts index f083eea1e..d63b2d645 100644 --- a/packages/contracts/src/ape-keys.ts +++ b/packages/contracts/src/ape-keys.ts @@ -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, diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts index d3a52f8e3..6c48460cc 100644 --- a/packages/contracts/src/leaderboards.ts +++ b/packages/contracts/src/leaderboards.ts @@ -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.", + }, + }), }, }, { diff --git a/packages/contracts/src/quotes.ts b/packages/contracts/src/quotes.ts index 36a4efdf2..b44cd68c6 100644 --- a/packages/contracts/src/quotes.ts +++ b/packages/contracts/src/quotes.ts @@ -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.", + }, }), }, }, diff --git a/packages/contracts/src/require-configuration/index.ts b/packages/contracts/src/require-configuration/index.ts new file mode 100644 index 000000000..c1e89d166 --- /dev/null +++ b/packages/contracts/src/require-configuration/index.ts @@ -0,0 +1,27 @@ +import { Configuration } from "../schemas/configuration"; + +type BooleanPaths = { + [K in keyof T]: T[K] extends boolean + ? P extends "" + ? K + : `${P}.${Extract}` + : T[K] extends object + ? `${P}.${Extract}` extends infer D + ? D extends string + ? BooleanPaths + : never + : never + : never; +}[keyof T]; + +// Helper type to remove leading dot +type RemoveLeadingDot = T extends `.${infer U}` ? U : T; + +export type ConfigurationPath = RemoveLeadingDot>; + +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; +}; diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 793c9ac7c..2469ed4d7 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -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: { diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index d0ce83c12..d247783db 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -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[]; }; /** diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index fd1641fdd..8855b81f6 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -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: {