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) { if (!current || typeof current !== "object") return patch return { ...(current as Record), ...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 | null = null let inventory: Record | null = null if (metadata && typeof metadata === "object") { const metaRecord = metadata as Record if (metaRecord.metrics && typeof metaRecord.metrics === "object") { metrics = metaRecord.metrics as Record } if (metaRecord.inventory && typeof metaRecord.inventory === "object") { inventory = metaRecord.inventory as Record } } 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 } }, })