import { Elysia, t } from "elysia"; import { requireAuth } from "./auth"; import sql from "../db"; import { validateMonitorUrl } from "../utils/ssrf"; import { getPlanLimits } from "../../../shared/plans"; import { invalidateMonitorList } from "../cache/monitor-list"; const MonitorBody = t.Object({ name: t.String({ maxLength: 200, description: "Human-readable name" }), url: t.String({ format: "uri", maxLength: 2048, description: "URL to check" }), method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })), request_headers: t.Optional(t.Any({ description: "Request headers as key-value object" })), request_body: t.Optional(t.Nullable(t.String({ maxLength: 65536, description: "Request body for POST/PUT/PATCH (max 64KB)" }))), timeout_ms: t.Optional(t.Number({ minimum: 1000, maximum: 60000, default: 10000, description: "Request timeout in ms" })), interval_s: t.Optional(t.Number({ minimum: 2, default: 30, description: "Check interval in seconds (minimum 2)" })), max_retries: t.Optional(t.Number({ minimum: 0, maximum: 10, default: 0, description: "Retry a failing check up to N times before declaring DOWN" })), retry_interval_s: t.Optional(t.Number({ minimum: 1, maximum: 600, default: 30, description: "Seconds between retries" })), resend_interval: t.Optional(t.Number({ minimum: 0, maximum: 1000, default: 0, description: "Re-alert every Nth consecutive down beat. 0 = never resend." })), cert_alert_days: t.Optional(t.Number({ minimum: 0, maximum: 365, default: 0, description: "Alert when TLS cert is within N days of expiry. 0 disables (default)." })), max_redirects: t.Optional(t.Number({ minimum: 0, maximum: 4, default: 1, description: "Follow up to N redirects. 0 = don't follow. Default 1." })), query: t.Optional(t.Any({ description: "PingQL query - filter conditions for up/down" })), regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })), channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })), tags: t.Optional(t.Array(t.String({ pattern: "^[a-z0-9][a-z0-9-]{0,40}$" }), { description: "Lowercase tag slugs for grouping. Replaces the existing tag set." })), }); function dedupeTags(tags: string[]): string[] { return Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean))); } async function validateChannelIds(accountId: string, channelIds: string[]): Promise { if (channelIds.length === 0) return []; const owned = await sql<{ id: string }[]>` SELECT id FROM notification_channels WHERE account_id = ${accountId} AND id = ANY(${sql.array(channelIds)}::uuid[]) `; return owned.map((o) => o.id); } export const monitors = new Elysia({ prefix: "/monitors" }) .use(requireAuth) .get("/", async ({ accountId, query }) => { const tag = (query as any)?.tag; if (tag) { return sql` SELECT * FROM monitors WHERE account_id = ${accountId} AND ${tag} = ANY(tags) ORDER BY created_at DESC `; } return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`; }, { detail: { summary: "List monitors (optional ?tag= filter)", tags: ["monitors"] } }) .post("/", async ({ accountId, plan, body, set }) => { const limits = getPlanLimits(plan); const [{ count }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`; if (count >= limits.maxMonitors) { set.status = 403; return { error: `Plan limit reached: ${limits.maxMonitors} monitors (${plan}). Upgrade to create more.` }; } const interval = body.interval_s ?? limits.minIntervalS; if (interval < limits.minIntervalS) { set.status = 400; return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` }; } const retryGap = body.retry_interval_s ?? Math.max(30, limits.minIntervalS); if (retryGap < limits.minIntervalS) { set.status = 400; return { error: `Retry interval for ${plan} plan must be at least ${limits.minIntervalS}s` }; } const regions = body.regions ?? []; if (regions.length > limits.maxRegions) { set.status = 400; return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` }; } const ssrfError = await validateMonitorUrl(body.url); if (ssrfError) { set.status = 400; return { error: ssrfError }; } const tags = body.tags ? dedupeTags(body.tags) : []; const channelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : []; const [monitor] = await sql` INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, max_retries, retry_interval_s, resend_interval, cert_alert_days, max_redirects, query, regions, tags, channel_ids) VALUES ( ${accountId}, ${body.name}, ${body.url}, ${(body.method ?? 'GET').toUpperCase()}, ${body.request_headers ? sql.json(body.request_headers) : null}, ${body.request_body ?? null}, ${body.timeout_ms ?? 10000}, ${interval}, ${body.max_retries ?? 0}, ${retryGap}, ${body.resend_interval ?? 0}, ${body.cert_alert_days ?? 0}, ${body.max_redirects ?? 1}, ${body.query ? sql.json(body.query) : null}, ${sql.array(regions)}, ${sql.array(tags)}, ${sql.array(channelIds)}::uuid[] ) RETURNING * `; invalidateMonitorList(); return monitor; }, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } }) .get("/:id", async ({ accountId, params, set }) => { const [monitor] = await sql` SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} `; if (!monitor) { set.status = 404; return { error: "Not found" }; } const results = await sql` SELECT * FROM pings WHERE monitor_id = ${params.id} ORDER BY checked_at DESC LIMIT 100 `; return { ...monitor, results }; }, { detail: { summary: "Get monitor with results", tags: ["monitors"] } }) .patch("/:id", async ({ accountId, plan, params, body, set }) => { const limits = getPlanLimits(plan); if (body.interval_s != null && body.interval_s < limits.minIntervalS) { set.status = 400; return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` }; } if (body.retry_interval_s != null && body.retry_interval_s < limits.minIntervalS) { set.status = 400; return { error: `Retry interval for ${plan} plan must be at least ${limits.minIntervalS}s` }; } if (body.regions && body.regions.length > limits.maxRegions) { set.status = 400; return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` }; } if (body.url) { const ssrfError = await validateMonitorUrl(body.url); if (ssrfError) { set.status = 400; return { error: ssrfError }; } } const validatedChannelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : null; const [monitor] = await sql` UPDATE monitors SET name = COALESCE(${body.name ?? null}, name), url = COALESCE(${body.url ?? null}, url), method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method), request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers), request_body = COALESCE(${body.request_body ?? null}, request_body), timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms), interval_s = COALESCE(${body.interval_s ?? null}, interval_s), max_retries = COALESCE(${body.max_retries ?? null}, max_retries), retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s), resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval), cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days), max_redirects = COALESCE(${body.max_redirects ?? null}, max_redirects), query = COALESCE(${body.query ? sql.json(body.query) : null}, query), regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions), tags = COALESCE(${body.tags ? sql.array(dedupeTags(body.tags)) : null}, tags), channel_ids = COALESCE(${validatedChannelIds ? sql.array(validatedChannelIds) : null}::uuid[], channel_ids), updated_at = now() WHERE id = ${params.id} AND account_id = ${accountId} RETURNING * `; if (!monitor) { set.status = 404; return { error: "Not found" }; } invalidateMonitorList(); return monitor; }, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } }) .delete("/:id", async ({ accountId, params, set }) => { const [deleted] = await sql` DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id `; if (!deleted) { set.status = 404; return { error: "Not found" }; } invalidateMonitorList(); return { deleted: true }; }, { detail: { summary: "Delete monitor", tags: ["monitors"] } }) .post("/:id/toggle", async ({ accountId, params, set }) => { const [monitor] = await sql` UPDATE monitors SET enabled = NOT enabled WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id, enabled `; if (!monitor) { set.status = 404; return { error: "Not found" }; } invalidateMonitorList(); return monitor; }, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } }) .get("/:id/pings", async ({ accountId, params, query, set }) => { const [monitor] = await sql` SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} `; if (!monitor) { set.status = 404; return { error: "Not found" }; } const limit = Math.min(Number(query.limit) || 100, 1000); const skip = Math.max(0, Math.floor(Number(query.skip) || 0)); const filter = query.filter; // "up", "down", "events", or undefined/all const before = query.before ? Number(query.before) * 1000 : null; // unix timestamp (seconds) -> ms const cursorClause = before ? sql`AND checked_at < ${new Date(before)}` : sql``; const offsetClause = skip > 0 ? sql`OFFSET ${skip}` : sql``; if (filter === "up") { return sql` SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = true ${cursorClause} ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause} `; } if (filter === "down") { return sql` SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = false ${cursorClause} ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause} `; } if (filter === "events") { return sql` SELECT * FROM ( SELECT *, LAG(up) OVER (ORDER BY checked_at) AS prev_up FROM pings WHERE monitor_id = ${params.id} ) t WHERE prev_up IS NULL OR up != prev_up ${before ? sql`AND checked_at < ${new Date(before)}` : sql``} ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause} `; } return sql` SELECT * FROM pings WHERE monitor_id = ${params.id} ${cursorClause} ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause} `; }, { detail: { summary: "Get ping history", tags: ["monitors"] } });