diff --git a/server/package-lock.json b/server/package-lock.json index 8423aa8d0..0a0937527 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -46,6 +46,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@eslint/js": "^9.17.0", "@types/compression": "1.8.1", "@types/cookie-parser": "1.4.10", @@ -126,6 +127,19 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "license": "MIT" }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -851,7 +865,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1471,7 +1484,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1516,7 +1528,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4230,7 +4241,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4391,7 +4401,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4629,7 +4638,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5243,7 +5251,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5822,7 +5829,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6716,7 +6722,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7481,7 +7486,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7843,7 +7847,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9512,7 +9515,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11956,6 +11958,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12347,7 +12359,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14370,7 +14381,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14539,7 +14549,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14703,7 +14712,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/server/package.json b/server/package.json index 4251a2f9d..b942436ab 100755 --- a/server/package.json +++ b/server/package.json @@ -64,6 +64,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@eslint/js": "^9.17.0", "@types/compression": "1.8.1", "@types/cookie-parser": "1.4.10", diff --git a/server/src/index.ts b/server/src/index.ts index 83698b45c..54ac7ed2c 100755 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,9 +3,9 @@ import { initializeControllers } from "./config/controllers.js"; import { createApp } from "./app.js"; import { initShutdownListener } from "@/shutdown.js"; import { validateEnv } from "@/validation/envValidation.js"; +import { getOpenApiSpec } from "@/openapi/index.js"; import { fileURLToPath } from "url"; import path from "path"; -import fs from "fs"; import Logger, { ILogger } from "@/utils/logger.js"; import { SettingsService } from "@/service/index.js"; @@ -20,7 +20,7 @@ const startApp = async () => { // FE path const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8")); + const openApiSpec = getOpenApiSpec(); const frontendPath = path.join(__dirname, "..", "public"); // Create settings service (env only — DB repository injected after connect) diff --git a/server/src/openapi/index.ts b/server/src/openapi/index.ts new file mode 100644 index 000000000..b546c5259 --- /dev/null +++ b/server/src/openapi/index.ts @@ -0,0 +1,52 @@ +import "./registry.js"; +import "./routes/monitor.js"; + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import type { JsonObject } from "swagger-ui-express"; +import { registry } from "./registry.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let cached: JsonObject | null = null; + +export function getOpenApiSpec(): JsonObject { + if (cached) return cached; + + const staticSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../../openapi.json"), "utf8")) as Record; + + const generator = new OpenApiGeneratorV3(registry.definitions); + const generated = generator.generateDocument({ + openapi: "3.0.0", + info: { title: "generated", version: "0.0.0" }, + }); + + const generatedPaths = (generated.paths ?? {}) as Record; + const generatedSchemas = (generated.components?.schemas ?? {}) as Record; + const generatedSecuritySchemes = (generated.components?.securitySchemes ?? {}) as Record; + + const staticPaths = (staticSpec.paths ?? {}) as Record; + const staticComponents = (staticSpec.components ?? {}) as Record; + const staticSchemas = (staticComponents.schemas ?? {}) as Record; + const staticSecuritySchemes = (staticComponents.securitySchemes ?? {}) as Record; + + // Drop static paths that the registry now owns, plus stale entries + // for routes that no longer exist in the codebase. + const owned = new Set(Object.keys(generatedPaths)); + const stale = new Set(["/monitors/bulk", "/monitors/test-email"]); + const filteredStaticPaths = Object.fromEntries(Object.entries(staticPaths).filter(([key]) => !owned.has(key) && !stale.has(key))); + + cached = { + ...staticSpec, + paths: { ...filteredStaticPaths, ...generatedPaths }, + components: { + ...staticComponents, + schemas: { ...staticSchemas, ...generatedSchemas }, + securitySchemes: { ...staticSecuritySchemes, ...generatedSecuritySchemes }, + }, + }; + + return cached; +} diff --git a/server/src/openapi/registry.ts b/server/src/openapi/registry.ts new file mode 100644 index 000000000..0edd6314f --- /dev/null +++ b/server/src/openapi/registry.ts @@ -0,0 +1,20 @@ +import { OpenAPIRegistry, OpenApiGeneratorV3, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +extendZodWithOpenApi(z); + +export const registry = new OpenAPIRegistry(); + +registry.registerComponent("securitySchemes", "bearerAuth", { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", +}); + +export function generateMonitorPaths() { + const generator = new OpenApiGeneratorV3(registry.definitions); + return generator.generateDocument({ + openapi: "3.0.0", + info: { title: "Checkmate generated paths", version: "0.0.0" }, + }); +} diff --git a/server/src/openapi/routes/monitor.ts b/server/src/openapi/routes/monitor.ts new file mode 100644 index 000000000..ba5735cbe --- /dev/null +++ b/server/src/openapi/routes/monitor.ts @@ -0,0 +1,301 @@ +import "../registry.js"; +import { z } from "zod"; +import { registry } from "../registry.js"; +import { successEnvelope, successEnvelopeNoData, errorEnvelope } from "../schemas/common.js"; +import { + MonitorSchema, + MonitorWithChecksListSchema, + CertificateInfoSchema, + NotificationsUpdateResultSchema, + ImportResultSchema, +} from "../schemas/monitor.js"; +import { + getMonitorByIdParamValidation, + getMonitorByIdQueryValidation, + getMonitorsByTeamIdQueryValidation, + getMonitorsWithChecksQueryValidation, + getCertificateParamValidation, + createMonitorBodyValidation, + editMonitorBodyValidation, + pauseMonitorParamValidation, + getUptimeDetailsByIdParamValidation, + getUptimeDetailsByIdQueryValidation, + importMonitorsBodyValidation, + getHardwareDetailsByIdParamValidation, + getHardwareDetailsByIdQueryValidation, +} from "@/validation/monitorValidation.js"; +import { updateNotificationsValidation } from "@/validation/notificationValidation.js"; + +const bearer = [{ bearerAuth: [] }]; +const tags = ["monitors"]; + +const json = (schema: T) => ({ + "application/json": { schema }, +}); + +const standardErrors = { + "401": { description: "Unauthorized", content: json(errorEnvelope) }, + "403": { description: "Forbidden", content: json(errorEnvelope) }, + "500": { description: "Internal server error", content: json(errorEnvelope) }, +}; + +registry.registerPath({ + method: "get", + path: "/monitors/team", + tags, + summary: "List monitors for the caller's team", + security: bearer, + request: { query: getMonitorsByTeamIdQueryValidation }, + responses: { + "200": { description: "OK", content: json(successEnvelope(z.array(MonitorSchema))) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/team/with-checks", + tags, + summary: "List team monitors with their most recent checks (paginated)", + security: bearer, + request: { query: getMonitorsWithChecksQueryValidation }, + responses: { + "200": { description: "OK", content: json(successEnvelope(MonitorWithChecksListSchema)) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/team/groups", + tags, + summary: "List monitor groups for the caller's team", + security: bearer, + responses: { + "200": { description: "OK", content: json(successEnvelope(z.array(z.string()))) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/uptime/details/{monitorId}", + tags, + summary: "Get uptime details for a monitor", + security: bearer, + request: { + params: getUptimeDetailsByIdParamValidation, + query: getUptimeDetailsByIdQueryValidation, + }, + responses: { + "200": { description: "OK", content: json(successEnvelope(z.unknown())) }, + "400": { description: "Validation error", content: json(errorEnvelope) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/hardware/details/{monitorId}", + tags, + summary: "Get hardware metrics detail for a monitor", + security: bearer, + request: { + params: getHardwareDetailsByIdParamValidation, + query: getHardwareDetailsByIdQueryValidation, + }, + responses: { + "200": { description: "OK", content: json(successEnvelope(z.unknown())) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/pagespeed/details/{monitorId}", + tags, + summary: "Get PageSpeed detail for a monitor", + security: bearer, + request: { + params: getHardwareDetailsByIdParamValidation, + query: getHardwareDetailsByIdQueryValidation, + }, + responses: { + "200": { description: "OK", content: json(successEnvelopeNoData) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/{monitorId}/geo-checks", + tags, + summary: "Get geo check results for a monitor", + security: bearer, + request: { + params: getMonitorByIdParamValidation, + query: getMonitorByIdQueryValidation, + }, + responses: { + "200": { description: "OK", content: json(successEnvelopeNoData) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/pause/{monitorId}", + tags, + summary: "Toggle pause state for a monitor", + security: bearer, + request: { params: pauseMonitorParamValidation }, + responses: { + "200": { description: "OK", content: json(successEnvelope(MonitorSchema)) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/certificate/{monitorId}", + tags, + summary: "Get SSL certificate info for a monitor", + security: bearer, + request: { params: getCertificateParamValidation }, + responses: { + "200": { description: "OK", content: json(successEnvelope(CertificateInfoSchema)) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "patch", + path: "/monitors/notifications", + tags, + summary: "Bulk update notifications across monitors", + security: bearer, + request: { body: { content: json(updateNotificationsValidation) } }, + responses: { + "200": { description: "OK", content: json(successEnvelope(NotificationsUpdateResultSchema)) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors", + tags, + summary: "Create a new monitor", + security: bearer, + request: { body: { content: json(createMonitorBodyValidation) } }, + responses: { + "200": { description: "Monitor created", content: json(successEnvelope(MonitorSchema)) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "delete", + path: "/monitors", + tags, + summary: "Delete every monitor (superadmin only)", + security: bearer, + responses: { + "200": { description: "OK", content: json(successEnvelopeNoData) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/demo", + tags, + summary: "Insert preconfigured demo monitors", + security: bearer, + responses: { + "200": { description: "OK", content: json(successEnvelope(z.number())) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/export/json", + tags, + summary: "Export all team monitors as JSON", + security: bearer, + responses: { + "200": { description: "OK", content: json(successEnvelope(z.unknown())) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/import/json", + tags, + summary: "Import monitors from JSON", + security: bearer, + request: { body: { content: json(importMonitorsBodyValidation) } }, + responses: { + "200": { description: "OK", content: json(successEnvelope(ImportResultSchema)) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/games", + tags, + summary: "List supported game-server types", + security: bearer, + responses: { + "200": { description: "OK", content: json(successEnvelope(z.array(z.string()))) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/{monitorId}", + tags, + summary: "Get a monitor by id", + security: bearer, + request: { params: getMonitorByIdParamValidation }, + responses: { + "200": { description: "OK", content: json(successEnvelope(MonitorSchema)) }, + "404": { description: "Monitor not found", content: json(errorEnvelope) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "patch", + path: "/monitors/{monitorId}", + tags, + summary: "Edit a monitor", + security: bearer, + request: { + params: getMonitorByIdParamValidation, + body: { content: json(editMonitorBodyValidation) }, + }, + responses: { + "200": { description: "OK", content: json(successEnvelope(MonitorSchema)) }, + "404": { description: "Monitor not found", content: json(errorEnvelope) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "delete", + path: "/monitors/{monitorId}", + tags, + summary: "Delete a monitor", + security: bearer, + request: { params: getMonitorByIdParamValidation }, + responses: { + "200": { description: "OK", content: json(successEnvelope(MonitorSchema)) }, + "404": { description: "Monitor not found", content: json(errorEnvelope) }, + ...standardErrors, + }, +}); diff --git a/server/src/openapi/schemas/common.ts b/server/src/openapi/schemas/common.ts new file mode 100644 index 000000000..29f2ae724 --- /dev/null +++ b/server/src/openapi/schemas/common.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const successEnvelope = (data: T) => + z.object({ + success: z.literal(true), + msg: z.string(), + data, + }); + +export const successEnvelopeNoData = z.object({ + success: z.literal(true), + msg: z.string(), +}); + +export const errorEnvelope = z.object({ + success: z.literal(false), + msg: z.string(), + data: z.unknown().optional(), +}); diff --git a/server/src/openapi/schemas/monitor.ts b/server/src/openapi/schemas/monitor.ts new file mode 100644 index 000000000..6d2ab8cd1 --- /dev/null +++ b/server/src/openapi/schemas/monitor.ts @@ -0,0 +1,71 @@ +import "../registry.js"; +import { z } from "zod"; +import { GeoContinents } from "@/types/geoCheck.js"; +import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; + +export const MonitorSchema = z + .object({ + _id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + name: z.string(), + description: z.string().optional(), + type: z.enum(MonitorTypes), + url: z.string(), + port: z.number().optional(), + isActive: z.boolean(), + interval: z.number(), + status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]), + statusWindowSize: z.number(), + statusWindowThreshold: z.number(), + ignoreTlsErrors: z.boolean(), + useAdvancedMatching: z.boolean(), + jsonPath: z.string().optional(), + expectedValue: z.string().optional(), + matchMethod: z.enum(MonitorMatchMethods).optional(), + notifications: z.array(z.string()), + secret: z.string().optional(), + cpuAlertThreshold: z.number(), + memoryAlertThreshold: z.number(), + diskAlertThreshold: z.number(), + tempAlertThreshold: z.number(), + selectedDisks: z.array(z.string()), + gameId: z.string().optional(), + grpcServiceName: z.string().optional(), + group: z.string().nullable().optional(), + geoCheckEnabled: z.boolean(), + geoCheckLocations: z.array(z.enum(GeoContinents)), + geoCheckInterval: z.number(), + teamId: z.string(), + userId: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough() + .openapi("Monitor"); + +export const MonitorWithChecksListSchema = z + .object({ + monitors: z.array(MonitorSchema), + monitorCount: z.number(), + filteredCount: z.number(), + }) + .passthrough() + .openapi("MonitorWithChecksList"); + +export const CertificateInfoSchema = z + .object({ + certificateDate: z.string(), + }) + .openapi("CertificateInfo"); + +export const NotificationsUpdateResultSchema = z + .object({ + modifiedCount: z.number(), + }) + .openapi("NotificationsUpdateResult"); + +export const ImportResultSchema = z + .object({ + imported: z.number(), + }) + .passthrough() + .openapi("ImportResult");