sistema-de-chamados/convex/machines.ts
2025-10-09 18:29:08 -03:00

505 lines
15 KiB
TypeScript

// ci: trigger convex functions deploy
import { mutation, query } from "./_generated/server"
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
type NormalizedIdentifiers = {
macs: string[]
serials: string[]
}
function getProvisioningSecret() {
const secret = process.env["MACHINE_PROVISIONING_SECRET"]
if (!secret) {
throw new ConvexError("Provisionamento de máquinas não configurado")
}
return secret
}
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 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 hashToken(token: string) {
return toHex(sha256(token))
}
async function ensureCompany(
ctx: MutationCtx,
tenantId: string,
companySlug?: string
): Promise<{ companyId?: Id<"companies">; companySlug?: string }> {
if (!companySlug) return {}
const company = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", companySlug))
.unique()
if (!company) {
throw new ConvexError("Empresa não encontrada para o tenant informado")
}
return { companyId: company._id, companySlug: company.slug }
}
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 mergeMetadata(current: unknown, patch: Record<string, unknown>) {
if (!current || typeof current !== "object") return patch
return { ...(current as Record<string, unknown>), ...patch }
}
export const register = mutation({
args: {
provisioningSecret: v.string(),
tenantId: v.optional(v.string()),
companySlug: v.optional(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 secret = getProvisioningSecret()
if (args.provisioningSecret !== secret) {
throw new ConvexError("Código de provisionamento inválido")
}
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
const now = Date.now()
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: args.metadata ? mergeMetadata(existing.metadata, { inventory: args.metadata }) : existing.metadata,
lastHeartbeatAt: now,
updatedAt: now,
status: "online",
registeredBy: args.registeredBy ?? existing.registeredBy,
})
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: args.metadata ? { inventory: args.metadata } : undefined,
lastHeartbeatAt: now,
status: "online",
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
})
}
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: {
provisioningSecret: v.string(),
tenantId: v.optional(v.string()),
companySlug: v.optional(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 secret = getProvisioningSecret()
if (args.provisioningSecret !== secret) {
throw new ConvexError("Código de provisionamento inválido")
}
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
const now = Date.now()
const metadataPatch = mergeMetadata({}, {
...(args.inventory ? { inventory: args.inventory } : {}),
...(args.metrics ? { metrics: args.metrics } : {}),
})
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: mergeMetadata(existing.metadata, metadataPatch),
lastHeartbeatAt: now,
updatedAt: now,
status: args.metrics ? "online" : existing.status ?? "unknown",
registeredBy: args.registeredBy ?? existing.registeredBy,
})
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,
lastHeartbeatAt: now,
status: args.metrics ? "online" : "unknown",
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
})
}
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 mergedMetadata = mergeMetadata(machine.metadata, {
...(args.metadata ?? {}),
...(args.metrics ? { metrics: args.metrics } : {}),
...(args.inventory ? { inventory: args.inventory } : {}),
})
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(),
})
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,
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 derivedStatus =
machine.status ??
(machine.lastHeartbeatAt && now - machine.lastHeartbeatAt <= 5 * 60 * 1000 ? "online" : machine.lastHeartbeatAt ? "offline" : "unknown")
const metadata = includeMetadata ? (machine.metadata ?? null) : null
let metrics: Record<string, unknown> | null = null
let inventory: Record<string, unknown> | 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>
}
}
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,
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,
}
})
)
},
})
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 }
},
})