997 lines
34 KiB
TypeScript
997 lines
34 KiB
TypeScript
// ci: trigger convex functions deploy
|
|
import { mutation, query } from "./_generated/server"
|
|
import { api } from "./_generated/api"
|
|
import { ConvexError, v } from "convex/values"
|
|
import { sha256 } from "@noble/hashes/sha256"
|
|
import { randomBytes } from "@noble/hashes/utils"
|
|
import type { Doc, Id } from "./_generated/dataModel"
|
|
import type { MutationCtx } from "./_generated/server"
|
|
|
|
const DEFAULT_TENANT_ID = "tenant-atlas"
|
|
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
|
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
|
|
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
|
|
|
|
type NormalizedIdentifiers = {
|
|
macs: string[]
|
|
serials: string[]
|
|
}
|
|
|
|
function getTokenTtlMs(): number {
|
|
const raw = process.env["MACHINE_TOKEN_TTL_MS"]
|
|
if (!raw) return DEFAULT_TOKEN_TTL_MS
|
|
const parsed = Number(raw)
|
|
if (!Number.isFinite(parsed) || parsed < 60_000) {
|
|
return DEFAULT_TOKEN_TTL_MS
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function getOfflineThresholdMs(): number {
|
|
const raw = process.env["MACHINE_OFFLINE_THRESHOLD_MS"]
|
|
if (!raw) return DEFAULT_OFFLINE_THRESHOLD_MS
|
|
const parsed = Number(raw)
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return DEFAULT_OFFLINE_THRESHOLD_MS
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function getStaleThresholdMs(offlineMs: number): number {
|
|
const raw = process.env["MACHINE_STALE_THRESHOLD_MS"]
|
|
if (!raw) return offlineMs * 12
|
|
const parsed = Number(raw)
|
|
if (!Number.isFinite(parsed) || parsed <= offlineMs) {
|
|
return offlineMs * 12
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]): NormalizedIdentifiers {
|
|
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
|
|
const normalizeSerial = (value: string) => value.trim().toLowerCase()
|
|
|
|
const macs = Array.from(new Set(macAddresses.map(normalizeMac).filter(Boolean))).sort()
|
|
const serials = Array.from(new Set(serialNumbers.map(normalizeSerial).filter(Boolean))).sort()
|
|
|
|
if (macs.length === 0 && serials.length === 0) {
|
|
throw new ConvexError("Informe ao menos um identificador (MAC ou serial)")
|
|
}
|
|
|
|
return { macs, serials }
|
|
}
|
|
|
|
function toHex(input: Uint8Array) {
|
|
return Array.from(input)
|
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
}
|
|
|
|
function computeFingerprint(tenantId: string, companySlug: string | undefined, hostname: string, ids: NormalizedIdentifiers) {
|
|
const payload = JSON.stringify({
|
|
tenantId,
|
|
companySlug: companySlug ?? null,
|
|
hostname: hostname.trim().toLowerCase(),
|
|
macs: ids.macs,
|
|
serials: ids.serials,
|
|
})
|
|
return toHex(sha256(payload))
|
|
}
|
|
|
|
function extractCollaboratorEmail(metadata: unknown): string | null {
|
|
if (!metadata || typeof metadata !== "object") return null
|
|
const record = metadata as Record<string, unknown>
|
|
const collaborator = record["collaborator"]
|
|
if (!collaborator || typeof collaborator !== "object") return null
|
|
const email = (collaborator as { email?: unknown }).email
|
|
if (typeof email !== "string") return null
|
|
const trimmed = email.trim().toLowerCase()
|
|
return trimmed || null
|
|
}
|
|
|
|
function matchesExistingHardware(existing: Doc<"machines">, identifiers: NormalizedIdentifiers, hostname: string): boolean {
|
|
const intersectsMac = existing.macAddresses.some((mac) => identifiers.macs.includes(mac))
|
|
const intersectsSerial = existing.serialNumbers.some((serial) => identifiers.serials.includes(serial))
|
|
const sameHostname = existing.hostname.trim().toLowerCase() === hostname.trim().toLowerCase()
|
|
return intersectsMac || intersectsSerial || sameHostname
|
|
}
|
|
|
|
function hashToken(token: string) {
|
|
return toHex(sha256(token))
|
|
}
|
|
|
|
async function getActiveToken(
|
|
ctx: MutationCtx,
|
|
tokenValue: string
|
|
): Promise<{ token: Doc<"machineTokens">; machine: Doc<"machines"> }> {
|
|
const tokenHash = hashToken(tokenValue)
|
|
const token = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_token_hash", (q: any) => q.eq("tokenHash", tokenHash))
|
|
.unique()
|
|
|
|
if (!token) {
|
|
throw new ConvexError("Token de máquina inválido")
|
|
}
|
|
if (token.revoked) {
|
|
throw new ConvexError("Token de máquina revogado")
|
|
}
|
|
if (token.expiresAt < Date.now()) {
|
|
throw new ConvexError("Token de máquina expirado")
|
|
}
|
|
|
|
const machine = await ctx.db.get(token.machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada para o token fornecido")
|
|
}
|
|
|
|
return { token, machine }
|
|
}
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
}
|
|
|
|
function mergeInventory(current: unknown, patch: unknown): unknown {
|
|
if (!isObject(patch)) {
|
|
return patch
|
|
}
|
|
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value === undefined) continue
|
|
if (isObject(value) && isObject(base[key])) {
|
|
base[key] = mergeInventory(base[key], value)
|
|
} else {
|
|
base[key] = value
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
function mergeMetadata(current: unknown, patch: Record<string, unknown>) {
|
|
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value === undefined) continue
|
|
if (key === "inventory") {
|
|
base[key] = mergeInventory(base[key], value)
|
|
} else if (isObject(value) && isObject(base[key])) {
|
|
base[key] = mergeInventory(base[key], value)
|
|
} else {
|
|
base[key] = value
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
type PostureFinding = {
|
|
kind: "CPU_HIGH" | "SERVICE_DOWN" | "SMART_FAIL"
|
|
message: string
|
|
severity: "warning" | "critical"
|
|
}
|
|
|
|
async function createTicketForAlert(
|
|
ctx: MutationCtx,
|
|
tenantId: string,
|
|
companyId: Id<"companies"> | undefined,
|
|
subject: string,
|
|
summary: string
|
|
) {
|
|
const actorEmail = process.env["MACHINE_ALERTS_TICKET_REQUESTER_EMAIL"] ?? "admin@sistema.dev"
|
|
const actor = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant_email", (q: any) => q.eq("tenantId", tenantId).eq("email", actorEmail))
|
|
.unique()
|
|
if (!actor) return null
|
|
|
|
// pick first category/subcategory if not configured
|
|
const category = await ctx.db.query("ticketCategories").withIndex("by_tenant", (q: any) => q.eq("tenantId", tenantId)).first()
|
|
if (!category) return null
|
|
const subcategory = await ctx.db
|
|
.query("ticketSubcategories")
|
|
.withIndex("by_category_order", (q: any) => q.eq("categoryId", category._id))
|
|
.first()
|
|
if (!subcategory) return null
|
|
|
|
try {
|
|
const id = await ctx.runMutation(api.tickets.create, {
|
|
actorId: actor._id,
|
|
tenantId,
|
|
subject,
|
|
summary,
|
|
priority: "Alta",
|
|
channel: "Automação",
|
|
queueId: undefined,
|
|
requesterId: actor._id,
|
|
assigneeId: undefined,
|
|
categoryId: category._id,
|
|
subcategoryId: subcategory._id,
|
|
customFields: undefined,
|
|
})
|
|
return id
|
|
} catch (error) {
|
|
console.error("[machines.alerts] Falha ao criar ticket:", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function evaluatePostureAndMaybeRaise(
|
|
ctx: MutationCtx,
|
|
machine: Doc<"machines">,
|
|
args: { metrics?: any; inventory?: any; metadata?: any }
|
|
) {
|
|
const findings: PostureFinding[] = []
|
|
|
|
// Janela temporal de CPU (5 minutos)
|
|
const now = Date.now()
|
|
const metrics = args.metrics ?? (args.metadata?.metrics ?? null)
|
|
const metaObj = machine.metadata && typeof machine.metadata === "object" ? (machine.metadata as Record<string, unknown>) : {}
|
|
const prevWindow: Array<{ ts: number; usage: number }> = Array.isArray((metaObj as any).cpuWindow)
|
|
? (((metaObj as any).cpuWindow as Array<any>).map((p) => ({ ts: Number(p.ts ?? 0), usage: Number(p.usage ?? NaN) })).filter((p) => Number.isFinite(p.ts) && Number.isFinite(p.usage)))
|
|
: []
|
|
const window = prevWindow.filter((p) => now - p.ts <= 5 * 60 * 1000)
|
|
const usage = Number((metrics as any)?.cpuUsagePercent ?? (metrics as any)?.cpu_usage_percent ?? NaN)
|
|
if (Number.isFinite(usage)) {
|
|
window.push({ ts: now, usage })
|
|
}
|
|
if (window.length > 0) {
|
|
const avg = window.reduce((acc, p) => acc + p.usage, 0) / window.length
|
|
if (avg >= 90) {
|
|
findings.push({ kind: "CPU_HIGH", message: `CPU média ${avg.toFixed(0)}% em 5 min`, severity: "warning" })
|
|
}
|
|
}
|
|
|
|
const inventory = args.inventory ?? (args.metadata?.inventory ?? null)
|
|
if (inventory && typeof inventory === "object") {
|
|
const services = (inventory as any).services
|
|
if (Array.isArray(services)) {
|
|
const criticalList = (process.env["MACHINE_CRITICAL_SERVICES"] ?? "")
|
|
.split(/[\s,]+/)
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
const criticalSet = new Set(criticalList)
|
|
const firstDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? s?.Status ?? "").toLowerCase() !== "running")
|
|
if (firstDown) {
|
|
const name = String(firstDown.name ?? firstDown.Name ?? "serviço")
|
|
const sev: "warning" | "critical" = criticalSet.has(name.toLowerCase()) ? "critical" : "warning"
|
|
findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${name}`, severity: sev })
|
|
}
|
|
}
|
|
const smart = (inventory as any).extended?.linux?.smart
|
|
if (Array.isArray(smart)) {
|
|
const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false)
|
|
if (failing) {
|
|
const model = failing?.model_name ?? failing?.model_family ?? "Disco"
|
|
const serial = failing?.serial_number ?? failing?.device?.name ?? "—"
|
|
const temp = failing?.temperature?.current ?? failing?.temperature?.value ?? null
|
|
const details = temp ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})`
|
|
findings.push({ kind: "SMART_FAIL", message: `SMART em falha: ${details}`, severity: "critical" })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persistir janela de CPU (limite de 120 amostras)
|
|
const cpuWindowCapped = window.slice(-120)
|
|
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, { cpuWindow: cpuWindowCapped }) })
|
|
|
|
if (!findings.length) return
|
|
|
|
const record = {
|
|
postureAlerts: findings,
|
|
lastPostureAt: now,
|
|
}
|
|
const prevMeta = (machine.metadata && typeof machine.metadata === "object") ? (machine.metadata as Record<string, unknown>) : null
|
|
const lastAtPrev = typeof prevMeta?.lastPostureAt === "number" ? (prevMeta!.lastPostureAt as number) : 0
|
|
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now })
|
|
|
|
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return
|
|
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
|
|
|
|
const subject = `Alerta de máquina: ${machine.hostname}`
|
|
const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ")
|
|
await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary)
|
|
}
|
|
|
|
export const register = mutation({
|
|
args: {
|
|
provisioningCode: v.string(),
|
|
hostname: v.string(),
|
|
os: v.object({
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
architecture: v.optional(v.string()),
|
|
}),
|
|
macAddresses: v.array(v.string()),
|
|
serialNumbers: v.array(v.string()),
|
|
metadata: v.optional(v.any()),
|
|
registeredBy: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const normalizedCode = args.provisioningCode.trim().toLowerCase()
|
|
const companyRecord = await ctx.db
|
|
.query("companies")
|
|
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
|
|
.unique()
|
|
|
|
if (!companyRecord) {
|
|
throw new ConvexError("Código de provisionamento inválido")
|
|
}
|
|
|
|
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
|
const companyId = companyRecord._id
|
|
const companySlug = companyRecord.slug
|
|
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
|
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
|
|
const now = Date.now()
|
|
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
|
|
|
|
let existing = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
|
|
.first()
|
|
|
|
if (!existing) {
|
|
const collaboratorEmail = extractCollaboratorEmail(metadataPatch ?? args.metadata)
|
|
if (collaboratorEmail) {
|
|
const candidate = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant_assigned_email", (q) => q.eq("tenantId", tenantId).eq("assignedUserEmail", collaboratorEmail))
|
|
.first()
|
|
if (candidate && matchesExistingHardware(candidate, identifiers, args.hostname)) {
|
|
existing = candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
let machineId: Id<"machines">
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
tenantId,
|
|
companyId: companyId ?? existing.companyId,
|
|
companySlug: companySlug ?? existing.companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
fingerprint,
|
|
metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: "online",
|
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
|
persona: existing.persona,
|
|
assignedUserId: existing.assignedUserId,
|
|
assignedUserEmail: existing.assignedUserEmail,
|
|
assignedUserName: existing.assignedUserName,
|
|
assignedUserRole: existing.assignedUserRole,
|
|
})
|
|
machineId = existing._id
|
|
} else {
|
|
machineId = await ctx.db.insert("machines", {
|
|
tenantId,
|
|
companyId,
|
|
companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
fingerprint,
|
|
metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined,
|
|
lastHeartbeatAt: now,
|
|
status: "online",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
registeredBy: args.registeredBy,
|
|
persona: undefined,
|
|
assignedUserId: undefined,
|
|
assignedUserEmail: undefined,
|
|
assignedUserName: undefined,
|
|
assignedUserRole: undefined,
|
|
})
|
|
}
|
|
|
|
const previousTokens = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
.collect()
|
|
|
|
for (const token of previousTokens) {
|
|
if (!token.revoked) {
|
|
await ctx.db.patch(token._id, { revoked: true, lastUsedAt: now })
|
|
}
|
|
}
|
|
|
|
const tokenPlain = toHex(randomBytes(32))
|
|
const tokenHash = hashToken(tokenPlain)
|
|
const expiresAt = now + getTokenTtlMs()
|
|
|
|
await ctx.db.insert("machineTokens", {
|
|
tenantId,
|
|
machineId,
|
|
tokenHash,
|
|
expiresAt,
|
|
revoked: false,
|
|
createdAt: now,
|
|
usageCount: 0,
|
|
type: "machine",
|
|
})
|
|
|
|
return {
|
|
machineId,
|
|
tenantId,
|
|
companyId,
|
|
companySlug,
|
|
machineToken: tokenPlain,
|
|
expiresAt,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const upsertInventory = mutation({
|
|
args: {
|
|
provisioningCode: v.string(),
|
|
hostname: v.string(),
|
|
os: v.object({
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
architecture: v.optional(v.string()),
|
|
}),
|
|
macAddresses: v.array(v.string()),
|
|
serialNumbers: v.array(v.string()),
|
|
inventory: v.optional(v.any()),
|
|
metrics: v.optional(v.any()),
|
|
registeredBy: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const normalizedCode = args.provisioningCode.trim().toLowerCase()
|
|
const companyRecord = await ctx.db
|
|
.query("companies")
|
|
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
|
|
.unique()
|
|
|
|
if (!companyRecord) {
|
|
throw new ConvexError("Código de provisionamento inválido")
|
|
}
|
|
|
|
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
|
const companyId = companyRecord._id
|
|
const companySlug = companyRecord.slug
|
|
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
|
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
|
|
const now = Date.now()
|
|
|
|
const metadataPatch: Record<string, unknown> = {}
|
|
if (args.inventory && typeof args.inventory === "object") {
|
|
metadataPatch.inventory = args.inventory as Record<string, unknown>
|
|
}
|
|
if (args.metrics && typeof args.metrics === "object") {
|
|
metadataPatch.metrics = args.metrics as Record<string, unknown>
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
|
|
.first()
|
|
|
|
let machineId: Id<"machines">
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
tenantId,
|
|
companyId: companyId ?? existing.companyId,
|
|
companySlug: companySlug ?? existing.companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
metadata: Object.keys(metadataPatch).length ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: args.metrics ? "online" : existing.status ?? "unknown",
|
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
|
persona: existing.persona,
|
|
assignedUserId: existing.assignedUserId,
|
|
assignedUserEmail: existing.assignedUserEmail,
|
|
assignedUserName: existing.assignedUserName,
|
|
assignedUserRole: existing.assignedUserRole,
|
|
})
|
|
machineId = existing._id
|
|
} else {
|
|
machineId = await ctx.db.insert("machines", {
|
|
tenantId,
|
|
companyId,
|
|
companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
fingerprint,
|
|
metadata: Object.keys(metadataPatch).length ? mergeMetadata(undefined, metadataPatch) : undefined,
|
|
lastHeartbeatAt: now,
|
|
status: args.metrics ? "online" : "unknown",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
registeredBy: args.registeredBy,
|
|
persona: undefined,
|
|
assignedUserId: undefined,
|
|
assignedUserEmail: undefined,
|
|
assignedUserName: undefined,
|
|
assignedUserRole: undefined,
|
|
})
|
|
}
|
|
|
|
// Evaluate posture/alerts based on provided metrics/inventory
|
|
const machine = (await ctx.db.get(machineId)) as Doc<"machines">
|
|
await evaluatePostureAndMaybeRaise(ctx, machine, { metrics: args.metrics, inventory: args.inventory })
|
|
|
|
return {
|
|
machineId,
|
|
tenantId,
|
|
companyId,
|
|
companySlug,
|
|
status: args.metrics ? "online" : "unknown",
|
|
}
|
|
},
|
|
})
|
|
|
|
export const heartbeat = mutation({
|
|
args: {
|
|
machineToken: v.string(),
|
|
status: v.optional(v.string()),
|
|
hostname: v.optional(v.string()),
|
|
os: v.optional(
|
|
v.object({
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
architecture: v.optional(v.string()),
|
|
})
|
|
),
|
|
metrics: v.optional(v.any()),
|
|
inventory: v.optional(v.any()),
|
|
metadata: v.optional(v.any()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { machine, token } = await getActiveToken(ctx, args.machineToken)
|
|
const now = Date.now()
|
|
|
|
const metadataPatch: Record<string, unknown> = {}
|
|
if (args.metadata && typeof args.metadata === "object") {
|
|
Object.assign(metadataPatch, args.metadata as Record<string, unknown>)
|
|
}
|
|
if (args.inventory && typeof args.inventory === "object") {
|
|
metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record<string, unknown>)
|
|
}
|
|
if (args.metrics && typeof args.metrics === "object") {
|
|
metadataPatch.metrics = args.metrics as Record<string, unknown>
|
|
}
|
|
const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata
|
|
|
|
await ctx.db.patch(machine._id, {
|
|
hostname: args.hostname ?? machine.hostname,
|
|
osName: args.os?.name ?? machine.osName,
|
|
osVersion: args.os?.version ?? machine.osVersion,
|
|
architecture: args.os?.architecture ?? machine.architecture,
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: args.status ?? "online",
|
|
metadata: mergedMetadata,
|
|
})
|
|
|
|
await ctx.db.patch(token._id, {
|
|
lastUsedAt: now,
|
|
usageCount: (token.usageCount ?? 0) + 1,
|
|
expiresAt: now + getTokenTtlMs(),
|
|
})
|
|
|
|
// Evaluate posture/alerts & optionally create ticket
|
|
const fresh = (await ctx.db.get(machine._id)) as Doc<"machines">
|
|
await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata })
|
|
|
|
return {
|
|
ok: true,
|
|
machineId: machine._id,
|
|
expiresAt: now + getTokenTtlMs(),
|
|
}
|
|
},
|
|
})
|
|
|
|
export const resolveToken = mutation({
|
|
args: {
|
|
machineToken: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { machine, token } = await getActiveToken(ctx, args.machineToken)
|
|
const now = Date.now()
|
|
|
|
await ctx.db.patch(token._id, {
|
|
lastUsedAt: now,
|
|
usageCount: (token.usageCount ?? 0) + 1,
|
|
})
|
|
|
|
return {
|
|
machine: {
|
|
_id: machine._id,
|
|
tenantId: machine.tenantId,
|
|
companyId: machine.companyId,
|
|
companySlug: machine.companySlug,
|
|
hostname: machine.hostname,
|
|
osName: machine.osName,
|
|
osVersion: machine.osVersion,
|
|
architecture: machine.architecture,
|
|
authUserId: machine.authUserId,
|
|
authEmail: machine.authEmail,
|
|
persona: machine.persona ?? null,
|
|
assignedUserId: machine.assignedUserId ?? null,
|
|
assignedUserEmail: machine.assignedUserEmail ?? null,
|
|
assignedUserName: machine.assignedUserName ?? null,
|
|
assignedUserRole: machine.assignedUserRole ?? null,
|
|
status: machine.status,
|
|
lastHeartbeatAt: machine.lastHeartbeatAt,
|
|
metadata: machine.metadata,
|
|
},
|
|
token: {
|
|
expiresAt: token.expiresAt,
|
|
lastUsedAt: token.lastUsedAt ?? null,
|
|
usageCount: token.usageCount ?? 0,
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
export const listByTenant = query({
|
|
args: {
|
|
tenantId: v.optional(v.string()),
|
|
includeMetadata: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
|
const includeMetadata = Boolean(args.includeMetadata)
|
|
const now = Date.now()
|
|
|
|
const machines = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
|
|
return Promise.all(
|
|
machines.map(async (machine) => {
|
|
const tokens = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machine._id))
|
|
.collect()
|
|
|
|
const activeToken = tokens.find((token) => !token.revoked && token.expiresAt > now) ?? null
|
|
const offlineThresholdMs = getOfflineThresholdMs()
|
|
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
|
|
const manualStatus = (machine.status ?? "").toLowerCase()
|
|
let derivedStatus: string
|
|
if (["maintenance", "blocked"].includes(manualStatus)) {
|
|
derivedStatus = manualStatus
|
|
} else if (machine.lastHeartbeatAt) {
|
|
const age = now - machine.lastHeartbeatAt
|
|
if (age <= offlineThresholdMs) {
|
|
derivedStatus = "online"
|
|
} else if (age <= staleThresholdMs) {
|
|
derivedStatus = "offline"
|
|
} else {
|
|
derivedStatus = "stale"
|
|
}
|
|
} else {
|
|
derivedStatus = machine.status ?? "unknown"
|
|
}
|
|
|
|
const metadata = includeMetadata ? (machine.metadata ?? null) : null
|
|
|
|
let metrics: Record<string, unknown> | null = null
|
|
let inventory: Record<string, unknown> | null = null
|
|
let postureAlerts: Array<Record<string, unknown>> | null = null
|
|
let lastPostureAt: number | null = null
|
|
|
|
if (metadata && typeof metadata === "object") {
|
|
const metaRecord = metadata as Record<string, unknown>
|
|
if (metaRecord.metrics && typeof metaRecord.metrics === "object") {
|
|
metrics = metaRecord.metrics as Record<string, unknown>
|
|
}
|
|
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
|
|
inventory = metaRecord.inventory as Record<string, unknown>
|
|
}
|
|
if (Array.isArray(metaRecord.postureAlerts)) {
|
|
postureAlerts = metaRecord.postureAlerts as Array<Record<string, unknown>>
|
|
}
|
|
if (typeof metaRecord.lastPostureAt === "number") {
|
|
lastPostureAt = metaRecord.lastPostureAt as number
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: machine._id,
|
|
tenantId: machine.tenantId,
|
|
hostname: machine.hostname,
|
|
companyId: machine.companyId ?? null,
|
|
companySlug: machine.companySlug ?? null,
|
|
osName: machine.osName,
|
|
osVersion: machine.osVersion ?? null,
|
|
architecture: machine.architecture ?? null,
|
|
macAddresses: machine.macAddresses,
|
|
serialNumbers: machine.serialNumbers,
|
|
authUserId: machine.authUserId ?? null,
|
|
authEmail: machine.authEmail ?? null,
|
|
persona: machine.persona ?? null,
|
|
assignedUserId: machine.assignedUserId ?? null,
|
|
assignedUserEmail: machine.assignedUserEmail ?? null,
|
|
assignedUserName: machine.assignedUserName ?? null,
|
|
assignedUserRole: machine.assignedUserRole ?? null,
|
|
status: derivedStatus,
|
|
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
|
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
|
|
registeredBy: machine.registeredBy ?? null,
|
|
createdAt: machine.createdAt,
|
|
updatedAt: machine.updatedAt,
|
|
token: activeToken
|
|
? {
|
|
expiresAt: activeToken.expiresAt,
|
|
lastUsedAt: activeToken.lastUsedAt ?? null,
|
|
usageCount: activeToken.usageCount ?? 0,
|
|
}
|
|
: null,
|
|
metrics,
|
|
inventory,
|
|
postureAlerts,
|
|
lastPostureAt,
|
|
}
|
|
})
|
|
)
|
|
},
|
|
})
|
|
|
|
export const updatePersona = mutation({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
persona: v.optional(v.string()),
|
|
assignedUserId: v.optional(v.id("users")),
|
|
assignedUserEmail: v.optional(v.string()),
|
|
assignedUserName: v.optional(v.string()),
|
|
assignedUserRole: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const machine = await ctx.db.get(args.machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
|
|
let nextPersona = machine.persona ?? undefined
|
|
const personaProvided = args.persona !== undefined
|
|
if (args.persona !== undefined) {
|
|
const trimmed = args.persona.trim().toLowerCase()
|
|
if (!trimmed) {
|
|
nextPersona = undefined
|
|
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
|
|
throw new ConvexError("Perfil inválido para a máquina")
|
|
} else {
|
|
nextPersona = trimmed
|
|
}
|
|
}
|
|
|
|
let nextAssignedUserId = machine.assignedUserId ?? undefined
|
|
if (args.assignedUserId !== undefined) {
|
|
nextAssignedUserId = args.assignedUserId
|
|
}
|
|
|
|
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
|
|
if (args.assignedUserEmail !== undefined) {
|
|
const trimmedEmail = args.assignedUserEmail.trim().toLowerCase()
|
|
nextAssignedEmail = trimmedEmail || undefined
|
|
}
|
|
|
|
let nextAssignedName = machine.assignedUserName ?? undefined
|
|
if (args.assignedUserName !== undefined) {
|
|
const trimmedName = args.assignedUserName.trim()
|
|
nextAssignedName = trimmedName || undefined
|
|
}
|
|
|
|
let nextAssignedRole = machine.assignedUserRole ?? undefined
|
|
if (args.assignedUserRole !== undefined) {
|
|
const trimmedRole = args.assignedUserRole.trim().toUpperCase()
|
|
nextAssignedRole = trimmedRole || undefined
|
|
}
|
|
|
|
if (nextPersona && !nextAssignedUserId) {
|
|
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
|
|
}
|
|
|
|
if (nextAssignedUserId) {
|
|
const assignedUser = await ctx.db.get(nextAssignedUserId)
|
|
if (!assignedUser) {
|
|
throw new ConvexError("Usuário vinculado não encontrado")
|
|
}
|
|
if (assignedUser.tenantId !== machine.tenantId) {
|
|
throw new ConvexError("Usuário vinculado pertence a outro tenant")
|
|
}
|
|
}
|
|
|
|
let nextMetadata = machine.metadata
|
|
if (nextPersona) {
|
|
const collaboratorMeta = {
|
|
email: nextAssignedEmail ?? null,
|
|
name: nextAssignedName ?? null,
|
|
role: nextPersona,
|
|
}
|
|
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
|
|
}
|
|
|
|
const patch: Record<string, unknown> = {
|
|
persona: nextPersona,
|
|
assignedUserId: nextPersona ? nextAssignedUserId : undefined,
|
|
assignedUserEmail: nextPersona ? nextAssignedEmail : undefined,
|
|
assignedUserName: nextPersona ? nextAssignedName : undefined,
|
|
assignedUserRole: nextPersona ? nextAssignedRole : undefined,
|
|
updatedAt: Date.now(),
|
|
}
|
|
if (nextMetadata !== machine.metadata) {
|
|
patch.metadata = nextMetadata
|
|
}
|
|
|
|
if (personaProvided) {
|
|
patch.persona = nextPersona
|
|
}
|
|
|
|
if (nextPersona) {
|
|
patch.assignedUserId = nextAssignedUserId
|
|
patch.assignedUserEmail = nextAssignedEmail
|
|
patch.assignedUserName = nextAssignedName
|
|
patch.assignedUserRole = nextAssignedRole
|
|
} else if (personaProvided) {
|
|
patch.assignedUserId = undefined
|
|
patch.assignedUserEmail = undefined
|
|
patch.assignedUserName = undefined
|
|
patch.assignedUserRole = undefined
|
|
}
|
|
|
|
await ctx.db.patch(machine._id, patch)
|
|
return { ok: true, persona: nextPersona ?? null }
|
|
},
|
|
})
|
|
|
|
export const getContext = query({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const machine = await ctx.db.get(args.machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
|
|
return {
|
|
id: machine._id,
|
|
tenantId: machine.tenantId,
|
|
companyId: machine.companyId ?? null,
|
|
companySlug: machine.companySlug ?? null,
|
|
persona: machine.persona ?? null,
|
|
assignedUserId: machine.assignedUserId ?? null,
|
|
assignedUserEmail: machine.assignedUserEmail ?? null,
|
|
assignedUserName: machine.assignedUserName ?? null,
|
|
assignedUserRole: machine.assignedUserRole ?? null,
|
|
metadata: machine.metadata ?? null,
|
|
authEmail: machine.authEmail ?? null,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const findByAuthEmail = query({
|
|
args: {
|
|
authEmail: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const normalizedEmail = args.authEmail.trim().toLowerCase()
|
|
|
|
const machine = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_auth_email", (q) => q.eq("authEmail", normalizedEmail))
|
|
.first()
|
|
|
|
if (!machine) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
id: machine._id,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const linkAuthAccount = mutation({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
authUserId: v.string(),
|
|
authEmail: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const machine = await ctx.db.get(args.machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
|
|
await ctx.db.patch(machine._id, {
|
|
authUserId: args.authUserId,
|
|
authEmail: args.authEmail,
|
|
updatedAt: Date.now(),
|
|
})
|
|
|
|
return { ok: true }
|
|
},
|
|
})
|
|
|
|
export const rename = mutation({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
actorId: v.id("users"),
|
|
tenantId: v.optional(v.string()),
|
|
hostname: v.string(),
|
|
},
|
|
handler: async (ctx, { machineId, actorId, tenantId, hostname }) => {
|
|
// Reutiliza requireStaff através de tickets.ts helpers
|
|
const machine = await ctx.db.get(machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
// Verifica permissão no tenant da máquina
|
|
const viewer = await ctx.db.get(actorId)
|
|
if (!viewer || viewer.tenantId !== machine.tenantId) {
|
|
throw new ConvexError("Acesso negado ao tenant da máquina")
|
|
}
|
|
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
|
|
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
|
if (!STAFF.has(normalizedRole)) {
|
|
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
|
|
}
|
|
|
|
const nextName = hostname.trim()
|
|
if (nextName.length < 2) {
|
|
throw new ConvexError("Informe um nome válido para a máquina")
|
|
}
|
|
|
|
await ctx.db.patch(machineId, { hostname: nextName, updatedAt: Date.now() })
|
|
return { ok: true }
|
|
},
|
|
})
|
|
|
|
export const remove = mutation({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { machineId, actorId }) => {
|
|
const machine = await ctx.db.get(machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
|
|
const actor = await ctx.db.get(actorId)
|
|
if (!actor || actor.tenantId !== machine.tenantId) {
|
|
throw new ConvexError("Acesso negado ao tenant da máquina")
|
|
}
|
|
const role = (actor.role ?? "AGENT").toUpperCase()
|
|
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
|
if (!STAFF.has(role)) {
|
|
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
|
|
}
|
|
|
|
const tokens = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
.collect()
|
|
|
|
await Promise.all(tokens.map((token) => ctx.db.delete(token._id)))
|
|
await ctx.db.delete(machineId)
|
|
return { ok: true }
|
|
},
|
|
})
|