Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/ext-api/backend/satellites.ts
T
VPS 4 Deploy Agent 1702235c59 feat(ext-api): auto-provisionamento de conta + reconexão de sessão
- POST /satellites/accounts (ensureAccount): cria/reativa User por email, idempotente;
  gate aceita chave MESTRA ou chave de satélite (nwk_) válida — o satélite espelha
  suas próprias contas (scoreodonto)
- POST /sessions/:id/connect (reconectar/re-scan) e /sessions/:id/disconnect
  (encerrar sem remover) — antes o satélite recebia 404 no botão Conectar
- inclui proxy/re-download de mídia (/media/:id/download) da integração satélite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:27:35 +02:00

176 lines
7.8 KiB
TypeScript

/**
* 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
}