Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/release-dry-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Lowercase repository name
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

Expand All @@ -105,8 +109,8 @@ jobs:
file: ./backend/Dockerfile
push: false
tags: |
ghcr.io/${{ github.repository }}/backend:${{ needs.validate-version.outputs.version }}
ghcr.io/${{ github.repository }}/backend:dryrun
ghcr.io/${{ steps.repo.outputs.name }}/backend:${{ needs.validate-version.outputs.version }}
ghcr.io/${{ steps.repo.outputs.name }}/backend:dryrun

- name: Build frontend image (dry-run)
uses: docker/build-push-action@v5
Expand All @@ -115,8 +119,8 @@ jobs:
file: ./frontend/Dockerfile
push: false
tags: |
ghcr.io/${{ github.repository }}/frontend:${{ needs.validate-version.outputs.version }}
ghcr.io/${{ github.repository }}/frontend:dryrun
ghcr.io/${{ steps.repo.outputs.name }}/frontend:${{ needs.validate-version.outputs.version }}
ghcr.io/${{ steps.repo.outputs.name }}/frontend:dryrun
continue-on-error: true

- name: Report Docker build status
Expand Down
177 changes: 177 additions & 0 deletions backend/src/api/routes/alertRoutingAdmin.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { FastifyInstance } from "fastify";
import { authMiddleware } from "../middleware/auth.js";
import { alertRoutingService } from "../../services/alertRouting.service.js";
import type { RoutingSeverity } from "../../services/alertRouting.service.js";
import {
createAlertRoutingRuleSchema,
listAlertRoutingAuditQuerySchema,
listAlertRoutingRulesQuerySchema,
updateAlertRoutingRuleSchema,
} from "../validations/alertRouting.schema.js";

const VALID_SEVERITIES = new Set<string>(["critical", "high", "medium", "low"]);

function parseSeverity(value: string): RoutingSeverity {
if (VALID_SEVERITIES.has(value)) return value as RoutingSeverity;
return "medium";
}

export async function alertRoutingAdminRoutes(server: FastifyInstance) {
const requireAdmin = authMiddleware({ requiredScopes: ["admin:api-keys"] });

Expand Down Expand Up @@ -205,4 +213,173 @@ export async function alertRoutingAdminRoutes(server: FastifyInstance) {
return { entries };
}
);

