diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 681a3da6c..609db4792 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -31,6 +31,7 @@ export const useStatusPageForm = ({ const defaults: StatusPageFormData = { companyName: data?.companyName || "", url: data?.url || generateDefaultUrl(), + customDomain: data?.customDomain ?? null, timezone: resolveTimezone(data?.timezone), type: data?.type || ["uptime"], color: data?.color || "#13715B", diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index c9bc04838..8a7b48010 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -151,6 +151,11 @@ const CreateStatusPage = () => { fd.append("isPublished", String(data.isPublished)); if (data.companyName) fd.append("companyName", data.companyName); if (data.url) fd.append("url", data.url); + if (data.customDomain !== undefined && data.customDomain !== null) { + fd.append("customDomain", data.customDomain); + } else { + fd.append("customDomain", ""); + } if (data.timezone) fd.append("timezone", data.timezone); if (data.color) fd.append("color", data.color); fd.append("showCharts", String(data.showCharts)); @@ -284,6 +289,27 @@ const CreateStatusPage = () => { /> )} /> + ( + + )} + /> } /> diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx index ba3a5940d..93c29d188 100644 --- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx +++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx @@ -8,7 +8,8 @@ import { Settings, ExternalLink } from "lucide-react"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; +import { type StatusPage } from "@/Types/StatusPage"; +import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl"; interface HeaderStatusPageControlsProps { isAdmin: boolean; @@ -50,7 +51,7 @@ export const HeaderStatusPageControls = ({ { window.open( - `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`, + getStatusPagePublicUrl(statusPage), "_blank", "noopener,noreferrer" ); diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 9c4001fae..5ccc5f44c 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -14,6 +14,11 @@ import { type StatusPageResponse, type StatusPageTheme, } from "@/Types/StatusPage"; +import { + buildStatusPageApiPath, + getStatusPagePublicUrl, + isCustomDomainHost, +} from "@/Utils/statusPageUrl"; import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls"; import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { @@ -67,9 +72,14 @@ const StatusPageView = () => { const isAdmin = useIsAdmin(); const location = useLocation(); - const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); + const onCustomDomainHost = isCustomDomainHost(); + const isPublic = + onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); - const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; + const apiUrl = buildStatusPageApiPath({ + url, + useCustomDomain: onCustomDomainHost, + }); const { data, isLoading, error } = useGet( apiUrl, @@ -134,7 +144,7 @@ const StatusPageView = () => { ); } - const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + const publicUrl = getStatusPagePublicUrl(statusPage); return ( { if (row.isPublished) { e.stopPropagation(); - const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`; - window.open(url, "_blank", "noopener,noreferrer"); + window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer"); } }; @@ -70,7 +71,9 @@ export const StatusPagesTable = ({ content: t("pages.statusPages.table.headers.url"), render: (row) => { const content = row.isPublished - ? `/${row.url}` + ? row.customDomain + ? getStatusPagePublicPath(row) + : `/${row.url}` : t("pages.statusPages.table.unpublished"); return ( { + if (isCustomDomainHost()) { + return ( + + } + /> + } + /> + + ); + } + return ( { + const source = + CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined); + if (!source) { + return "https:"; + } + + try { + return new URL(source).protocol; + } catch { + return "https:"; + } +}; + +const buildCustomDomainOrigin = (customDomain: string): string => + `${getClientOriginProtocol()}//${customDomain}`; + +export const getAppHostname = (): string | null => { + const source = + CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined); + if (!source) { + return null; + } + + try { + return new URL(source).hostname.toLowerCase(); + } catch { + return null; + } +}; + +export const isCustomDomainHost = (): boolean => { + if (typeof window === "undefined") { + return false; + } + + const appHostname = getAppHostname(); + if (!appHostname) { + return false; + } + + return window.location.hostname.toLowerCase() !== appHostname; +}; + +export const getStatusPagePublicPath = ( + statusPage: Pick +): string => { + if (statusPage.customDomain) { + return buildCustomDomainOrigin(statusPage.customDomain); + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + +export const getStatusPagePublicUrl = ( + statusPage: Pick +): string => { + if (statusPage.customDomain) { + return buildCustomDomainOrigin(statusPage.customDomain); + } + + if (typeof window !== "undefined") { + return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + +export const buildStatusPageApiPath = (options: { + url?: string; + useCustomDomain?: boolean; +}): string | null => { + const query = "type=uptime&type=infrastructure"; + + if (options.useCustomDomain && typeof window !== "undefined") { + const domain = encodeURIComponent(window.location.hostname); + return `/status-page/resolve?domain=${domain}&${query}`; + } + + if (options.url) { + return `/status-page/${options.url}?${query}`; + } + + return null; +}; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 542f6b2ae..271699765 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -9,6 +9,23 @@ import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage" // field definitions. export const statusPageStepRegistry = z.registry<{ step: number }>(); +const statusPageHostnameRegex = + /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + +const normalizeCustomDomainInput = (raw: string | null): string | null => { + if (raw === null) { + return null; + } + + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + const withoutScheme = trimmed.replace(/^https?:\/\//, ""); + return withoutScheme.split("/")[0]?.split(":")[0] ?? null; +}; + export const statusPageSchema = z.object({ companyName: z .string() @@ -22,6 +39,12 @@ export const statusPageSchema = z.object({ /^[a-z0-9-]+$/, "URL can only contain lowercase letters, numbers, and hyphens" ), + customDomain: z + .union([z.string(), z.null()]) + .transform(normalizeCustomDomainInput) + .refine((domain) => domain === null || statusPageHostnameRegex.test(domain), { + message: "Enter a valid domain name (e.g. status.example.com)", + }), timezone: z.string().optional(), type: z .array(z.enum(["uptime", "infrastructure"])) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e5f7073b6..b209bf414 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1509,6 +1509,11 @@ "url": { "label": "Your status page address", "placeholder": "my-status-page" + }, + "customDomain": { + "label": "Custom domain", + "placeholder": "status.example.com", + "helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain." } } }, diff --git a/server/openapi.json b/server/openapi.json index 77580866d..f3a6a2505 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -10448,6 +10448,17 @@ "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, + "customDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$" + }, + { + "nullable": true + } + ] + }, "timezone": { "type": "string" }, @@ -10651,6 +10662,17 @@ "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, + "customDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$" + }, + { + "nullable": true + } + ] + }, "timezone": { "type": "string" }, @@ -10920,6 +10942,102 @@ } } }, + "/status-page/resolve": { + "get": { + "tags": ["status-page"], + "summary": "Resolve a published status page by custom domain", + "parameters": [ + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + } + ] + }, + "required": true, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "timeFrame", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "domain", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "404": { + "description": "Status page not found" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/status-page/{url}": { "get": { "tags": ["status-page"], diff --git a/server/openapi/routes/statusPage.ts b/server/openapi/routes/statusPage.ts index d0df87972..7ecc6e6d7 100644 --- a/server/openapi/routes/statusPage.ts +++ b/server/openapi/routes/statusPage.ts @@ -1,6 +1,11 @@ import { registry } from "../registry.js"; import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; -import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/api/validation/statusPageValidation.js"; +import { + createStatusPageBodyValidation, + getStatusPageParamValidation, + getStatusPageQueryValidation, + resolveStatusPageQueryValidation, +} from "@/api/validation/statusPageValidation.js"; const tags = ["status-page"]; @@ -33,6 +38,15 @@ registry.registerPath({ responses: { "200": okUnknown, ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/status-page/resolve", + tags, + summary: "Resolve a published status page by custom domain", + request: { query: resolveStatusPageQueryValidation }, + responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, +}); + registry.registerPath({ method: "get", path: "/status-page/{url}", diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 5771fa5e4..394d76e23 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -5,6 +5,7 @@ import { getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation, + resolveStatusPageQueryValidation, } from "@/api/validation/statusPageValidation.js"; import { AppError } from "@/utils/AppError.js"; import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; @@ -12,6 +13,8 @@ import { IStatusPageService } from "@/domain/status-pages/status-page.service.js import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { NormalizeData } from "@/utils/dataUtils.js"; +import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js"; +import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; const SERVICE_NAME = "statusPageController"; @@ -20,6 +23,7 @@ export interface IStatusPageController { createStatusPage(req: Request, res: Response, next: NextFunction): Promise; updateStatusPage(req: Request, res: Response, next: NextFunction): Promise; getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise; + resolveStatusPageByDomain(req: Request, res: Response, next: NextFunction): Promise; getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise; deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise; } @@ -39,6 +43,41 @@ class StatusPageController implements IStatusPageController { return StatusPageController.SERVICE_NAME; } + private buildStatusPagePayload = async (statusPage: StatusPage, req: Request) => { + if (!statusPage.isPublished) { + const teamId = requireTeamId(req?.user?.teamId); + if (statusPage.teamId !== teamId) { + throw new AppError({ message: "Forbidden", status: 403 }); + } + } + + const settings = await this.settingsService.getDBSettings(); + const showURL = settings.showURL; + + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); + const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index])); + const sortedMonitors = [...monitors].sort((a, b) => { + const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + const normalizedMonitors = sortedMonitors.map((monitor) => { + const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); + if (!showURL) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { url, port, secret, notifications, ...rest } = monitor; + return { ...rest, checks: normalizedChecks }; + } + return { ...monitor, checks: normalizedChecks }; + }); + + return { + statusPage, + monitors: normalizedMonitors, + }; + }; + createStatusPage = async (req: Request, res: Response, next: NextFunction) => { try { createStatusPageBodyValidation.parse(req.body); @@ -96,47 +135,44 @@ class StatusPageController implements IStatusPageController { } const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); + const data = await this.buildStatusPagePayload(statusPage, req); - if (!statusPage.isPublished) { - const teamId = requireTeamId(req?.user?.teamId); - if (statusPage.teamId !== teamId) { - throw new AppError({ message: "Forbidden", status: 403 }); - } - } - - const settings = await this.settingsService.getDBSettings(); - const showURL = settings.showURL; - - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); - // Sort monitors according to the order in statusPage.monitors - const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index])); - const sortedMonitors = [...monitors].sort((a, b) => { - const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return orderA - orderB; - }); - - const normalizedMonitors = sortedMonitors.map((monitor) => { - const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); - if (!showURL) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { url, port, secret, notifications, ...rest } = monitor; - return { ...rest, checks: normalizedChecks }; - } - return { ...monitor, checks: normalizedChecks }; - }); return res.status(200).json({ success: true, msg: "Status page retrieved successfully", - data: { - statusPage, - monitors: normalizedMonitors, - }, + data, }); } catch (error) { next(error); } }; + + resolveStatusPageByDomain = async (req: Request, res: Response, next: NextFunction) => { + try { + resolveStatusPageQueryValidation.parse(req.query); + + const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); + if (!domain) { + throw new AppError({ message: "Domain is required", status: 400 }); + } + + const statusPage = await this.statusPageService.getStatusPageByCustomDomain(domain); + if (!statusPage.isPublished) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + + const data = await this.buildStatusPagePayload(statusPage, req); + + return res.status(200).json({ + success: true, + msg: "Status page retrieved successfully", + data, + }); + } catch (error) { + next(error); + } + }; + getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { const teamId = requireTeamId(req.user?.teamId); diff --git a/server/src/api/middleware/handleErrors.ts b/server/src/api/middleware/handleErrors.ts index 2f817b2af..239349b90 100755 --- a/server/src/api/middleware/handleErrors.ts +++ b/server/src/api/middleware/handleErrors.ts @@ -7,8 +7,7 @@ import { AppError } from "@/utils/AppError.js"; const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400); const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => { - const status = - error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500; + const status = error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500; const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error"; const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService"; const method = error instanceof AppError ? error.method : "unknownMethod"; diff --git a/server/src/api/middleware/statusPageCorsOrigin.ts b/server/src/api/middleware/statusPageCorsOrigin.ts new file mode 100644 index 000000000..338c43472 --- /dev/null +++ b/server/src/api/middleware/statusPageCorsOrigin.ts @@ -0,0 +1,68 @@ +import type { CorsOptions } from "cors"; +import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; + +const CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; +const CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS = 60 * 1000; + +type CustomDomainCorsCacheEntry = { + allowed: boolean; + expiresAt: number; +}; + +const getCustomDomainCorsCacheTtlMs = (allowed: boolean): number => + allowed ? CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS : CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS; + +export const createStatusPageCorsOrigin = (clientHost: string, statusPagesRepository: IStatusPagesRepository): CorsOptions["origin"] => { + const cache = new Map(); + + const cacheOriginResult = (customDomain: string, allowed: boolean) => { + cache.set(customDomain, { + allowed, + expiresAt: Date.now() + getCustomDomainCorsCacheTtlMs(allowed), + }); + }; + + return (origin, callback) => { + if (!origin) { + callback(null, true); + return; + } + + if (origin === clientHost) { + callback(null, true); + return; + } + + let customDomain: string | null = null; + try { + customDomain = normalizeStatusPageDomain(new URL(origin).hostname); + } catch { + callback(null, false); + return; + } + + if (!customDomain) { + callback(null, false); + return; + } + + const cached = cache.get(customDomain); + if (cached && cached.expiresAt > Date.now()) { + callback(null, cached.allowed ? origin : false); + return; + } + + statusPagesRepository + .findByCustomDomain(customDomain) + .then((statusPage) => { + const allowed = statusPage.isPublished; + cacheOriginResult(customDomain, allowed); + callback(null, allowed ? origin : false); + }) + .catch(() => { + cacheOriginResult(customDomain, false); + callback(null, false); + }); + }; +}; diff --git a/server/src/api/middleware/statusPagePublicApiPath.ts b/server/src/api/middleware/statusPagePublicApiPath.ts new file mode 100644 index 000000000..a4b5e42d1 --- /dev/null +++ b/server/src/api/middleware/statusPagePublicApiPath.ts @@ -0,0 +1,24 @@ +const PUBLIC_STATUS_PAGE_API_PREFIX = "/api/v1/status-page"; + +export const isPublicStatusPageApiPath = (method: string, path: string): boolean => { + if (!path.startsWith(PUBLIC_STATUS_PAGE_API_PREFIX)) { + return false; + } + + if (method !== "GET" && method !== "OPTIONS") { + return false; + } + + const subPath = path.slice(PUBLIC_STATUS_PAGE_API_PREFIX.length).split("?")[0] ?? ""; + const slug = subPath.replace(/^\//, ""); + + if (slug === "resolve") { + return true; + } + + if (!slug || slug === "team" || slug.includes("/")) { + return false; + } + + return true; +}; diff --git a/server/src/api/routes/authRoute.ts b/server/src/api/routes/authRoute.ts index e8ed31f2e..3b202a502 100644 --- a/server/src/api/routes/authRoute.ts +++ b/server/src/api/routes/authRoute.ts @@ -30,7 +30,13 @@ class AuthRoutes { this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById); - this.router.patch("/user", verifyJWT, imageUpload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser); + this.router.patch( + "/user", + verifyJWT, + imageUpload.single("profileImage"), + isAllowed(["admin", "superadmin", "user"]), + this.authController.editUser + ); this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser); } diff --git a/server/src/api/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts index 90b759048..eab3aecfe 100644 --- a/server/src/api/routes/statusPageRoute.ts +++ b/server/src/api/routes/statusPageRoute.ts @@ -18,6 +18,7 @@ class StatusPageRoutes { this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain); this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); } diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 58595e24f..2fa24be45 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { booleanCoercion } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; //**************************************** // Status Page Validations @@ -15,6 +16,20 @@ export const getStatusPageQueryValidation = z.object({ timeFrame: z.coerce.number().optional(), }); +export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({ + domain: z.string().optional(), +}); + +const customDomainValidation = z.preprocess( + (val) => { + if (val === undefined || val === null) { + return undefined; + } + return normalizeStatusPageDomain(String(val)); + }, + z.union([z.string().regex(dnsHostnameRegex, "Enter a valid domain name (e.g. status.example.com)"), z.null()]).optional() +); + export const createStatusPageBodyValidation = z .object({ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])), @@ -22,6 +37,7 @@ export const createStatusPageBodyValidation = z url: z.string().regex(/^[a-zA-Z0-9_-]+$/, { message: "URL can only contain letters, numbers, underscores, and hyphens", }), + customDomain: customDomainValidation, timezone: z.string().optional(), color: z.string().optional(), monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), diff --git a/server/src/app.ts b/server/src/app.ts index e397b798c..907c0f77d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,6 @@ import express from "express"; import path from "path"; -import cors from "cors"; +import cors, { type CorsOptions } from "cors"; import helmet from "helmet"; import compression from "compression"; import cookieParser from "cookie-parser"; @@ -12,6 +12,8 @@ import { setupRoutes } from "@/config/routes.js"; import { InitializedServices } from "@/config/services.js"; import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; +import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; +import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; export const createApp = ({ services, @@ -28,17 +30,23 @@ export const createApp = ({ }) => { const allowedOrigin = envSettings.clientHost; const app = express(); + const defaultCorsOptions: CorsOptions = { + origin: allowedOrigin, + methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", + allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"], + credentials: true, + }; + const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository); app.use(generalApiLimiter); - app.use( - cors({ - origin: allowedOrigin, - methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", - allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"], - credentials: true, - }) - ); + app.use((req, res, next) => { + const corsOptions = isPublicStatusPageApiPath(req.method, req.path) + ? { ...defaultCorsOptions, origin: publicStatusPageCorsOrigin } + : defaultCorsOptions; + + return cors(corsOptions)(req, res, next); + }); app.use(express.static(frontendPath)); diff --git a/server/src/domain/status-pages/status-page-repository.interface.ts b/server/src/domain/status-pages/status-page-repository.interface.ts index da80e3e3b..4b3eccd0a 100644 --- a/server/src/domain/status-pages/status-page-repository.interface.ts +++ b/server/src/domain/status-pages/status-page-repository.interface.ts @@ -5,6 +5,7 @@ export interface IStatusPagesRepository { create(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // single fetch findByUrl(url: string): Promise; + findByCustomDomain(customDomain: string): Promise; findByTeamId(teamId: string): Promise; // collection fetch // update diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index b7f5f984b..2dedca4ef 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -3,6 +3,7 @@ import { type StatusPageDocument, StatusPageModel } from "@/domain/status-pages/ import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/domain/status-pages/status-page.type.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; // Type for update data that can include document-level fields (Buffer for logo) type StatusPageUpdateData = Partial> & { @@ -47,6 +48,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { type: doc.type, companyName: doc.companyName, url: doc.url, + customDomain: doc.customDomain ?? null, timezone: doc.timezone ?? undefined, color: doc.color, monitors: this.mapIdArray(doc.monitors), @@ -74,10 +76,12 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { }; create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const { logo, ...restData } = data; + const { logo, customDomain, ...restData } = data; void logo; + const normalizedCustomDomain = normalizeStatusPageDomain(customDomain); const statusPage = new StatusPageModel({ ...restData, + ...(normalizedCustomDomain ? { customDomain: normalizedCustomDomain } : {}), userId, teamId, }); @@ -102,6 +106,21 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { // Get status page }; + findByCustomDomain = async (customDomain: string): Promise => { + const normalizedDomain = normalizeStatusPageDomain(customDomain); + if (!normalizedDomain) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + + const statusPage = await StatusPageModel.findOne({ + customDomain: normalizedDomain, + }); + if (!statusPage) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + return this.toEntity(statusPage); + }; + findByTeamId = async (teamId: string): Promise => { const statusPages = await StatusPageModel.find({ teamId }); return this.mapDocuments(statusPages); @@ -113,19 +132,37 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { image: Express.Multer.File | undefined, patch: Partial & { removeLogo?: string } ): Promise => { - const { logo, removeLogo, ...restPatch } = patch; + const { logo, removeLogo, customDomain, ...restPatch } = patch; void logo; - const updateData: StatusPageUpdateData = { ...restPatch }; + const setData: StatusPageUpdateData = { ...restPatch }; + const unsetData: Record = {}; + + if (customDomain !== undefined) { + const normalizedCustomDomain = normalizeStatusPageDomain(customDomain); + if (normalizedCustomDomain) { + setData.customDomain = normalizedCustomDomain; + } else { + unsetData.customDomain = 1; + } + } if (image) { - updateData.logo = { + setData.logo = { data: image.buffer as Buffer, contentType: image.mimetype, }; } else if (removeLogo === "true") { - updateData.logo = null; + setData.logo = null; } - const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateData, { + const updateQuery = + unsetData.customDomain !== undefined + ? { + $set: setData, + $unset: unsetData, + } + : setData; + + const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateQuery, { new: true, }); diff --git a/server/src/domain/status-pages/status-page.model.ts b/server/src/domain/status-pages/status-page.model.ts index 421d5df17..3337fe998 100644 --- a/server/src/domain/status-pages/status-page.model.ts +++ b/server/src/domain/status-pages/status-page.model.ts @@ -65,6 +65,11 @@ const StatusPageSchema = new Schema( required: true, default: "", }, + customDomain: { + type: String, + lowercase: true, + trim: true, + }, timezone: { type: String, }, @@ -128,6 +133,14 @@ const StatusPageSchema = new Schema( { timestamps: true } ); +StatusPageSchema.index( + { customDomain: 1 }, + { + unique: true, + partialFilterExpression: { customDomain: { $type: "string" } }, + } +); + const StatusPageModel = model("StatusPage", StatusPageSchema); export type { StatusPageDocument }; diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 4cfdc2dfa..6be395e49 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -1,10 +1,13 @@ import { type IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/domain/status-pages/status-page.type.js"; +import { AppError } from "@/utils/AppError.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; getStatusPageByUrl(url: string): Promise; + getStatusPageByCustomDomain(customDomain: string): Promise; getStatusPagesByTeamId(teamId: string): Promise; updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -19,6 +22,30 @@ export class StatusPageService implements IStatusPageService { this.settingsService = settingsService; } + private assertCustomDomainAllowed = (customDomain: string | null | undefined) => { + if (!customDomain) { + return; + } + + const clientHost = normalizeStatusPageDomain(this.settingsService.getSettings().clientHost); + if (clientHost && customDomain === clientHost) { + throw new AppError({ + message: "Custom domain cannot match the Checkmate instance host", + status: 400, + }); + } + }; + + private normalizeCustomDomainInput = (data: Partial): Partial => { + if (!("customDomain" in data)) { + return data; + } + + const customDomain = normalizeStatusPageDomain(data.customDomain); + this.assertCustomDomainAllowed(customDomain); + return { ...data, customDomain }; + }; + private withoutThemeFields = (data: Partial): Partial => { const { theme: _theme, themeMode: _themeMode, ...rest } = data; return rest; @@ -42,7 +69,8 @@ export class StatusPageService implements IStatusPageService { image: Express.Multer.File | undefined, data: Partial ): Promise => { - const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data)); + const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data)); + const created = await this.statusPagesRepository.create(userId, teamId, image, normalizedData); return this.normalizeTheme(created); }; @@ -51,13 +79,19 @@ export class StatusPageService implements IStatusPageService { return this.normalizeTheme(statusPage); }; + getStatusPageByCustomDomain = async (customDomain: string): Promise => { + const statusPage = await this.statusPagesRepository.findByCustomDomain(customDomain); + return this.normalizeTheme(statusPage); + }; + getStatusPagesByTeamId = async (teamId: string): Promise => { const statusPages = await this.statusPagesRepository.findByTeamId(teamId); return statusPages.map((sp) => this.normalizeTheme(sp)); }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data)); + const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data)); + const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData); return this.normalizeTheme(updated); }; diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index 0ef4d72a9..db08b7398 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -26,6 +26,7 @@ export interface StatusPage { type: StatusPageType[]; companyName: string; url: string; + customDomain?: string | null; timezone?: string; color: string; monitors: string[]; diff --git a/server/src/utils/statusPageDomain.ts b/server/src/utils/statusPageDomain.ts new file mode 100644 index 000000000..ec730a806 --- /dev/null +++ b/server/src/utils/statusPageDomain.ts @@ -0,0 +1,29 @@ +export const normalizeStatusPageDomain = (value?: string | null): string | null => { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + // Strip scheme, path, and port if a full URL was pasted. + const withoutScheme = trimmed.replace(/^https?:\/\//, ""); + const hostname = withoutScheme.split("/")[0]?.split(":")[0] ?? ""; + + return hostname || null; +}; + +export const resolveStatusPageDomainFromRequest = (hostHeader?: string | null, queryDomain?: string | null): string | null => { + const fromQuery = normalizeStatusPageDomain(queryDomain); + if (fromQuery) { + return fromQuery; + } + + if (!hostHeader) { + return null; + } + + return normalizeStatusPageDomain(hostHeader.split(":")[0]); +}; diff --git a/server/test/unit/middleware/statusPageCorsOrigin.test.ts b/server/test/unit/middleware/statusPageCorsOrigin.test.ts new file mode 100644 index 000000000..88a854845 --- /dev/null +++ b/server/test/unit/middleware/statusPageCorsOrigin.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createStatusPageCorsOrigin } from "../../../src/api/middleware/statusPageCorsOrigin.ts"; +import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; +import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; + +const makeStatusPage = (overrides?: Partial): StatusPage => + ({ + id: "sp-1", + isPublished: true, + customDomain: "status.example.com", + ...overrides, + }) as StatusPage; + +const createRepo = () => + ({ + findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()), + }) as unknown as jest.Mocked>; + +const invokeOrigin = (originFn: ReturnType, origin: string): Promise => + new Promise((resolve, reject) => { + originFn?.(origin, (error, result) => { + if (error) { + reject(error); + return; + } + resolve(result ?? false); + }); + }); + +describe("createStatusPageCorsOrigin", () => { + let repo: jest.Mocked>; + + beforeEach(() => { + repo = createRepo(); + }); + + it("allows the configured client host without a repository lookup", async () => { + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://checkmate.example.com")).resolves.toBe(true); + expect(repo.findByCustomDomain).not.toHaveBeenCalled(); + }); + + it("caches published custom domain lookups", async () => { + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com"); + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com"); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com"); + }); + + it("rejects unpublished custom domains", async () => { + repo.findByCustomDomain.mockResolvedValue(makeStatusPage({ isPublished: false })); + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false); + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + }); + + it("caches unknown custom domain lookups", async () => { + repo.findByCustomDomain.mockRejectedValue(new Error("Status page not found")); + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false); + await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + expect(repo.findByCustomDomain).toHaveBeenCalledWith("unknown.example.com"); + }); +}); diff --git a/server/test/unit/middleware/statusPagePublicApiPath.test.ts b/server/test/unit/middleware/statusPagePublicApiPath.test.ts new file mode 100644 index 000000000..95a799593 --- /dev/null +++ b/server/test/unit/middleware/statusPagePublicApiPath.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "@jest/globals"; +import { isPublicStatusPageApiPath } from "../../../src/api/middleware/statusPagePublicApiPath.ts"; + +describe("isPublicStatusPageApiPath", () => { + it("allows public resolve and slug routes", () => { + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/resolve")).toBe(true); + expect(isPublicStatusPageApiPath("OPTIONS", "/api/v1/status-page/resolve")).toBe(true); + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page")).toBe(true); + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page?type=uptime")).toBe(true); + }); + + it("rejects authenticated and mutating status page routes", () => { + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/team")).toBe(false); + expect(isPublicStatusPageApiPath("POST", "/api/v1/status-page")).toBe(false); + expect(isPublicStatusPageApiPath("PUT", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false); + expect(isPublicStatusPageApiPath("DELETE", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false); + expect(isPublicStatusPageApiPath("GET", "/api/v1/monitors")).toBe(false); + }); +}); diff --git a/server/test/unit/middleware/upload.test.ts b/server/test/unit/middleware/upload.test.ts index bbdfb205c..dc172d3da 100644 --- a/server/test/unit/middleware/upload.test.ts +++ b/server/test/unit/middleware/upload.test.ts @@ -6,10 +6,9 @@ import { AppError } from "../../../src/utils/AppError.ts"; // Reaches into the multer instance config to exercise the fileFilter/limits in isolation. // Guards against regressing GHSA-9xvg-x28f-m78m (pre-auth DoS via unbounded uploads). -const config = (imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } }); +const config = imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } }; -const makeFile = (mimetype: string): Express.Multer.File => - ({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File; +const makeFile = (mimetype: string): Express.Multer.File => ({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File; const runFilter = (mimetype: string): Promise<{ error: unknown; accepted: unknown }> => new Promise((resolve) => { diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 066bdff95..b2c2cdcee 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -26,20 +26,22 @@ const createRepo = () => ({ create: jest.fn().mockResolvedValue(makeStatusPage()), findByUrl: jest.fn().mockResolvedValue(makeStatusPage()), + findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()), findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]), updateById: jest.fn().mockResolvedValue(makeStatusPage()), deleteById: jest.fn().mockResolvedValue(makeStatusPage()), removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1), }) as unknown as jest.Mocked; -const createSettingsService = (themesEnabled: boolean) => +const createSettingsService = (themesEnabled: boolean, clientHost = "http://localhost:5173") => ({ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), + getSettings: jest.fn().mockReturnValue({ clientHost }), }) as unknown as jest.Mocked; -const createService = (themesEnabled = true) => { +const createService = (themesEnabled = true, clientHost = "http://localhost:5173") => { const repo = createRepo(); - const settingsService = createSettingsService(themesEnabled); + const settingsService = createSettingsService(themesEnabled, clientHost); const service = new StatusPageService(repo, settingsService); return { service, repo, settingsService }; }; @@ -99,6 +101,50 @@ describe("StatusPageService", () => { }); }); + describe("getStatusPageByCustomDomain", () => { + it("delegates to repository", async () => { + const { service, repo } = createService(true); + + const result = await service.getStatusPageByCustomDomain("status.example.com"); + + expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com"); + expect(result).toEqual(makeStatusPage()); + }); + }); + + describe("createStatusPage custom domain validation", () => { + it("rejects custom domains that match the Checkmate instance host", async () => { + const { service } = createService(true, "https://checkmate.example.com"); + + await expect( + service.createStatusPage("user-1", "team-1", undefined, { + customDomain: "checkmate.example.com", + }) + ).rejects.toMatchObject({ + status: 400, + }); + }); + + it("normalizes custom domains before create", async () => { + const { service, repo } = createService(true); + + await service.createStatusPage("user-1", "team-1", undefined, { + customDomain: "https://Status.Example.COM", + }); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, expect.objectContaining({ customDomain: "status.example.com" })); + }); + + it("allows multiple status pages without a custom domain", async () => { + const { service, repo } = createService(true); + + await service.createStatusPage("user-1", "team-1", undefined, { companyName: "First" }); + await service.createStatusPage("user-1", "team-1", undefined, { companyName: "Second" }); + + expect(repo.create).toHaveBeenCalledTimes(2); + }); + }); + describe("getStatusPagesByTeamId", () => { it("delegates to repository", async () => { const { service, repo } = createService(true); diff --git a/server/test/unit/utils/statusPageDomain.test.ts b/server/test/unit/utils/statusPageDomain.test.ts new file mode 100644 index 000000000..37cd03633 --- /dev/null +++ b/server/test/unit/utils/statusPageDomain.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@jest/globals"; +import { normalizeStatusPageDomain, resolveStatusPageDomainFromRequest } from "../../../src/utils/statusPageDomain.ts"; + +describe("statusPageDomain utils", () => { + describe("normalizeStatusPageDomain", () => { + it("returns null for empty values", () => { + expect(normalizeStatusPageDomain("")).toBeNull(); + expect(normalizeStatusPageDomain(" ")).toBeNull(); + expect(normalizeStatusPageDomain(null)).toBeNull(); + }); + + it("normalizes hostnames", () => { + expect(normalizeStatusPageDomain("Status.Example.COM")).toBe("status.example.com"); + expect(normalizeStatusPageDomain("https://status.example.com/path")).toBe("status.example.com"); + expect(normalizeStatusPageDomain("status.example.com:443")).toBe("status.example.com"); + }); + }); + + describe("resolveStatusPageDomainFromRequest", () => { + it("prefers the query domain over the host header", () => { + expect(resolveStatusPageDomainFromRequest("checkmate.example.com", "status.example.com")).toBe("status.example.com"); + }); + + it("falls back to the host header", () => { + expect(resolveStatusPageDomainFromRequest("status.example.com:8443", undefined)).toBe("status.example.com"); + }); + }); +});