rhombus-node-mcp
Version:
MCP server for Rhombus API
106 lines (105 loc) • 5.54 kB
JavaScript
import { buildMediaHints } from "./badge-correlation.js";
import { searchElementsEvents } from "./elements-tool-api.js";
const ALL_RULES = [
"lost_or_inactive_badge",
"entry_not_made",
"off_hours",
"impossible_travel",
"area_novelty",
];
const DAY_MS = 86_400_000;
/**
* Flags anomalous Honeywell Elements access events over a window: deterministic rules (lost/inactive badge,
* no-entry, off-hours, impossible travel, first-time-in-area vs a baseline) computed over searchElementsEvents,
* each finding carrying the still/clip hints to confirm it. The agent narrates/triages from here.
*/
export async function getElementsAccessAnomalies(args, timeZone, requestModifiers, sessionId) {
const rules = new Set(args.rules && args.rules.length ? args.rules : ALL_RULES);
const offStart = args.offHoursStartHour ?? 7;
const offEnd = args.offHoursEndHour ?? 19;
const maxTravelSec = args.impossibleTravelMaxSeconds ?? 30;
const baselineDays = args.baselineDays ?? 30;
const scope = { area: args.area, locationUuids: args.locationUuids, deviceUuids: args.deviceUuids };
const { events } = await searchElementsEvents({ ...scope, afterMs: args.afterMs, beforeMs: args.beforeMs, limit: args.limit ?? 500 }, timeZone, requestModifiers, sessionId);
// Baseline area-set per cardholder (for area_novelty).
const baselineAreas = new Map();
if (rules.has("area_novelty") && baselineDays > 0 && args.afterMs != null) {
const { events: baseEvents } = await searchElementsEvents({ ...scope, afterMs: args.afterMs - baselineDays * DAY_MS, beforeMs: args.afterMs, limit: 1000 }, timeZone, requestModifiers, sessionId);
for (const e of baseEvents) {
if (!e.cardholderName || !e.areaEntering)
continue;
const set = baselineAreas.get(e.cardholderName) ?? new Set();
set.add(e.areaEntering);
baselineAreas.set(e.cardholderName, set);
}
}
const hourFmt = new Intl.DateTimeFormat("en-US", { timeZone, hour: "2-digit", hourCycle: "h23" });
const localHour = (ms) => parseInt(hourFmt.format(new Date(ms)), 10);
const findings = [];
const seen = new Set();
const add = (e, rule, severity, rationale) => {
const key = `${e.cardholderName ?? ""}|${rule}|${e.timestampMs ?? ""}|${e.areaEntering ?? ""}`;
if (seen.has(key))
return;
seen.add(key);
const { clipHint, stillHint } = buildMediaHints({ deviceUuid: e.deviceUuid, timestampMs: e.timestampMs });
findings.push({
cardholderName: e.cardholderName,
rule,
severity,
datetime: e.datetime,
timestampMs: e.timestampMs,
deviceUuid: e.deviceUuid,
area: e.areaEntering ?? e.areaExiting,
rationale,
clipHint,
stillHint,
});
};
for (const e of events) {
const at = e.areaEntering ? ` at "${e.areaEntering}"` : "";
if (rules.has("lost_or_inactive_badge") && e.badgeStatus && e.badgeStatus.toLowerCase() !== "active") {
add(e, "lost_or_inactive_badge", "high", `Badge status "${e.badgeStatus}" used${at}.`);
}
if (rules.has("entry_not_made") && e.entryMade === false) {
add(e, "entry_not_made", "medium", `Access granted but no entry made${at} — possible tailgating or aborted entry.`);
}
if (rules.has("off_hours") && e.timestampMs != null) {
const h = localHour(e.timestampMs);
if (h < offStart || h >= offEnd) {
add(e, "off_hours", "medium", `Entry ${e.datetime ?? `${h}:00`} is outside business hours (${offStart}:00–${offEnd}:00)${at}.`);
}
}
if (rules.has("area_novelty") && baselineAreas.size > 0 && e.cardholderName && e.areaEntering) {
const base = baselineAreas.get(e.cardholderName);
if (base && !base.has(e.areaEntering)) {
add(e, "area_novelty", "high", `First entry to "${e.areaEntering}" — not in ${e.cardholderName}'s prior ${baselineDays}-day pattern.`);
}
}
}
// Impossible travel: per cardholder, consecutive taps in different areas within the window.
if (rules.has("impossible_travel")) {
const byPerson = new Map();
for (const e of events) {
if (!e.cardholderName || e.timestampMs == null)
continue;
const arr = byPerson.get(e.cardholderName) ?? [];
arr.push(e);
byPerson.set(e.cardholderName, arr);
}
for (const [name, evs] of byPerson) {
const sorted = evs.slice().sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0));
for (let i = 1; i < sorted.length; i++) {
const a = sorted[i - 1];
const b = sorted[i];
const gapSec = ((b.timestampMs ?? 0) - (a.timestampMs ?? 0)) / 1000;
if (a.areaEntering && b.areaEntering && a.areaEntering !== b.areaEntering && gapSec <= maxTravelSec) {
add(b, "impossible_travel", "high", `${name} at "${a.areaEntering}" then "${b.areaEntering}" ${Math.round(gapSec)}s apart — physically implausible for one person.`);
}
}
}
}
const sev = (s) => (s === "high" ? 0 : 1);
findings.sort((a, b) => sev(a.severity) - sev(b.severity) || (b.timestampMs ?? 0) - (a.timestampMs ?? 0));
return { findings, eventsAnalyzed: events.length };
}