server.post(
"/simulate",
{
preHandler: requireAdmin,
schema: {
tags: ["Config"],
summary: "Dry-run alert routing simulation (no dispatches)",
description:
"Evaluates which active routing rules would match a simulated alert and which channels would fire, without dispatching anything to real endpoints.",
security: [{ ApiKeyAuth: [] }],
body: {
type: "object",
required: ["severity"],
additionalProperties: false,
properties: {
severity: {
type: "string",
enum: ["critical", "high", "medium", "low"],
},
assetCode: { type: "string", maxLength: 20 },
sourceType: { type: "string", maxLength: 80 },
ownerAddress: { type: "string" },
label: { type: "string", maxLength: 120 },
triggeredValue: { type: "number" },
threshold: { type: "number" },
metric: { type: "string", maxLength: 80 },
},
},
},
},
async (request, reply) => {
const body = request.body as {
severity: string;
assetCode?: string;
sourceType?: string;
ownerAddress?: string;
label?: string;
triggeredValue?: number;
threshold?: number;
metric?: string;
};

if (!VALID_SEVERITIES.has(body.severity)) {
return reply.code(400).send({ error: "Invalid severity value" });
}

const severity = parseSeverity(body.severity);
const assetCode = (body.assetCode ?? "").trim().toUpperCase();
const sourceType = (body.sourceType ?? "").trim();

// Load rules — scope by ownerAddress if provided
const allRules = await alertRoutingService.listRules(body.ownerAddress);
const activeRules = allRules.filter((rule) => rule.isActive);
const inactiveRules = allRules.filter((rule) => !rule.isActive);

// Evaluate each active rule in priority order
const ruleResults = activeRules
.slice()
.sort((a, b) => a.priorityOrder - b.priorityOrder)
.map((rule) => {
const severityMatch =
rule.severityLevels.length === 0 ||
rule.severityLevels.includes(severity);

const assetMatch =
rule.assetCodes.length === 0 ||
(assetCode !== "" &&
rule.assetCodes
.map((c) => c.toUpperCase())
.includes(assetCode));

const sourceMatch =
rule.sourceTypes.length === 0 ||
(sourceType !== "" && rule.sourceTypes.includes(sourceType));

const matched = severityMatch && assetMatch && sourceMatch;

const reasons: string[] = [];

if (rule.severityLevels.length === 0) {
reasons.push("Severity: matches any (no filter set)");
} else if (severityMatch) {
reasons.push(
`Severity: "${severity}" is in [${rule.severityLevels.join(", ")}]`
);
} else {
reasons.push(
`Severity: "${severity}" not in [${rule.severityLevels.join(", ")}] — no match`
);
}

if (rule.assetCodes.length === 0) {
reasons.push("Asset: matches any (no filter set)");
} else if (assetMatch) {
reasons.push(
`Asset: "${assetCode}" is in [${rule.assetCodes.join(", ")}]`
);
} else {
reasons.push(
`Asset: "${assetCode || "(empty)"}" not in [${rule.assetCodes.join(", ")}] — no match`
);
}

if (rule.sourceTypes.length === 0) {
reasons.push("Source type: matches any (no filter set)");
} else if (sourceMatch) {
reasons.push(
`Source type: "${sourceType}" is in [${rule.sourceTypes.join(", ")}]`
);
} else {
reasons.push(
`Source type: "${sourceType || "(empty)"}" not in [${rule.sourceTypes.join(", ")}] — no match`
);
}

return {
ruleId: rule.id,
ruleName: rule.name,
priorityOrder: rule.priorityOrder,
ownerAddress: rule.ownerAddress,
matched,
reasons,
channels: matched ? rule.channels : [],
fallbackChannels: matched ? rule.fallbackChannels : [],
suppressionWindowSeconds: rule.suppressionWindowSeconds,
};
});

const matchedResults = ruleResults.filter((r) => r.matched);
const firstMatch = matchedResults[0] ?? null;

const simulationId = `sim_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 8)}`;

return reply.send({
simulationId,
timestamp: new Date().toISOString(),
input: {
severity,
assetCode,
sourceType,
ownerAddress: body.ownerAddress ?? null,
label: body.label ?? null,
triggeredValue: body.triggeredValue ?? null,
threshold: body.threshold ?? null,
metric: body.metric ?? null,
},
results: ruleResults,
skippedInactive: inactiveRules.map((r) => ({
ruleId: r.id,
ruleName: r.name,
priorityOrder: r.priorityOrder,
})),
summary: {
totalActiveRules: activeRules.length,
totalMatched: matchedResults.length,
firstMatchingRule: firstMatch
? { ruleId: firstMatch.ruleId, ruleName: firstMatch.ruleName }
: null,
wouldDispatch: firstMatch !== null,
effectiveChannels: firstMatch?.channels ?? [],
effectiveFallbackChannels: firstMatch?.fallbackChannels ?? [],
suppressionWindowSeconds: firstMatch?.suppressionWindowSeconds ?? 0,
},
});
}
);
}
143 changes: 143 additions & 0 deletions backend/src/api/routes/anomalyDetection.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { FastifyInstance } from "fastify";
import { anomalyDetectionService } from "../../services/anomalyDetection.service.js";
import type { AnomalySeverity } from "../../database/models/anomaly.model.js";

