Merge branch 'alerting-rules' into trial

This commit is contained in:
Owen
2026-04-21 14:57:25 -07:00
220 changed files with 4948 additions and 1900 deletions

View File

@@ -154,7 +154,10 @@ export enum ActionsEnum {
createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks"
listHealthChecks = "listHealthChecks",
triggerSiteAlert = "triggerSiteAlert",
triggerResourceAlert = "triggerResourceAlert",
triggerHealthCheckAlert = "triggerHealthCheckAlert"
}
export async function checkUserActionPermission(

View File

@@ -21,6 +21,7 @@ import {
exitNodes,
sessions,
clients,
resources,
siteResources,
targetHealthCheck,
sites
@@ -477,13 +478,21 @@ export const alertRules = pgTable("alertRules", {
.$type<
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_not_healthy"
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle"
>()
.notNull(),
// Nullable depending on eventType
enabled: boolean("enabled").notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
allSites: boolean("allSites").notNull().default(false),
allHealthChecks: boolean("allHealthChecks").notNull().default(false),
allResources: boolean("allResources").notNull().default(false),
lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
@@ -509,6 +518,15 @@ export const alertHealthChecks = pgTable("alertHealthChecks", {
})
});
export const alertResources = pgTable("alertResources", {
alertRuleId: integer("alertRuleId")
.notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
});
// Separating channels by type avoids the mixed-shape problem entirely
export const alertEmailActions = pgTable("alertEmailActions", {
emailActionId: serial("emailActionId").primaryKey(),
@@ -530,7 +548,7 @@ export const alertEmailRecipients = pgTable("alertEmailRecipients", {
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
roleId: varchar("roleId").references(() => roles.roleId, {
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
email: varchar("email", { length: 255 }) // external emails not tied to a user
@@ -584,3 +602,4 @@ export type EventStreamingDestination = InferSelectModel<
export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
export type AlertResources = InferSelectModel<typeof alertResources>;

View File

@@ -194,6 +194,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),

View File

@@ -13,6 +13,7 @@ import {
domains,
exitNodes,
orgs,
resources,
roles,
sessions,
siteResources,
@@ -469,12 +470,20 @@ export const alertRules = sqliteTable("alertRules", {
.$type<
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_not_healthy"
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle"
>()
.notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
allSites: integer("allSites", { mode: "boolean" }).notNull().default(false),
allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false),
allResources: integer("allResources", { mode: "boolean" }).notNull().default(false),
lastTriggeredAt: integer("lastTriggeredAt"),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
@@ -500,6 +509,15 @@ export const alertHealthChecks = sqliteTable("alertHealthChecks", {
})
});
export const alertResources = sqliteTable("alertResources", {
alertRuleId: integer("alertRuleId")
.notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
});
export const alertEmailActions = sqliteTable("alertEmailActions", {
emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }),
alertRuleId: integer("alertRuleId")
@@ -515,7 +533,7 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
.notNull()
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, { onDelete: "cascade" }),
roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
email: text("email")
});
@@ -561,3 +579,4 @@ export type EventStreamingDestination = InferSelectModel<
export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
export type AlertResources = InferSelectModel<typeof alertResources>;

View File

@@ -217,6 +217,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()

View File

@@ -15,8 +15,13 @@ import {
export type AlertEventType =
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_not_healthy";
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
interface Props {
eventType: AlertEventType;
@@ -50,6 +55,15 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Offline",
statusColor: "#dc2626"
};
case "site_toggle":
return {
heading: "Site Status Changed",
previewText: "A site in your organization has changed status.",
summary:
"A site in your organization has changed status. Please review the details below and take action if needed.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
};
case "health_check_healthy":
return {
heading: "Health Check Recovered",
@@ -60,7 +74,7 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Healthy",
statusColor: "#16a34a"
};
case "health_check_not_healthy":
case "health_check_unhealthy":
return {
heading: "Health Check Failing",
previewText:
@@ -70,6 +84,53 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Not Healthy",
statusColor: "#dc2626"
};
case "health_check_toggle":
return {
heading: "Health Check Status Changed",
previewText:
"A health check in your organization has changed status.",
summary:
"A health check in your organization has changed status. Please review the details below and take action if needed.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
};
case "resource_healthy":
return {
heading: "Resource Healthy",
previewText: "A resource in your organization is now healthy.",
summary:
"A resource in your organization has recovered and is now reporting a healthy status.",
statusLabel: "Healthy",
statusColor: "#16a34a"
};
case "resource_unhealthy":
return {
heading: "Resource Unhealthy",
previewText: "A resource in your organization is not healthy.",
summary:
"A resource in your organization is currently unhealthy. Please review the details below and take action if needed.",
statusLabel: "Unhealthy",
statusColor: "#dc2626"
};
case "resource_toggle":
return {
heading: "Resource Status Changed",
previewText:
"A resource in your organization has changed status.",
summary:
"A resource in your organization has changed status. Please review the details below and take action if needed.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
};
default:
return {
heading: "Alert Notification",
previewText: "An alert event has occurred in your organization.",
summary:
"An alert event has occurred in your organization. Please review the details below and take action if needed.",
statusLabel: "Alert",
statusColor: "#f59e0b"
};
}
}

View File

@@ -141,7 +141,9 @@ export async function updateProxyResources(
.insert(targetHealthCheck)
.values({
name: `${targetData.hostname}:${targetData.port}`,
siteId: site.siteId,
targetId: newTarget.targetId,
orgId: orgId,
hcEnabled: healthcheckData?.enabled || false,
hcPath: healthcheckData?.path,
hcScheme: healthcheckData?.scheme,

View File

@@ -55,7 +55,7 @@ export async function fireHealthCheckHealthyAlert(
}
/**
* Fire a `health_check_not_healthy` alert for the given health check.
* Fire a `health_check_unhealthy` alert for the given health check.
*
* Call this after a health check has been detected as failing so that any
* matching `alertRules` can dispatch their email and webhook actions.
@@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert(
): Promise<void> {
try {
await processAlerts({
eventType: "health_check_not_healthy",
eventType: "health_check_unhealthy",
orgId,
healthCheckId,
data: {

View File

@@ -19,73 +19,109 @@ import { processAlerts } from "../processAlerts";
// ---------------------------------------------------------------------------
/**
* Fire a `health_check_healthy` alert for the given health check.
* Fire a `resource_healthy` alert for the given resource.
*
* Call this after a previously-failing health check has recovered so that any
* Call this after a previously-unhealthy resource has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckHealthyAlert(
export async function fireResourceHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
): Promise<void> {
try {
await processAlerts({
eventType: "health_check_healthy",
eventType: "resource_healthy",
orgId,
healthCheckId,
resourceId,
data: {
healthCheckId,
...(healthCheckName != null ? { healthCheckName } : {}),
resourceId,
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `health_check_not_healthy` alert for the given health check.
* Fire a `resource_unhealthy` alert for the given resource.
*
* Call this after a health check has been detected as failing so that any
* Call this after a resource has been detected as unhealthy so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckNotHealthyAlert(
export async function fireResourceUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
): Promise<void> {
try {
await processAlerts({
eventType: "health_check_not_healthy",
eventType: "resource_unhealthy",
orgId,
healthCheckId,
resourceId,
data: {
healthCheckId,
...(healthCheckName != null ? { healthCheckName } : {}),
resourceId,
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_toggle` alert for the given resource.
*
* Call this when a resource's enabled/disabled status is toggled so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceToggleAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
): Promise<void> {
try {
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}

View File

@@ -11,12 +11,13 @@
* This file is not licensed under the AGPLv3.
*/
import { and, eq, isNull, or } from "drizzle-orm";
import { and, eq, or } from "drizzle-orm";
import { db } from "@server/db";
import {
alertRules,
alertSites,
alertHealthChecks,
alertResources,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions,
@@ -48,11 +49,9 @@ export async function processAlerts(context: AlertContext): Promise<void> {
// ------------------------------------------------------------------
// 1. Find matching alert rules
// ------------------------------------------------------------------
// Rules with no junction-table entries match ALL sites / health checks.
// Rules with junction entries match only those specific IDs.
// We implement this with a LEFT JOIN: a NULL join result means the rule
// has no scope restrictions (match all); a non-NULL result that satisfies
// the id equality filter means an explicit match.
// Rules with allSites / allHealthChecks / allResources set to true match
// ANY event of that type. Rules without these flags set match only the
// specific IDs listed in the junction tables.
const baseConditions = and(
eq(alertRules.orgId, context.orgId),
eq(alertRules.eventType, context.eventType),
@@ -73,12 +72,20 @@ export async function processAlerts(context: AlertContext): Promise<void> {
and(
baseConditions,
or(
eq(alertSites.siteId, context.siteId),
isNull(alertSites.alertRuleId)
eq(alertRules.allSites, true),
eq(alertSites.siteId, context.siteId)
)
)
);
rules = rows.map((r) => r.alertRules);
// Deduplicate in case a rule matched on multiple junction rows
const seen = new Set<number>();
rules = rows
.map((r) => r.alertRules)
.filter((r) => {
if (seen.has(r.alertRuleId)) return false;
seen.add(r.alertRuleId);
return true;
});
} else if (context.healthCheckId != null) {
const rows = await db
.select()
@@ -91,12 +98,44 @@ export async function processAlerts(context: AlertContext): Promise<void> {
and(
baseConditions,
or(
eq(alertHealthChecks.healthCheckId, context.healthCheckId),
isNull(alertHealthChecks.alertRuleId)
eq(alertRules.allHealthChecks, true),
eq(alertHealthChecks.healthCheckId, context.healthCheckId)
)
)
);
rules = rows.map((r) => r.alertRules);
const seen = new Set<number>();
rules = rows
.map((r) => r.alertRules)
.filter((r) => {
if (seen.has(r.alertRuleId)) return false;
seen.add(r.alertRuleId);
return true;
});
} else if (context.resourceId != null) {
const rows = await db
.select()
.from(alertRules)
.leftJoin(
alertResources,
eq(alertResources.alertRuleId, alertRules.alertRuleId)
)
.where(
and(
baseConditions,
or(
eq(alertRules.allResources, true),
eq(alertResources.resourceId, context.resourceId)
)
)
);
const seen = new Set<number>();
rules = rows
.map((r) => r.alertRules)
.filter((r) => {
if (seen.has(r.alertRuleId)) return false;
seen.add(r.alertRuleId);
return true;
});
} else {
rules = [];
}

View File

@@ -72,10 +72,20 @@ function buildSubject(context: AlertContext): string {
return "[Alert] Site Back Online";
case "site_offline":
return "[Alert] Site Offline";
case "site_toggle":
return "[Alert] Site Status Changed";
case "health_check_healthy":
return "[Alert] Health Check Recovered";
case "health_check_not_healthy":
case "health_check_unhealthy":
return "[Alert] Health Check Failing";
case "health_check_toggle":
return "[Alert] Health Check Status Changed";
case "resource_healthy":
return "[Alert] Resource Healthy";
case "resource_unhealthy":
return "[Alert] Resource Unhealthy";
case "resource_toggle":
return "[Alert] Resource Status Changed";
default: {
// Exhaustiveness fallback should never be reached with a
// well-typed caller, but keeps runtime behaviour predictable.
@@ -84,4 +94,4 @@ function buildSubject(context: AlertContext): string {
return "[Alert] Event Notification";
}
}
}
}

View File

@@ -18,8 +18,13 @@
export type AlertEventType =
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_not_healthy";
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
// ---------------------------------------------------------------------------
// Webhook authentication config (stored as encrypted JSON in the DB)
@@ -58,6 +63,8 @@ export interface AlertContext {
siteId?: number;
/** Set for health_check_* events */
healthCheckId?: number;
/** Set for resource_* events */
resourceId?: number;
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}
}

View File

@@ -0,0 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./triggerSiteAlert";
export * from "./triggerResourceAlert";
export * from "./triggerHealthCheckAlert";

View File

@@ -0,0 +1,129 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { targetHealthCheck, statusHistory } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckNotHealthyAlert
} from "#private/lib/alerts/events/healthCheckEvents";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
healthCheckId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
eventType: z.enum(["health_check_healthy", "health_check_unhealthy"])
});
export type TriggerHealthCheckAlertResponse = {
success: true;
};
export async function triggerHealthCheckAlert(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, healthCheckId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { eventType } = parsedBody.data;
// Verify the health check exists and belongs to the org
const [healthCheck] = await db
.select()
.from(targetHealthCheck)
.where(
and(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheckId
),
eq(targetHealthCheck.orgId, orgId)
)
)
.limit(1);
if (!healthCheck) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Health check ${healthCheckId} not found in organization ${orgId}`
)
);
}
await db.insert(statusHistory).values({
entityType: "healthCheck",
entityId: healthCheckId,
orgId,
status: eventType === "health_check_healthy" ? "healthy" : "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
if (eventType === "health_check_healthy") {
await fireHealthCheckHealthyAlert(
orgId,
healthCheckId,
healthCheck.name ?? undefined
);
} else {
await fireHealthCheckNotHealthyAlert(
orgId,
healthCheckId,
healthCheck.name ?? undefined
);
}
return response<TriggerHealthCheckAlertResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Alert triggered successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,135 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, statusHistory } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import {
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceToggleAlert
} from "#private/lib/alerts/events/resourceEvents";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"])
});
export type TriggerResourceAlertResponse = {
success: true;
};
export async function triggerResourceAlert(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { eventType } = parsedBody.data;
// Verify the resource exists and belongs to the org
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource ${resourceId} not found in organization ${orgId}`
)
);
}
if (eventType === "resource_healthy" || eventType === "resource_unhealthy") {
await db.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId,
status: eventType === "resource_healthy" ? "healthy" : "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
}
if (eventType === "resource_healthy") {
await fireResourceHealthyAlert(
orgId,
resourceId,
resource.name ?? undefined
);
} else if (eventType === "resource_unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resourceId,
resource.name ?? undefined
);
} else {
await fireResourceToggleAlert(
orgId,
resourceId,
resource.name ?? undefined
);
}
return response<TriggerResourceAlertResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Alert triggered successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,113 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { sites, statusHistory } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import {
fireSiteOnlineAlert,
fireSiteOfflineAlert
} from "#private/lib/alerts/events/siteEvents";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
siteId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
eventType: z.enum(["site_online", "site_offline"])
});
export type TriggerSiteAlertResponse = {
success: true;
};
export async function triggerSiteAlert(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, siteId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { eventType } = parsedBody.data;
// Verify the site exists and belongs to the org
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site ${siteId} not found in organization ${orgId}`
)
);
}
await db.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId,
status: eventType === "site_online" ? "online" : "offline",
timestamp: Math.floor(Date.now() / 1000)
});
if (eventType === "site_online") {
await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined);
} else {
await fireSiteOfflineAlert(orgId, siteId, site.name ?? undefined);
}
return response<TriggerSiteAlertResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Alert triggered successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -13,11 +13,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, roles } from "@server/db";
import {
alertRules,
alertSites,
alertHealthChecks,
alertResources,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions
@@ -31,10 +32,16 @@ import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
const HC_EVENT_TYPES = [
export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const;
export const HC_EVENT_TYPES = [
"health_check_healthy",
"health_check_not_healthy"
"health_check_unhealthy",
"health_check_toggle"
] as const;
export const RESOURCE_EVENT_TYPES = [
"resource_healthy",
"resource_unhealthy",
"resource_toggle"
] as const;
const paramsSchema = z.strictObject({
@@ -51,22 +58,28 @@ const bodySchema = z
.strictObject({
name: z.string().nonempty(),
eventType: z.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_not_healthy"
...HC_EVENT_TYPES,
...SITE_EVENT_TYPES,
...RESOURCE_EVENT_TYPES
]),
enabled: z.boolean().optional().default(true),
cooldownSeconds: z.number().int().nonnegative().optional().default(300),
// Source join tables - which is required depends on eventType
siteIds: z.array(z.number().int().positive()).optional().default([]),
allSites: z.boolean().optional().default(false),
healthCheckIds: z
.array(z.number().int().positive())
.optional()
.default([]),
allHealthChecks: z.boolean().optional().default(false),
resourceIds: z
.array(z.number().int().positive())
.optional()
.default([]),
allResources: z.boolean().optional().default(false),
// Email recipients (flat)
userIds: z.array(z.string().nonempty()).optional().default([]),
roleIds: z.array(z.string().nonempty()).optional().default([]),
roleIds: z.array(z.number()).optional().default([]),
emails: z.array(z.string().email()).optional().default([]),
// Webhook actions
webhookActions: z.array(webhookActionSchema).optional().default([])
@@ -78,21 +91,23 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
if (isSiteEvent && val.siteIds.length === 0) {
if (isSiteEvent && !val.allSites && val.siteIds.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"At least one siteId is required for site event types",
message: "At least one siteId is required for site event types when allSites is false",
path: ["siteIds"]
});
}
if (isHcEvent && val.healthCheckIds.length === 0) {
if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"At least one healthCheckId is required for health check event types",
"At least one healthCheckId is required for health check event types when allHealthChecks is false",
path: ["healthCheckIds"]
});
}
@@ -108,11 +123,50 @@ const bodySchema = z
if (isHcEvent && val.siteIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"siteIds must not be set for health check event types",
message: "siteIds must not be set for health check event types",
path: ["siteIds"]
});
}
if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one resourceId is required for resource event types when allResources is false",
path: ["resourceIds"]
});
}
if (isResourceEvent && val.siteIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "siteIds must not be set for resource event types",
path: ["siteIds"]
});
}
if (isResourceEvent && val.healthCheckIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "healthCheckIds must not be set for resource event types",
path: ["healthCheckIds"]
});
}
if (isSiteEvent && val.resourceIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "resourceIds must not be set for site event types",
path: ["resourceIds"]
});
}
if (isHcEvent && val.resourceIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "resourceIds must not be set for health check event types",
path: ["resourceIds"]
});
}
});
export type CreateAlertRuleResponse = {
@@ -171,7 +225,11 @@ export async function createAlertRule(
enabled,
cooldownSeconds,
siteIds,
allSites,
healthCheckIds,
allHealthChecks,
resourceIds,
allResources,
userIds,
roleIds,
emails,
@@ -188,13 +246,16 @@ export async function createAlertRule(
eventType,
enabled,
cooldownSeconds,
allSites,
allHealthChecks,
allResources,
createdAt: now,
updatedAt: now
})
.returning();
// Insert site associations
if (siteIds.length > 0) {
// Insert site associations (skipped when allSites=true — empty junction = match all)
if (!allSites && siteIds.length > 0) {
await db.insert(alertSites).values(
siteIds.map((siteId) => ({
alertRuleId: rule.alertRuleId,
@@ -203,8 +264,8 @@ export async function createAlertRule(
);
}
// Insert health check associations
if (healthCheckIds.length > 0) {
// Insert health check associations (skipped when allHealthChecks=true)
if (!allHealthChecks && healthCheckIds.length > 0) {
await db.insert(alertHealthChecks).values(
healthCheckIds.map((healthCheckId) => ({
alertRuleId: rule.alertRuleId,
@@ -213,10 +274,22 @@ export async function createAlertRule(
);
}
// Insert resource associations (skipped when allResources=true)
if (!allResources && resourceIds.length > 0) {
await db.insert(alertResources).values(
resourceIds.map((resourceId) => ({
alertRuleId: rule.alertRuleId,
resourceId
}))
);
}
// Create the email action pivot row and recipients if any recipients
// were supplied (userIds, roleIds, or raw emails).
const hasRecipients =
userIds.length > 0 || roleIds.length > 0 || emails.length > 0;
userIds.length > 0 ||
roleIds.length > 0 ||
emails.length > 0;
if (hasRecipients) {
const [emailActionRow] = await db
@@ -228,7 +301,7 @@ export async function createAlertRule(
...userIds.map((userId) => ({
emailActionId: emailActionRow.emailActionId,
userId,
roleId: null as string | null,
roleId: null as number | null,
email: null as string | null
})),
...roleIds.map((roleId) => ({
@@ -240,7 +313,7 @@ export async function createAlertRule(
...emails.map((email) => ({
emailActionId: emailActionRow.emailActionId,
userId: null as string | null,
roleId: null as string | null,
roleId: null as number | null,
email
}))
];
@@ -254,7 +327,10 @@ export async function createAlertRule(
webhookActions.map((wa) => ({
alertRuleId: rule.alertRuleId,
webhookUrl: wa.webhookUrl,
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
config:
wa.config != null
? encrypt(wa.config, serverSecret)
: null,
enabled: wa.enabled
}))
);
@@ -275,4 +351,4 @@ export async function createAlertRule(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -18,6 +18,7 @@ import {
alertRules,
alertSites,
alertHealthChecks,
alertResources,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions
@@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { WebhookAlertConfig } from "@server/lib/alerts/types";
import { WebhookAlertConfig } from "#private/lib/alerts/types";
const paramsSchema = z
.object({
@@ -47,8 +48,13 @@ export type GetAlertRuleResponse = {
eventType:
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_not_healthy";
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
enabled: boolean;
cooldownSeconds: number;
lastTriggeredAt: number | null;
@@ -56,10 +62,11 @@ export type GetAlertRuleResponse = {
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
recipients: {
recipientId: number;
userId: string | null;
roleId: string | null;
roleId: number | null;
email: string | null;
}[];
webhookActions: {
@@ -128,6 +135,12 @@ export async function getAlertRule(
.from(alertHealthChecks)
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
// Fetch resource associations
const resourceRows = await db
.select()
.from(alertResources)
.where(eq(alertResources.alertRuleId, alertRuleId));
// Resolve the single email action row for this rule, then collect all
// recipients into a flat list. The emailAction pivot row is an internal
// implementation detail and is not surfaced to callers.
@@ -175,26 +188,30 @@ export async function getAlertRule(
updatedAt: rule.updatedAt,
siteIds: siteRows.map((r) => r.siteId),
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
resourceIds: resourceRows.map((r) => r.resourceId),
recipients,
webhookActions: webhooks.map((w) => {
let parsedConfig: WebhookAlertConfig | null = null;
if (w.config) {
try {
const serverSecret = config.getRawConfig().server.secret!;
const decrypted = decrypt(w.config, serverSecret);
parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig;
} catch {
// best-effort return null if decryption fails
}
}
return {
webhookActionId: w.webhookActionId,
webhookUrl: w.webhookUrl,
enabled: w.enabled,
lastSentAt: w.lastSentAt ?? null,
config: parsedConfig
};
})
let parsedConfig: WebhookAlertConfig | null = null;
if (w.config) {
try {
const serverSecret =
config.getRawConfig().server.secret!;
const decrypted = decrypt(w.config, serverSecret);
parsedConfig = JSON.parse(
decrypted
) as WebhookAlertConfig;
} catch {
// best-effort return null if decryption fails
}
}
return {
webhookActionId: w.webhookActionId,
webhookUrl: w.webhookUrl,
enabled: w.enabled,
lastSentAt: w.lastSentAt ?? null,
config: parsedConfig
};
})
},
success: true,
error: false,
@@ -207,4 +224,4 @@ export async function getAlertRule(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -14,14 +14,14 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { alertRules, alertSites, alertHealthChecks } from "@server/db";
import { alertRules, alertSites, alertHealthChecks, alertResources } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, like, sql } from "drizzle-orm";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -39,7 +39,18 @@ const querySchema = z.strictObject({
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
.pipe(z.number().int().nonnegative()),
query: z.string().optional(),
siteId: z
.string()
.optional()
.transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().int().positive().optional()),
resourceId: z
.string()
.optional()
.transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().int().positive().optional())
});
export type ListAlertRulesResponse = {
@@ -55,6 +66,7 @@ export type ListAlertRulesResponse = {
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
}[];
pagination: {
total: number;
@@ -101,12 +113,69 @@ export async function listAlertRules(
)
);
}
const { limit, offset } = parsedQuery.data;
const { limit, offset, query, siteId, resourceId } = parsedQuery.data;
// Resolve siteId filter → matching alertRuleIds
let siteFilterRuleIds: number[] | null = null;
if (siteId !== undefined) {
const rows = await db
.select({ alertRuleId: alertSites.alertRuleId })
.from(alertSites)
.where(eq(alertSites.siteId, siteId));
siteFilterRuleIds = rows.map((r) => r.alertRuleId);
if (siteFilterRuleIds.length === 0) {
return response<ListAlertRulesResponse>(res, {
data: {
alertRules: [],
pagination: { total: 0, limit, offset }
},
success: true,
error: false,
message: "Alert rules retrieved successfully",
status: HttpCode.OK
});
}
}
// Resolve resourceId filter → matching alertRuleIds
let resourceFilterRuleIds: number[] | null = null;
if (resourceId !== undefined) {
const rows = await db
.select({ alertRuleId: alertResources.alertRuleId })
.from(alertResources)
.where(eq(alertResources.resourceId, resourceId));
resourceFilterRuleIds = rows.map((r) => r.alertRuleId);
if (resourceFilterRuleIds.length === 0) {
return response<ListAlertRulesResponse>(res, {
data: {
alertRules: [],
pagination: { total: 0, limit, offset }
},
success: true,
error: false,
message: "Alert rules retrieved successfully",
status: HttpCode.OK
});
}
}
const whereClause = and(
eq(alertRules.orgId, orgId),
query
? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`)
: undefined,
siteFilterRuleIds !== null
? inArray(alertRules.alertRuleId, siteFilterRuleIds)
: undefined,
resourceFilterRuleIds !== null
? inArray(alertRules.alertRuleId, resourceFilterRuleIds)
: undefined
);
const list = await db
.select()
.from(alertRules)
.where(eq(alertRules.orgId, orgId))
.where(whereClause)
.orderBy(sql`${alertRules.createdAt} DESC`)
.limit(limit)
.offset(offset);
@@ -114,7 +183,7 @@ export async function listAlertRules(
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(alertRules)
.where(eq(alertRules.orgId, orgId));
.where(whereClause);
// Batch-fetch site and health-check associations for all returned rules
// in two queries rather than N+1 individual lookups.
@@ -138,6 +207,14 @@ export async function listAlertRules(
)
: [];
const resourceRows =
ruleIds.length > 0
? await db
.select()
.from(alertResources)
.where(inArray(alertResources.alertRuleId, ruleIds))
: [];
// Index by alertRuleId for O(1) lookup when building the response
const sitesByRule = new Map<number, number[]>();
for (const row of siteRows) {
@@ -153,6 +230,13 @@ export async function listAlertRules(
healthChecksByRule.set(row.alertRuleId, existing);
}
const resourcesByRule = new Map<number, number[]>();
for (const row of resourceRows) {
const existing = resourcesByRule.get(row.alertRuleId) ?? [];
existing.push(row.resourceId);
resourcesByRule.set(row.alertRuleId, existing);
}
return response<ListAlertRulesResponse>(res, {
data: {
alertRules: list.map((rule) => ({
@@ -167,7 +251,8 @@ export async function listAlertRules(
updatedAt: rule.updatedAt,
siteIds: sitesByRule.get(rule.alertRuleId) ?? [],
healthCheckIds:
healthChecksByRule.get(rule.alertRuleId) ?? []
healthChecksByRule.get(rule.alertRuleId) ?? [],
resourceIds: resourcesByRule.get(rule.alertRuleId) ?? []
})),
pagination: {
total: count,

View File

@@ -18,6 +18,7 @@ import {
alertRules,
alertSites,
alertHealthChecks,
alertResources,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions
@@ -31,12 +32,8 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
const HC_EVENT_TYPES = [
"health_check_healthy",
"health_check_not_healthy"
] as const;
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode";
const paramsSchema = z
.object({
@@ -57,20 +54,23 @@ const bodySchema = z
name: z.string().nonempty().optional(),
eventType: z
.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_not_healthy"
...HC_EVENT_TYPES,
...SITE_EVENT_TYPES,
...RESOURCE_EVENT_TYPES
])
.optional(),
enabled: z.boolean().optional(),
cooldownSeconds: z.number().int().nonnegative().optional(),
// Source join tables - if provided the full set is replaced
siteIds: z.array(z.number().int().positive()).optional(),
allSites: z.boolean().optional(),
healthCheckIds: z.array(z.number().int().positive()).optional(),
allHealthChecks: z.boolean().optional(),
resourceIds: z.array(z.number().int().positive()).optional(),
allResources: z.boolean().optional(),
// Recipient arrays - if any are provided the full recipient set is replaced
userIds: z.array(z.string().nonempty()).optional(),
roleIds: z.array(z.string().nonempty()).optional(),
roleIds: z.array(z.number()).optional(),
emails: z.array(z.string().email()).optional(),
// Webhook actions - if provided the full webhook set is replaced
webhookActions: z.array(webhookActionSchema).optional()
@@ -84,6 +84,33 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one siteId is required for site event types when allSites is false",
path: ["siteIds"]
});
}
if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one healthCheckId is required for health check event types when allHealthChecks is false",
path: ["healthCheckIds"]
});
}
if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one resourceId is required for resource event types when allResources is false",
path: ["resourceIds"]
});
}
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
ctx.addIssue({
@@ -100,6 +127,22 @@ const bodySchema = z
path: ["siteIds"]
});
}
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "siteIds must not be set for resource event types",
path: ["siteIds"]
});
}
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "healthCheckIds must not be set for resource event types",
path: ["healthCheckIds"]
});
}
});
export type UpdateAlertRuleResponse = {
@@ -174,7 +217,11 @@ export async function updateAlertRule(
enabled,
cooldownSeconds,
siteIds,
allSites,
healthCheckIds,
allHealthChecks,
resourceIds,
allResources,
userIds,
roleIds,
emails,
@@ -189,8 +236,10 @@ export async function updateAlertRule(
if (name !== undefined) updateData.name = name;
if (eventType !== undefined) updateData.eventType = eventType;
if (enabled !== undefined) updateData.enabled = enabled;
if (cooldownSeconds !== undefined)
updateData.cooldownSeconds = cooldownSeconds;
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds;
if (allSites !== undefined) updateData.allSites = allSites;
if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks;
if (allResources !== undefined) updateData.allResources = allResources;
await db
.update(alertRules)
@@ -203,12 +252,14 @@ export async function updateAlertRule(
);
// --- Full-replace site associations if siteIds was provided ---
if (siteIds !== undefined) {
if (siteIds !== undefined || allSites !== undefined) {
await db
.delete(alertSites)
.where(eq(alertSites.alertRuleId, alertRuleId));
if (siteIds.length > 0) {
// Only insert junction rows when allSites is not true
const effectiveAllSites = allSites ?? false;
if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) {
await db.insert(alertSites).values(
siteIds.map((siteId) => ({
alertRuleId,
@@ -219,12 +270,13 @@ export async function updateAlertRule(
}
// --- Full-replace health check associations if healthCheckIds was provided ---
if (healthCheckIds !== undefined) {
if (healthCheckIds !== undefined || allHealthChecks !== undefined) {
await db
.delete(alertHealthChecks)
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
if (healthCheckIds.length > 0) {
const effectiveAllHealthChecks = allHealthChecks ?? false;
if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) {
await db.insert(alertHealthChecks).values(
healthCheckIds.map((healthCheckId) => ({
alertRuleId,
@@ -234,6 +286,23 @@ export async function updateAlertRule(
}
}
// --- Full-replace resource associations if resourceIds was provided ---
if (resourceIds !== undefined || allResources !== undefined) {
await db
.delete(alertResources)
.where(eq(alertResources.alertRuleId, alertRuleId));
const effectiveAllResources = allResources ?? false;
if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) {
await db.insert(alertResources).values(
resourceIds.map((resourceId) => ({
alertRuleId,
resourceId
}))
);
}
}
// --- Full-replace recipients if any recipient array was provided ---
const recipientsProvided =
userIds !== undefined ||
@@ -244,7 +313,7 @@ export async function updateAlertRule(
const newRecipients = [
...(userIds ?? []).map((userId) => ({
userId,
roleId: null as string | null,
roleId: null as number | null,
email: null as string | null
})),
...(roleIds ?? []).map((roleId) => ({
@@ -254,7 +323,7 @@ export async function updateAlertRule(
})),
...(emails ?? []).map((email) => ({
userId: null as string | null,
roleId: null as string | null,
roleId: null as number | null,
email
}))
];
@@ -331,4 +400,4 @@ export async function updateAlertRule(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -42,7 +42,9 @@ import {
verifyRoleAccess,
verifyUserAccess,
verifyUserCanSetUserOrgRoles,
verifySiteProvisioningKeyAccess
verifySiteProvisioningKeyAccess,
verifyIsLoggedInUser,
verifyAdmin
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
@@ -89,6 +91,7 @@ authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
orgIdp.requireOrgIdentityProviderMode,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createIdp),
@@ -96,10 +99,23 @@ authenticated.put(
orgIdp.createOrgOidcIdp
);
authenticated.post(
"/org/:orgId/idp/:idpId/import",
verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
orgIdp.requireOrgIdentityProviderMode,
verifyOrgAccess,
verifyLimits,
verifyAdmin,
logActionAudit(ActionsEnum.createIdp),
orgIdp.importOrgIdp
);
authenticated.post(
"/org/:orgId/idp/:idpId/oidc",
verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
orgIdp.requireOrgIdentityProviderMode,
verifyOrgAccess,
verifyIdpAccess,
verifyLimits,
@@ -111,6 +127,7 @@ authenticated.post(
authenticated.delete(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
orgIdp.requireOrgIdentityProviderMode,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.deleteIdp),
@@ -118,6 +135,17 @@ authenticated.delete(
orgIdp.deleteOrgIdp
);
authenticated.delete(
"/org/:orgId/idp/:idpId/association",
verifyValidLicense,
orgIdp.requireOrgIdentityProviderMode,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.deleteIdp),
logActionAudit(ActionsEnum.deleteIdp),
orgIdp.unassociateOrgIdp
);
authenticated.get(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
@@ -127,16 +155,14 @@ authenticated.get(
orgIdp.getOrgIdp
);
authenticated.get(
"/org/:orgId/idp",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listIdps),
orgIdp.listOrgIdps
);
authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get(
"/user/:userId/admin-org-idps",
verifyIsLoggedInUser,
orgIdp.listUserAdminOrgIdps
);
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyValidLicense,

View File

@@ -13,13 +13,15 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, targetHealthCheck } from "@server/db";
import { db, targetHealthCheck, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -27,6 +29,7 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({
name: z.string().nonempty(),
siteId: z.number().int().positive(),
hcEnabled: z.boolean().default(false),
hcMode: z.string().default("http"),
hcHostname: z.string().optional(),
@@ -97,6 +100,7 @@ export async function createHealthCheck(
const {
name,
siteId,
hcEnabled,
hcMode,
hcHostname,
@@ -120,6 +124,7 @@ export async function createHealthCheck(
.values({
targetId: null,
orgId,
siteId,
name,
hcEnabled,
hcMode,
@@ -140,6 +145,31 @@ export async function createHealthCheck(
})
.returning();
// Push health check to newt if the site is a newt site
if (siteId) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (site && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
await addStandaloneHealthCheck(
newt.newtId,
record,
newt.version
);
}
}
}
return response<CreateHealthCheckResponse>(res, {
data: {
targetHealthCheckId: record.targetHealthCheckId

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, targetHealthCheck } from "@server/db";
import { db, targetHealthCheck, newts, sites } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -21,6 +21,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm";
import { removeStandaloneHealthCheck } from "@server/routers/newt/targets";
const paramsSchema = z
.object({
@@ -91,6 +92,21 @@ export async function deleteHealthCheck(
)
);
// Remove health check from newt if the site is a newt site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, existing.siteId))
.limit(1);
if (newt) {
await removeStandaloneHealthCheck(
newt.newtId,
healthCheckId,
newt.version
);
}
return response<null>(res, {
data: null,
success: true,

View File

@@ -43,7 +43,7 @@ export async function getHealthCheckStatusHistory(
}
const entityType = "healthCheck";
const entityId = parsedParams.data.healthCheckId
const entityId = parsedParams.data.healthCheckId;
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);

View File

@@ -11,13 +11,13 @@
* This file is not licensed under the AGPLv3.
*/
import { db, targetHealthCheck, targets, resources } from "@server/db";
import { db, targetHealthCheck, targets, resources, sites } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull, sql } from "drizzle-orm";
import { and, eq, like, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import { z } from "zod";
import { fromError } from "zod-validation-error";
@@ -39,7 +39,8 @@ const querySchema = z.object({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
query: z.string().optional()
});
registry.registerPath({
@@ -80,16 +81,25 @@ export async function listHealthChecks(
)
);
}
const { limit, offset } = parsedQuery.data;
const { limit, offset, query } = parsedQuery.data;
const whereClause = and(
eq(targetHealthCheck.orgId, orgId),
query
? like(
sql`LOWER(${targetHealthCheck.name})`,
`%${query.toLowerCase()}%`
)
: undefined
);
const list = await db
.select({
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
name: targetHealthCheck.name,
siteId: targetHealthCheck.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
hcEnabled: targetHealthCheck.hcEnabled,
hcHealth: targetHealthCheck.hcHealth,
hcMode: targetHealthCheck.hcMode,
@@ -114,6 +124,7 @@ export async function listHealthChecks(
.from(targetHealthCheck)
.leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId))
.leftJoin(resources, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targetHealthCheck.siteId, sites.siteId))
.where(whereClause)
.orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`)
.limit(limit)
@@ -129,6 +140,9 @@ export async function listHealthChecks(
healthChecks: list.map((row) => ({
targetHealthCheckId: row.targetHealthCheckId,
name: row.name ?? "",
siteId: row.siteId ?? null,
siteName: row.siteName ?? null,
siteNiceId: row.siteNiceId ?? null,
hcEnabled: row.hcEnabled,
hcHealth: (row.hcHealth ?? "unknown") as
| "unknown"

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, targetHealthCheck } from "@server/db";
import { db, targetHealthCheck, newts, sites } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -21,6 +21,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
const paramsSchema = z
.object({
@@ -34,6 +35,7 @@ const paramsSchema = z
const bodySchema = z.strictObject({
name: z.string().nonempty().optional(),
siteId: z.number().int().positive().optional(),
hcEnabled: z.boolean().optional(),
hcMode: z.string().optional(),
hcHostname: z.string().optional(),
@@ -55,6 +57,7 @@ const bodySchema = z.strictObject({
export type UpdateHealthCheckResponse = {
targetHealthCheckId: number;
name: string | null;
siteId: number | null;
hcEnabled: boolean;
hcHealth: string | null;
hcMode: string | null;
@@ -125,10 +128,7 @@ export async function updateHealthCheck(
.from(targetHealthCheck)
.where(
and(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheckId
),
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
eq(targetHealthCheck.orgId, orgId),
isNull(targetHealthCheck.targetId)
)
@@ -145,6 +145,7 @@ export async function updateHealthCheck(
const {
name,
siteId,
hcEnabled,
hcMode,
hcHostname,
@@ -166,6 +167,7 @@ export async function updateHealthCheck(
const updateData: Record<string, unknown> = {};
if (name !== undefined) updateData.name = name;
if (siteId !== undefined) updateData.siteId = siteId;
if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled;
if (hcMode !== undefined) updateData.hcMode = hcMode;
if (hcHostname !== undefined) updateData.hcHostname = hcHostname;
@@ -193,19 +195,28 @@ export async function updateHealthCheck(
.set(updateData)
.where(
and(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheckId
),
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
eq(targetHealthCheck.orgId, orgId),
isNull(targetHealthCheck.targetId)
)
)
.returning();
// Push updated health check to newt if the site is a newt site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, updated.siteId))
.limit(1);
if (newt) {
await addStandaloneHealthCheck(newt.newtId, updated, newt.version);
}
return response<UpdateHealthCheckResponse>(res, {
data: {
targetHealthCheckId: updated.targetHealthCheckId,
siteId: updated.siteId ?? null,
name: updated.name ?? null,
hcEnabled: updated.hcEnabled,
hcHealth: updated.hcHealth ?? null,

View File

@@ -14,6 +14,7 @@
import * as orgIdp from "#private/routers/orgIdp";
import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents";
import {
verifyApiKeyHasAction,
@@ -40,6 +41,27 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
export const unauthenticated = ua;
export const authenticated = a;
authenticated.post(
"/org/:orgId/site/:siteId/trigger-alert",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.triggerSiteAlert),
alertEvents.triggerSiteAlert
);
authenticated.post(
"/org/:orgId/resource/:resourceId/trigger-alert",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.triggerResourceAlert),
alertEvents.triggerResourceAlert
);
authenticated.post(
"/org/:orgId/health-check/:healthCheckId/trigger-alert",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.triggerHealthCheckAlert),
alertEvents.triggerHealthCheckAlert
);
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine

View File

@@ -27,7 +27,6 @@ import config from "@server/lib/config";
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import privateConfig from "#private/lib/config";
import { build } from "@server/build";
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
@@ -45,6 +44,7 @@ const bodySchema = z.strictObject({
autoProvision: z.boolean().optional(),
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"),
roleMapping: z.string().optional(),
orgMapping: z.string().nullish(),
tags: z.string().optional()
});
@@ -94,18 +94,6 @@ export async function createOrgOidcIdp(
);
}
if (
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
"org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
)
);
}
const {
clientId,
clientSecret,
@@ -118,6 +106,7 @@ export async function createOrgOidcIdp(
name,
variant,
roleMapping,
orgMapping: orgMappingBody,
tags
} = parsedBody.data;
@@ -169,7 +158,7 @@ export async function createOrgOidcIdp(
idpId: idpRes.idpId,
orgId: orgId,
roleMapping: roleMapping || null,
orgMapping: `'${orgId}'`
orgMapping: orgMappingBody
});
});

View File

@@ -22,7 +22,6 @@ import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig, idpOrg } from "@server/db";
import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import privateConfig from "#private/lib/config";
const paramsSchema = z
.object({
@@ -60,18 +59,6 @@ export async function deleteOrgIdp(
const { idpId } = parsedParams.data;
if (
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
"org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
)
);
}
// Check if IDP exists
const [existingIdp] = await db
.select()

View File

@@ -0,0 +1,211 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOrg, orgs, roles, userOrgs } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import { checkOrgAccessPolicy } from "#private/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
idpId: z.coerce.number<number>().int().positive()
});
const bodySchema = z.strictObject({
sourceOrgId: z.string().nonempty()
});
async function userIsOrgAdmin(
userId: string,
orgId: string,
session: Request["session"]
): Promise<boolean> {
const [userOrgRow] = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!userOrgRow) {
return false;
}
const policyCheck = await checkOrgAccessPolicy({
orgId,
userId,
session
});
if (!policyCheck.allowed || policyCheck.error) {
return false;
}
const roleIds = await getUserOrgRoleIds(userId, orgId);
if (roleIds.length === 0) {
return false;
}
const [adminRole] = await db
.select()
.from(roles)
.where(and(inArray(roles.roleId, roleIds), eq(roles.isAdmin, true)))
.limit(1);
return !!adminRole;
}
export async function importOrgIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId: targetOrgId, idpId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { sourceOrgId } = parsedBody.data;
if (sourceOrgId === targetOrgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Source and target organization must be different"
)
);
}
const userId = req.user!.userId;
const sourceLinked = await db
.select()
.from(idpOrg)
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, sourceOrgId)))
.limit(1);
if (sourceLinked.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"IdP not found for the source organization"
)
);
}
const sourceAdmin = await userIsOrgAdmin(
userId,
sourceOrgId,
req.session
);
if (!sourceAdmin) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You must be an organization admin in the source organization where this IdP is linked"
)
);
}
const [targetOrg] = await db
.select({ orgId: orgs.orgId })
.from(orgs)
.where(eq(orgs.orgId, targetOrgId))
.limit(1);
if (!targetOrg) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Target organization not found"
)
);
}
const [existingIdp] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId))
.limit(1);
if (!existingIdp) {
return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found"));
}
const alreadyTarget = await db
.select()
.from(idpOrg)
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, targetOrgId)))
.limit(1);
if (alreadyTarget.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"This IdP is already linked to the target organization"
)
);
}
await db.insert(idpOrg).values({
idpId,
orgId: targetOrgId,
roleMapping: null,
orgMapping: null
});
const redirectUrl = await generateOidcRedirectUrl(idpId, targetOrgId);
return response<CreateOrgIdpResponse>(res, {
data: {
idpId,
redirectUrl
},
success: true,
error: false,
message: "Org IdP imported successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -12,7 +12,11 @@
*/
export * from "./createOrgOidcIdp";
export * from "./importOrgIdp";
export * from "./getOrgIdp";
export * from "./listOrgIdps";
export * from "./listUserAdminOrgIdps";
export * from "./updateOrgOidcIdp";
export * from "./deleteOrgIdp";
export * from "./unassociateOrgIdp";
export * from "./requireOrgIdentityProviderMode";

View File

@@ -0,0 +1,160 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOidcConfig } from "@server/db";
import { idp, idpOrg, orgs, roles, userOrgRoles } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { and, eq, inArray, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
const querySchema = z.strictObject({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.int().nonnegative()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
});
const paramsSchema = z.strictObject({
userId: z.string().nonempty()
});
async function getOrgIdsWhereUserIsAdmin(userId: string): Promise<string[]> {
const rows = await db
.select({ orgId: userOrgRoles.orgId })
.from(userOrgRoles)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(and(eq(userOrgRoles.userId, userId), eq(roles.isAdmin, true)));
return [...new Set(rows.map((r) => r.orgId))];
}
async function queryIdpsForOrgs(
orgIds: string[],
limit: number,
offset: number
) {
return db
.select({
idpId: idp.idpId,
orgId: idpOrg.orgId,
orgName: orgs.name,
name: idp.name,
type: idp.type,
variant: idpOidcConfig.variant,
tags: idp.tags
})
.from(idpOrg)
.where(inArray(idpOrg.orgId, orgIds))
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId))
.innerJoin(idp, eq(idp.idpId, idpOrg.idpId))
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId))
.orderBy(sql`idp.name DESC`)
.limit(limit)
.offset(offset);
}
async function countIdpsForOrgs(orgIds: string[]) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(idpOrg)
.innerJoin(idp, eq(idp.idpId, idpOrg.idpId))
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId))
.where(inArray(idpOrg.orgId, orgIds));
return count;
}
export async function listUserAdminOrgIdps(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { userId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const adminOrgIds = await getOrgIdsWhereUserIsAdmin(userId);
if (adminOrgIds.length === 0) {
return response<ListUserAdminOrgIdpsResponse>(res, {
data: {
idps: [],
pagination: {
total: 0,
limit,
offset
}
},
success: true,
error: false,
message: "Org Idps retrieved successfully",
status: HttpCode.OK
});
}
const list = await queryIdpsForOrgs(adminOrgIds, limit, offset);
const total = await countIdpsForOrgs(adminOrgIds);
return response<ListUserAdminOrgIdpsResponse>(res, {
data: {
idps: list,
pagination: {
total,
limit,
offset
}
},
success: true,
error: false,
message: "Org Idps retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,34 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import privateConfig from "#private/lib/config";
import HttpCode from "@server/types/HttpCode";
export function requireOrgIdentityProviderMode(
_req: Request,
_res: Response,
next: NextFunction
): void {
if (privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
)
);
}
return next();
}

View File

@@ -0,0 +1,96 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOrg } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq, sql } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
orgId: z.string().nonempty(),
idpId: z.coerce.number<number>().int().positive()
})
.strict();
export async function unassociateOrgIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, idpId } = parsedParams.data;
const [association] = await db
.select()
.from(idpOrg)
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)))
.limit(1);
if (!association) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`IdP with ID ${idpId} is not associated with organization ${orgId}`
)
);
}
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(idpOrg)
.where(eq(idpOrg.idpId, idpId));
if (count <= 1) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"This is the last organization associated with this identity provider. Delete it instead."
)
);
}
await db
.delete(idpOrg)
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Org IdP unassociated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -26,7 +26,6 @@ import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import privateConfig from "#private/lib/config";
import { build } from "@server/build";
const paramsSchema = z
@@ -48,6 +47,7 @@ const bodySchema = z.strictObject({
scopes: z.string().optional(),
autoProvision: z.boolean().optional(),
roleMapping: z.string().optional(),
orgMapping: z.string().nullish(),
tags: z.string().optional()
});
@@ -99,18 +99,6 @@ export async function updateOrgOidcIdp(
);
}
if (
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
"org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
)
);
}
const { idpId, orgId } = parsedParams.data;
const {
clientId,
@@ -123,6 +111,7 @@ export async function updateOrgOidcIdp(
namePath,
name,
roleMapping,
orgMapping,
tags
} = parsedBody.data;
@@ -218,13 +207,20 @@ export async function updateOrgOidcIdp(
.where(eq(idpOidcConfig.idpId, idpId));
}
const idpOrgPolicyPatch: {
roleMapping?: string;
orgMapping?: string | null;
} = {};
if (roleMapping !== undefined) {
// Update IdP-org policy
idpOrgPolicyPatch.roleMapping = roleMapping;
}
if (orgMapping !== undefined) {
idpOrgPolicyPatch.orgMapping = orgMapping;
}
if (Object.keys(idpOrgPolicyPatch).length > 0) {
await trx
.update(idpOrg)
.set({
roleMapping
})
.set(idpOrgPolicyPatch)
.where(
and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))
);

View File

@@ -103,7 +103,8 @@ export async function listDomains(
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(domains);
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
return response<ListDomainsResponse>(res, {
data: {

View File

@@ -2,6 +2,9 @@ export type ListHealthChecksResponse = {
healthChecks: {
targetHealthCheckId: number;
name: string;
siteId: number | null;
siteName: string | null;
siteNiceId: string | null;
hcEnabled: boolean;
hcHealth: "unknown" | "healthy" | "unhealthy";
hcMode: string | null;

View File

@@ -86,7 +86,8 @@ export async function buildClientConfigurationForNewtClient(
// )
// );
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
if (!client.clientSitesAssociationsCache.isJitMode) {
// if we are adding sites through jit then dont add the site to the olm
// update the peer info on the olm
// if the peer has not been added yet this will be a no-op
await updatePeer(client.clients.clientId, {
@@ -189,7 +190,10 @@ export async function buildClientConfigurationForNewtClient(
};
}
export async function buildTargetConfigurationForNewtClient(siteId: number) {
export async function buildTargetConfigurationForNewtClient(
siteId: number,
version?: string | null
) {
// Get all enabled targets with their resource protocol information
const allTargets = await db
.select({
@@ -200,8 +204,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled,
protocol: resources.protocol,
hcId: targetHealthCheck.targetHealthCheckId,
protocol: resources.protocol
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
const allHealthChecks = await db
.select({
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
hcEnabled: targetHealthCheck.hcEnabled,
hcPath: targetHealthCheck.hcPath,
hcScheme: targetHealthCheck.hcScheme,
@@ -219,13 +230,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.leftJoin(
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
.from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId));
const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => {
@@ -249,7 +255,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
);
const healthCheckTargets = allTargets.map((target) => {
const healthCheckTargets = allHealthChecks.map((target) => {
// make sure the stuff is defined
const isTCP = target.hcMode?.toLowerCase() === "tcp";
if (!target.hcHostname || !target.hcPort || !target.hcInterval) {
@@ -273,8 +279,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
}
return {
id: target.targetId,
hcId: target.hcId,
id: target.targetHealthCheckId,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath,
hcScheme: target.hcScheme,

View File

@@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
}
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(siteId);
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`

View File

@@ -83,8 +83,7 @@ export async function addTargets(
}
return {
id: target.targetId,
hcId: hc.targetHealthCheckId,
id: hc.targetHealthCheckId,
hcEnabled: hc.hcEnabled,
hcPath: hc.hcPath,
hcScheme: hc.hcScheme,
@@ -121,6 +120,96 @@ export async function addTargets(
);
}
export async function addStandaloneHealthCheck(
newtId: string,
healthCheck: TargetHealthCheck,
version?: string | null
) {
const isTCP = healthCheck.hcMode?.toLowerCase() === "tcp";
if (
!healthCheck.hcHostname ||
!healthCheck.hcPort ||
!healthCheck.hcInterval
) {
logger.debug(
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing fields`
);
return;
}
if (!isTCP && (!healthCheck.hcPath || !healthCheck.hcMethod)) {
logger.debug(
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing HTTP health check fields`
);
return;
}
const hcHeadersParse = healthCheck.hcHeaders
? JSON.parse(healthCheck.hcHeaders)
: null;
const hcHeadersSend: { [key: string]: string } = {};
if (hcHeadersParse) {
hcHeadersParse.forEach((header: { name: string; value: string }) => {
hcHeadersSend[header.name] = header.value;
});
}
let hcStatus: number | undefined = undefined;
if (healthCheck.hcStatus) {
const parsedStatus = parseInt(healthCheck.hcStatus.toString());
if (!isNaN(parsedStatus)) {
hcStatus = parsedStatus;
}
}
await sendToClient(
newtId,
{
type: `newt/healthcheck/add`,
data: {
targets: [
{
id: healthCheck.targetHealthCheckId,
hcEnabled: healthCheck.hcEnabled,
hcPath: healthCheck.hcPath,
hcScheme: healthCheck.hcScheme,
hcMode: healthCheck.hcMode,
hcHostname: healthCheck.hcHostname,
hcPort: healthCheck.hcPort,
hcInterval: healthCheck.hcInterval,
hcUnhealthyInterval: healthCheck.hcUnhealthyInterval,
hcTimeout: healthCheck.hcTimeout,
hcHeaders: hcHeadersSend,
hcFollowRedirects: healthCheck.hcFollowRedirects,
hcMethod: healthCheck.hcMethod,
hcStatus: hcStatus,
hcTlsServerName: healthCheck.hcTlsServerName,
hcHealthyThreshold: healthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: healthCheck.hcUnhealthyThreshold
}
]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeStandaloneHealthCheck(
newtId: string,
healthCheckId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: `newt/healthcheck/remove`,
data: {
ids: [healthCheckId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeTargets(
newtId: string,
targets: Target[],

View File

@@ -25,3 +25,22 @@ export type ListOrgIdpsResponse = {
offset: number;
};
};
export type ListUserAdminOrgIdpsEntry = {
idpId: number;
orgId: string;
orgName: string;
name: string;
type: string;
variant: string;
tags: string | null;
};
export type ListUserAdminOrgIdpsResponse = {
idps: ListUserAdminOrgIdpsEntry[];
pagination: {
total: number;
limit: number;
offset: number;
};
};

View File

@@ -230,6 +230,8 @@ export async function createTarget(
.values({
orgId: resource.orgId,
targetId: newTarget[0].targetId,
siteId: targetData.siteId,
name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,

View File

@@ -99,19 +99,19 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
name: targetHealthCheck.name,
hcStatus: targetHealthCheck.hcHealth
})
.from(targets)
.from(targetHealthCheck)
.innerJoin(
targets,
eq(targetHealthCheck.targetId, targets.targetId)
)
.innerJoin(
resources,
eq(targets.resourceId, resources.resourceId)
)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.innerJoin(
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(
and(
eq(targets.targetId, targetIdNum),
eq(targetHealthCheck.targetHealthCheckId, targetIdNum),
eq(sites.siteId, newt.siteId)
)
)
@@ -142,13 +142,21 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
| "healthy"
| "unhealthy"
})
.where(eq(targetHealthCheck.targetId, targetIdNum));
.where(eq(targetHealthCheck.targetId, targetCheck.targetId));
const orgId = targetCheck.orgId || targetCheck.resourceOrgId; // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
if (!orgId) {
logger.warn(
`No org ID found for target ${targetId}, skipping status history logging`
);
continue;
}
// Log the state change to status history
await db.insert(statusHistory).values({
entityType: "healthCheck",
entityId: targetCheck.targetHealthCheckId,
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
orgId: orgId,
status: healthStatus.status,
timestamp: Math.floor(Date.now() / 1000)
});
@@ -170,7 +178,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
.where(
and(
eq(targets.resourceId, targetCheck.resourceId),
eq(targets.targetId, targetIdNum) // only check the other targets, not the one we just updated
eq(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated
)
);
@@ -188,7 +196,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
await db.insert(statusHistory).values({
entityType: "resource",
entityId: targetCheck.resourceId,
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
orgId: orgId,
status: status,
timestamp: Math.floor(Date.now() / 1000)
});
@@ -197,13 +205,13 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
// because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") {
await fireHealthCheckHealthyAlert(
targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
orgId,
targetCheck.targetHealthCheckId,
targetCheck.name
);
} else if (healthStatus.status === "healthy") {
await fireHealthCheckNotHealthyAlert(
targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
orgId,
targetCheck.targetHealthCheckId,
targetCheck.name
);

View File

@@ -228,6 +228,7 @@ export async function updateTarget(
const [updatedHc] = await db
.update(targetHealthCheck)
.set({
siteId: parsedBody.data.siteId,
hcEnabled: parsedBody.data.hcEnabled || false,
hcPath: parsedBody.data.hcPath,
hcScheme: parsedBody.data.hcScheme,

View File

@@ -0,0 +1,106 @@
import { db, apiKeys } from "@server/db";
import { eq } from "drizzle-orm";
import { generateRandomString, RandomReader } from "@oslojs/crypto/random";
import moment from "moment";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
const random: RandomReader = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
}
};
function validateApiKeyId(id: string): boolean {
return /^[a-z0-9]{15}$/.test(id);
}
function validateApiKeySecret(secret: string): boolean {
return secret.length > 0;
}
function showRootApiKey(apiKeyId: string, source: string): void {
console.log(`=== ROOT API KEY ${source} ===`);
console.log("API Key ID:", apiKeyId);
console.log(
"The root API key from PANGOLIN_ROOT_API_KEY has been applied."
);
console.log("Use the full key value (apiKeyId.apiKeySecret) in requests.");
console.log("================================");
}
export async function ensureRootApiKey() {
try {
const envApiKey = process.env.PANGOLIN_ROOT_API_KEY;
if (!envApiKey) {
// logger.debug(
// "PANGOLIN_ROOT_API_KEY not set. Root API key from environment skipped."
// );
return;
}
const parts = envApiKey.split(".");
if (parts.length !== 2) {
throw new Error(
"Invalid format for PANGOLIN_ROOT_API_KEY. Expected format: {apiKeyId}.{apiKeySecret}"
);
}
const [apiKeyId, apiKeySecret] = parts;
if (!validateApiKeyId(apiKeyId)) {
throw new Error(
"Invalid apiKeyId in PANGOLIN_ROOT_API_KEY. Must be 15 lowercase alphanumeric characters."
);
}
if (!validateApiKeySecret(apiKeySecret)) {
throw new Error(
"Invalid apiKeySecret in PANGOLIN_ROOT_API_KEY. Secret must not be empty."
);
}
const apiKeyHash = await hashPassword(apiKeySecret);
const lastChars = apiKeySecret.slice(-4);
const createdAt = moment().toISOString();
const [existingKey] = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.apiKeyId, apiKeyId));
if (existingKey) {
if (!existingKey.isRoot) {
console.warn(
`API key with ID ${apiKeyId} exists but is not a root key. Promoting to root and updating hash.`
);
} else {
console.warn(
`Overwriting existing root API key hash since PANGOLIN_ROOT_API_KEY is set (apiKeyId: ${apiKeyId})`
);
}
await db
.update(apiKeys)
.set({ apiKeyHash, lastChars, isRoot: true })
.where(eq(apiKeys.apiKeyId, apiKeyId));
showRootApiKey(apiKeyId, "UPDATED FROM ENVIRONMENT");
} else {
await db.insert(apiKeys).values({
apiKeyId,
name: "Root API Key (Environment)",
apiKeyHash,
lastChars,
createdAt,
isRoot: true
});
showRootApiKey(apiKeyId, "CREATED FROM ENVIRONMENT");
}
} catch (error) {
console.error("Failed to ensure root API key:", error);
throw error;
}
}

View File

@@ -2,10 +2,12 @@ import { ensureActions } from "./ensureActions";
import { copyInConfig } from "./copyInConfig";
import { clearStaleData } from "./clearStaleData";
import { ensureSetupToken } from "./ensureSetupToken";
import { ensureRootApiKey } from "./ensureRootApiKey";
export async function runSetupFunctions() {
await copyInConfig(); // copy in the config to the db as needed
await ensureActions(); // make sure all of the actions are in the db and the roles
await clearStaleData();
await ensureSetupToken(); // ensure setup token exists for initial setup
await ensureRootApiKey();
}