/** * Gerenciador de plugins/satélites do motor. * * Um "satélite" é um sistema cliente (ex.: scoreodonto) registrado como PluginPair, * com integration_key própria (nwk_...), clientUrl e clientSystem. Este módulo: * (a) resolve a chave do satélite → tenant no auth do ext-api (honra revoke, * atualiza lastSeenAt) — tornando o REGISTRY o gatekeeper, em vez de uma * única EXT_MASTER_KEY global; * (b) expõe gestão (listar / provisionar / revogar / rotacionar) para superadmin * (autenticado pela chave mestra). * * A chave mestra (EXT_MASTER_KEY) permanece para bootstrap/superadmin. */ import type { PrismaClient } from '@prisma/client' import { Router, type Request, type Response } from 'express' import crypto from 'crypto' import bcrypt from 'bcryptjs' import { isMasterKey, resolvePairKey } from './apikey-auth' const genKey = () => `nwk_${crypto.randomUUID().replace(/-/g, '')}` // ─── Provisionamento de conta (auto-provisioning de satélites) ───────────────── // Garante que exista um User no motor para um dado email. Idempotente: se a conta // já existe, apenas a reativa (se inativa) e retorna. A conta é "headless" — o // acesso se dá via satélite pela chave mestra + x-nw-email; a senha é aleatória // (a pessoa nunca loga direto no motor). Chamado pelo scoreodonto ao criar a // conta lá (eager) e como self-heal quando o motor devolve "conta não encontrada". export async function ensureAccount( prisma: PrismaClient, input: { email: string; name?: string | null }, ) { const email = String(input.email ?? '').toLowerCase().trim() if (!email || !email.includes('@')) { throw Object.assign(new Error('email válido é obrigatório'), { statusCode: 400 }) } const existing = await prisma.user.findUnique({ where: { email }, select: { id: true, email: true, isActive: true }, }) if (existing) { if (!existing.isActive) await prisma.user.update({ where: { id: existing.id }, data: { isActive: true } }) return { id: existing.id, email: existing.email, created: false } } // Senha aleatória (conta headless — nunca usada para login direto). const passwordHash = await bcrypt.hash(`${crypto.randomUUID()}${crypto.randomUUID()}`, 10) const user = await prisma.user.create({ data: { name: (input.name || email).slice(0, 120), email, passwordHash, isActive: true }, select: { id: true, email: true }, }) return { id: user.id, email: user.email, created: true } } // A resolução da chave de satélite para o auth vive em apikey-auth.resolvePairKey // (co-localizada com os demais resolvers + cache). Aqui ficam só as operações de gestão. // ─── Operações de gestão ────────────────────────────────────────────────────── export async function provisionSatellite( prisma: PrismaClient, input: { email: string; clientSystem: string; clientName: string; clientUrl?: string | null }, ) { const email = String(input.email ?? '').toLowerCase().trim() if (!email || !input.clientSystem || !input.clientName) { throw Object.assign(new Error('email, clientSystem e clientName obrigatórios'), { statusCode: 400 }) } const user = await prisma.user.findUnique({ where: { email }, select: { id: true, isActive: true, name: true, email: true }, }) if (!user || !user.isActive) throw Object.assign(new Error(`Conta não encontrada/inativa: ${email}`), { statusCode: 404 }) // Revoga par anterior do mesmo sistema (evita duplicatas ativas). await prisma.pluginPair.updateMany({ where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null }, data: { revokedAt: new Date() }, }) const integrationKey = genKey() const pair = await prisma.pluginPair.create({ data: { userId: user.id, clientSystem: input.clientSystem, clientName: input.clientName, clientUrl: input.clientUrl || null, integrationKey, }, }) return { id: pair.id, integration_key: integrationKey, account: { name: user.name, email: user.email }, clientSystem: input.clientSystem, clientName: input.clientName, } } export async function listSatellites(prisma: PrismaClient) { const pairs = await prisma.pluginPair.findMany({ orderBy: { createdAt: 'desc' }, select: { id: true, clientSystem: true, clientName: true, clientUrl: true, lastSeenAt: true, revokedAt: true, createdAt: true, user: { select: { email: true, isActive: true } }, }, }) return pairs.map((p) => ({ id: p.id, clientSystem: p.clientSystem, clientName: p.clientName, clientUrl: p.clientUrl, account: p.user.email, active: !p.revokedAt && p.user.isActive, revokedAt: p.revokedAt, lastSeenAt: p.lastSeenAt, createdAt: p.createdAt, })) } export async function revokeSatellite(prisma: PrismaClient, id: string) { const r = await prisma.pluginPair.updateMany({ where: { id, revokedAt: null }, data: { revokedAt: new Date() } }) if (!r.count) throw Object.assign(new Error('Satélite não encontrado ou já revogado'), { statusCode: 404 }) return { ok: true } } export async function rotateSatelliteKey(prisma: PrismaClient, id: string) { const pair = await prisma.pluginPair.findFirst({ where: { id, revokedAt: null }, select: { id: true } }) if (!pair) throw Object.assign(new Error('Satélite não encontrado'), { statusCode: 404 }) const integrationKey = genKey() await prisma.pluginPair.update({ where: { id }, data: { integrationKey } }) return { id, integration_key: integrationKey } } // ─── Router de gestão (superadmin via chave mestra) ─────────────────────────── // Montado FORA do authMiddleware do ext-api: tem gate próprio (chave mestra, // sem exigir x-nw-email como o fluxo de operação). export function buildSatelliteRoutes(prisma: PrismaClient): Router { const router = Router() // Auto-provisiona (ou reativa) a conta de um email. Idempotente. // Gate PRÓPRIO (definido antes do gate mestra abaixo): aceita a chave mestra OU // uma chave de satélite válida (nwk_) — pois auto-provisionar as contas dos seus // próprios usuários é justamente a função de um satélite confiável (ex.: scoreodonto). router.post('/accounts', async (req: Request, res: Response) => { const key = (req.headers['x-nw-key'] as string | undefined)?.trim() ?? '' const allowed = isMasterKey(key) || (key.startsWith('nwk_') && !!(await resolvePairKey(prisma, key))) if (!allowed) { res.status(403).json({ error: 'Chave inválida para provisionar conta.' }); return } try { res.json(await ensureAccount(prisma, req.body ?? {})) } catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) } }) router.use((req: Request, res: Response, next) => { const key = (req.headers['x-nw-key'] as string | undefined)?.trim() ?? '' if (!isMasterKey(key)) { res.status(403).json({ error: 'Apenas a chave mestra gerencia satélites.' }); return } next() }) router.get('/', async (_req, res) => { try { res.json({ satellites: await listSatellites(prisma) }) } catch (e: any) { res.status(500).json({ error: e.message }) } }) router.post('/', async (req, res) => { try { res.json(await provisionSatellite(prisma, req.body ?? {})) } catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) } }) router.post('/:id/revoke', async (req, res) => { try { res.json(await revokeSatellite(prisma, req.params['id']!)) } catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) } }) router.post('/:id/rotate', async (req, res) => { try { res.json(await rotateSatelliteKey(prisma, req.params['id']!)) } catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) } }) return router }