export async function anomalyDetectionRoutes(server: FastifyInstance) {
server.get<{
Querystring: {
assetCode?: string;
bridgeName?: string;
severity?: AnomalySeverity;
includeSuppressed?: string;
limit?: string;
};
}>(
"/events",
{
schema: {
tags: ["Anomaly Detection"],
summary: "List recent anomaly detections",
querystring: {
type: "object",
properties: {
assetCode: { type: "string" },
bridgeName: { type: "string" },
severity: { type: "string", enum: ["low", "medium", "high", "critical"] },
includeSuppressed: { type: "string", enum: ["true", "false"] },
limit: { type: "string" },
},
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request) => {
const events = await anomalyDetectionService.getRecentEvents({
assetCode: request.query.assetCode,
bridgeName: request.query.bridgeName,
severity: request.query.severity,
includeSuppressed: request.query.includeSuppressed === "true",
limit: request.query.limit ? Number(request.query.limit) : undefined,
});

return { events, count: events.length };
}
);

server.post<{
Body: { assetCode: string; bridgeName?: string };
}>(
"/evaluate",
{
schema: {
tags: ["Anomaly Detection"],
summary: "Run anomaly detection for a single asset",
body: {
type: "object",
required: ["assetCode"],
properties: {
assetCode: { type: "string" },
bridgeName: { type: "string" },
},
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request) => anomalyDetectionService.evaluateAsset(request.body.assetCode, request.body.bridgeName)
);

server.get(
"/thresholds",
{
schema: {
tags: ["Anomaly Detection"],
summary: "List anomaly detection thresholds",
response: { 200: { type: "object", additionalProperties: true } },
},
},
async () => {
const thresholds = await anomalyDetectionService.getThresholds();
return { thresholds, count: thresholds.length };
}
);

server.put<{
Body: {
assetCode?: string;
bridgeName?: string;
priceChangePct: number;
liquidityChangePct: number;
supplyMismatchPct: number;
healthScoreDrop: number;
minSignalCount: number;
duplicateWindowSeconds: number;
isActive?: boolean;
};
}>(
"/thresholds",
{
schema: {
tags: ["Anomaly Detection"],
summary: "Create or update anomaly detection thresholds",
body: {
type: "object",
required: [
"priceChangePct",
"liquidityChangePct",
"supplyMismatchPct",
"healthScoreDrop",
"minSignalCount",
"duplicateWindowSeconds",
],
properties: {
assetCode: { type: "string", default: "*" },
bridgeName: { type: "string", default: "*" },
priceChangePct: { type: "number", minimum: 0 },
liquidityChangePct: { type: "number", minimum: 0 },
supplyMismatchPct: { type: "number", minimum: 0 },
healthScoreDrop: { type: "number", minimum: 0 },
minSignalCount: { type: "integer", minimum: 1 },
duplicateWindowSeconds: { type: "integer", minimum: 1 },
isActive: { type: "boolean", default: true },
},
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request) => {
const body = request.body;
const threshold = await anomalyDetectionService.upsertThreshold({
asset_code: body.assetCode ?? "*",
bridge_name: body.bridgeName ?? "*",
price_change_pct: body.priceChangePct,
liquidity_change_pct: body.liquidityChangePct,
supply_mismatch_pct: body.supplyMismatchPct,
health_score_drop: body.healthScoreDrop,
min_signal_count: body.minSignalCount,
duplicate_window_seconds: body.duplicateWindowSeconds,
is_active: body.isActive ?? true,
});

return { threshold };
}
);
}
6 changes: 6 additions & 0 deletions backend/src/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import { freshnessRoutes } from "./freshness.js";
import { providerAllowlistRoutes } from "./providerAllowlist.routes.js";
import { providerAllowlistAdminRoutes } from "./providerAllowlistAdmin.routes.js";
import { provenanceRoutes } from "./provenance.routes.js";
import { operationalAccessAuditRoutes } from "./operationalAccessAudit.js";
import { anomalyDetectionRoutes } from "./anomalyDetection.routes.js";

export async function registerRoutes(server: FastifyInstance) {
server.register(assetsRoutes, { prefix: "/api/v1/assets" });
Expand Down Expand Up @@ -162,4 +164,8 @@ export async function registerRoutes(server: FastifyInstance) {
prefix: "/api/v1/admin/providers/allowlist",
});
server.register(provenanceRoutes, { prefix: "/api/v1/provenance" });
server.register(operationalAccessAuditRoutes, {
prefix: "/api/v1/admin/access-audit",
});
server.register(anomalyDetectionRoutes, { prefix: "/api/v1/anomaly-detection" });
}
Loading