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>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-02 23:27:35 +02:00
parent 6d9850d8d3
commit 1702235c59
6 changed files with 457 additions and 4 deletions
@@ -1,4 +1,37 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildExtRoutes = buildExtRoutes;
/**
@@ -19,6 +52,7 @@ exports.buildExtRoutes = buildExtRoutes;
*/
const fs_1 = require("fs");
const promises_1 = require("fs/promises");
const nodePath = __importStar(require("path"));
const express_1 = require("express");
let dragonfly;
if (process.env.NODE_ENV === 'production') {
@@ -35,6 +69,47 @@ if (process.env.NODE_ENV === 'production') {
else {
sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage;
}
// Engine (download/getContentType) + StorageProvider (Wasabi) para re-download de
// mídia sob demanda. Mesmos módulos que a app principal usa; carregados lazy pelo
// mesmo esquema prod(dist)/dev(backend/dist) do sendRichMessage acima.
let mediaEngine;
let mediaStorage;
if (process.env.NODE_ENV === 'production') {
mediaEngine = require('../../../dist/modules/whatsapp/engine');
mediaStorage = require('../../../dist/core/StorageProvider').storageProvider;
}
else {
mediaEngine = require('../../../backend/dist/modules/whatsapp/engine');
mediaStorage = require('../../../backend/dist/core/StorageProvider').storageProvider;
}
const downloadMediaMessage = mediaEngine.downloadMediaMessage;
const getContentType = mediaEngine.getContentType;
// Extensão/mime por tipo de mensagem (espelha whatsapp.media.routes.ts).
const MEDIA_EXT_MAP = {
imageMessage: 'jpg', videoMessage: 'mp4', audioMessage: 'ogg',
documentMessage: 'bin', stickerMessage: 'webp',
};
const MEDIA_MIME_MAP = {
imageMessage: 'image/jpeg', videoMessage: 'video/mp4', audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream', stickerMessage: 'image/webp',
};
// Payload salvo em mediaPayload guarda buffers como { _b64: base64 } → Buffer.
function deserializeMediaPayload(val) {
if (Array.isArray(val))
return val.map(deserializeMediaPayload);
if (val !== null && typeof val === 'object') {
const obj = val;
if ('_b64' in obj && typeof obj._b64 === 'string')
return Buffer.from(obj._b64, 'base64');
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializeMediaPayload(v)]));
}
return val;
}
// Logger pino-like mínimo exigido pelo downloadMediaMessage do Baileys.
const mediaLogger = {
level: 'silent', child: () => mediaLogger,
trace() { }, debug() { }, info() { }, warn() { }, error() { }, fatal() { },
};
function uuid() {
try {
return crypto.randomUUID();
@@ -326,6 +401,46 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
res.status(500).json({ error: err.message ?? 'Erro interno' });
}
});
// ── POST /sessions/:id/connect ────────────────────────────────────────────
// (Re)inicia a conexão de uma instância JÁ existente — gera novo QR se preciso.
// O POST /sessions já conecta na criação; este endpoint cobre reconectar uma
// sessão que caiu/desconectou (o "re-scan" do modelo de ownership do satélite).
router.post('/sessions/:id/connect', async (req, res) => {
const tenantId = req.extTenantId;
const instanceId = req.params['id'];
try {
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId }, select: { id: true } });
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
manager.connect(instanceId, tenantId).catch((err) => {
console.error('[ext-api] Falha ao reconectar instância:', err);
});
res.json({ message: 'Conexão iniciada', instanceId });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro interno' });
}
});
// ── POST /sessions/:id/disconnect ─────────────────────────────────────────
// Encerra a sessão do WhatsApp SEM remover a instância (permite reconectar).
router.post('/sessions/:id/disconnect', async (req, res) => {
const tenantId = req.extTenantId;
const instanceId = req.params['id'];
try {
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId }, select: { id: true } });
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
await manager.disconnect(instanceId).catch(() => { });
res.json({ message: 'Sessão encerrada', instanceId });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro interno' });
}
});
// ── GET /media/:messageId ─────────────────────────────────────────────────
// Serve o arquivo de mídia de uma mensagem.
// Se mediaUrl for URL externa (Wasabi etc.) redireciona 302.
@@ -389,6 +504,97 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
res.status(500).json({ error: err.message ?? 'Erro interno' });
}
});
// ── POST /media/:messageId/download ─────────────────────────────────────────
// Re-baixa a mídia sob demanda. ~95% das mídias têm mediaUrl NULL: só o
// WAMessage fica salvo em mediaPayload (lazy). Aqui re-baixamos do WhatsApp,
// persistimos (Wasabi/local) e devolvemos { mediaUrl } — é o que o "Toque para
// baixar" do satélite chama. Espelha /redownload-media da app principal.
router.post('/media/:messageId/download', async (req, res) => {
const tenantId = req.extTenantId;
const messageId = req.params['messageId'];
try {
const msg = await prisma.message.findFirst({
where: { id: messageId, tenantId },
select: { id: true, mediaUrl: true, mediaPayload: true, chatId: true, instanceId: true, mimeType: true, fileName: true },
});
if (!msg) {
res.status(404).json({ error: 'Mensagem não encontrada' });
return;
}
if (msg.mediaUrl) {
res.json({ mediaUrl: msg.mediaUrl });
return;
}
if (!msg.mediaPayload) {
res.status(400).json({ error: 'Mídia não disponível para re-download' });
return;
}
const sock = manager?.getSocket(msg.instanceId);
if (!sock) {
res.status(503).json({ error: 'Instância não conectada' });
return;
}
const waMsg = deserializeMediaPayload(msg.mediaPayload);
const buffer = await downloadMediaMessage(waMsg, 'buffer', {}, { logger: mediaLogger, reuploadRequest: sock.updateMediaMessage });
if (!buffer || !buffer.length) {
res.status(502).json({ error: 'Sem dados de mídia do WhatsApp' });
return;
}
const contentType = getContentType(waMsg.message) ?? 'imageMessage';
let ext = MEDIA_EXT_MAP[contentType] ?? 'bin';
let mime = MEDIA_MIME_MAP[contentType] ?? 'application/octet-stream';
// Documento: preserva a extensão/mime REAIS em vez de gravar sempre .bin.
// O nome/mimetype verdadeiros vivem no WAMessage (documentMessage), não nas
// colunas do banco (quase sempre NULL). Prioridade: nome → mimetype → bin.
if (contentType === 'documentMessage' || contentType === 'documentWithCaptionMessage') {
const doc = waMsg.message?.documentMessage
?? waMsg.message?.documentWithCaptionMessage?.message?.documentMessage;
const docName = (doc?.fileName || msg.fileName || '');
const docMime = (doc?.mimetype || msg.mimeType || '');
const fromName = docName.split('.').pop()?.toLowerCase();
if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/.test(fromName)) {
ext = fromName;
}
else {
const sub = docMime.split('/').pop()?.split(';')[0]?.toLowerCase();
if (sub && /^[a-z0-9.+-]+$/.test(sub))
ext = sub;
}
if (docMime)
mime = docMime;
}
const fileName = `${messageId}.${ext}`;
let mediaUrl = `/media/${msg.instanceId}/${fileName}`;
if (mediaStorage?.isRegistered?.()) {
try {
const result = await mediaStorage.uploadFile({
category: 'whatsapp', usuarioSis: tenantId, sessionJID: msg.instanceId,
file: { originalname: fileName, buffer: buffer, mimetype: mime },
});
mediaUrl = result.path;
}
catch {
const filePath = nodePath.resolve('./media', msg.instanceId, fileName);
await (0, promises_1.mkdir)(nodePath.dirname(filePath), { recursive: true });
await (0, promises_1.writeFile)(filePath, buffer);
}
}
else {
const filePath = nodePath.resolve('./media', msg.instanceId, fileName);
await (0, promises_1.mkdir)(nodePath.dirname(filePath), { recursive: true });
await (0, promises_1.writeFile)(filePath, buffer);
}
await prisma.message.update({ where: { id: messageId }, data: { mediaUrl } });
try {
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl });
}
catch { }
res.json({ mediaUrl });
}
catch (err) {
res.status(500).json({ error: err?.message ?? 'Falha no re-download de mídia' });
}
});
// ── GET /inbox ────────────────────────────────────────────────────────────
// Lista chats da instância com snapshot da última mensagem.
router.get('/inbox', async (req, res) => {
File diff suppressed because one or more lines are too long
@@ -15,7 +15,8 @@
* Envelope de resposta de sucesso: dados diretos (sem wrapper extra)
*/
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import { stat, mkdir, writeFile } from 'fs/promises'
import * as nodePath from 'path'
import { Router, type Request, type Response } from 'express'
import type { PrismaClient } from '@prisma/client'
import type { Knex } from 'knex'
@@ -37,6 +38,46 @@ if (process.env.NODE_ENV === 'production') {
sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage;
}
// Engine (download/getContentType) + StorageProvider (Wasabi) para re-download de
// mídia sob demanda. Mesmos módulos que a app principal usa; carregados lazy pelo
// mesmo esquema prod(dist)/dev(backend/dist) do sendRichMessage acima.
let mediaEngine: any;
let mediaStorage: any;
if (process.env.NODE_ENV === 'production') {
mediaEngine = require('../../../dist/modules/whatsapp/engine');
mediaStorage = require('../../../dist/core/StorageProvider').storageProvider;
} else {
mediaEngine = require('../../../backend/dist/modules/whatsapp/engine');
mediaStorage = require('../../../backend/dist/core/StorageProvider').storageProvider;
}
const downloadMediaMessage: any = mediaEngine.downloadMediaMessage;
const getContentType: any = mediaEngine.getContentType;
// Extensão/mime por tipo de mensagem (espelha whatsapp.media.routes.ts).
const MEDIA_EXT_MAP: Record<string, string> = {
imageMessage: 'jpg', videoMessage: 'mp4', audioMessage: 'ogg',
documentMessage: 'bin', stickerMessage: 'webp',
}
const MEDIA_MIME_MAP: Record<string, string> = {
imageMessage: 'image/jpeg', videoMessage: 'video/mp4', audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream', stickerMessage: 'image/webp',
}
// Payload salvo em mediaPayload guarda buffers como { _b64: base64 } → Buffer.
function deserializeMediaPayload(val: unknown): unknown {
if (Array.isArray(val)) return val.map(deserializeMediaPayload)
if (val !== null && typeof val === 'object') {
const obj = val as Record<string, unknown>
if ('_b64' in obj && typeof obj._b64 === 'string') return Buffer.from(obj._b64, 'base64')
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializeMediaPayload(v)]))
}
return val
}
// Logger pino-like mínimo exigido pelo downloadMediaMessage do Baileys.
const mediaLogger: any = {
level: 'silent', child: () => mediaLogger,
trace() {}, debug() {}, info() {}, warn() {}, error() {}, fatal() {},
}
function uuid(): string {
try { return (crypto as any).randomUUID() } catch { return `${Date.now()}-${Math.random().toString(36).slice(2)}` }
}
@@ -342,6 +383,46 @@ export function buildExtRoutes(
}
})
// ── POST /sessions/:id/connect ────────────────────────────────────────────
// (Re)inicia a conexão de uma instância JÁ existente — gera novo QR se preciso.
// O POST /sessions já conecta na criação; este endpoint cobre reconectar uma
// sessão que caiu/desconectou (o "re-scan" do modelo de ownership do satélite).
router.post('/sessions/:id/connect', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const instanceId = req.params['id'] as string
try {
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId }, select: { id: true } })
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' })
return
}
manager.connect(instanceId, tenantId).catch((err: unknown) => {
console.error('[ext-api] Falha ao reconectar instância:', err)
})
res.json({ message: 'Conexão iniciada', instanceId })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── POST /sessions/:id/disconnect ─────────────────────────────────────────
// Encerra a sessão do WhatsApp SEM remover a instância (permite reconectar).
router.post('/sessions/:id/disconnect', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const instanceId = req.params['id'] as string
try {
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId }, select: { id: true } })
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' })
return
}
await manager.disconnect(instanceId).catch(() => {})
res.json({ message: 'Sessão encerrada', instanceId })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── GET /media/:messageId ─────────────────────────────────────────────────
// Serve o arquivo de mídia de uma mensagem.
// Se mediaUrl for URL externa (Wasabi etc.) redireciona 302.
@@ -410,6 +491,82 @@ export function buildExtRoutes(
}
})
// ── POST /media/:messageId/download ─────────────────────────────────────────
// Re-baixa a mídia sob demanda. ~95% das mídias têm mediaUrl NULL: só o
// WAMessage fica salvo em mediaPayload (lazy). Aqui re-baixamos do WhatsApp,
// persistimos (Wasabi/local) e devolvemos { mediaUrl } — é o que o "Toque para
// baixar" do satélite chama. Espelha /redownload-media da app principal.
router.post('/media/:messageId/download', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const messageId = req.params['messageId'] as string
try {
const msg = await prisma.message.findFirst({
where: { id: messageId, tenantId },
select: { id: true, mediaUrl: true, mediaPayload: true, chatId: true, instanceId: true, mimeType: true, fileName: true },
})
if (!msg) { res.status(404).json({ error: 'Mensagem não encontrada' }); return }
if (msg.mediaUrl) { res.json({ mediaUrl: msg.mediaUrl }); return }
if (!msg.mediaPayload) { res.status(400).json({ error: 'Mídia não disponível para re-download' }); return }
const sock = manager?.getSocket(msg.instanceId)
if (!sock) { res.status(503).json({ error: 'Instância não conectada' }); return }
const waMsg = deserializeMediaPayload(msg.mediaPayload as Record<string, unknown>) as any
const buffer = await downloadMediaMessage(
waMsg, 'buffer', {},
{ logger: mediaLogger, reuploadRequest: sock.updateMediaMessage },
)
if (!buffer || !(buffer as Buffer).length) { res.status(502).json({ error: 'Sem dados de mídia do WhatsApp' }); return }
const contentType = getContentType(waMsg.message) ?? 'imageMessage'
let ext = MEDIA_EXT_MAP[contentType] ?? 'bin'
let mime = MEDIA_MIME_MAP[contentType] ?? 'application/octet-stream'
// Documento: preserva a extensão/mime REAIS em vez de gravar sempre .bin.
// O nome/mimetype verdadeiros vivem no WAMessage (documentMessage), não nas
// colunas do banco (quase sempre NULL). Prioridade: nome → mimetype → bin.
if (contentType === 'documentMessage' || contentType === 'documentWithCaptionMessage') {
const doc = waMsg.message?.documentMessage
?? waMsg.message?.documentWithCaptionMessage?.message?.documentMessage
const docName = (doc?.fileName || msg.fileName || '') as string
const docMime = (doc?.mimetype || msg.mimeType || '') as string
const fromName = docName.split('.').pop()?.toLowerCase()
if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/.test(fromName)) {
ext = fromName
} else {
const sub = docMime.split('/').pop()?.split(';')[0]?.toLowerCase()
if (sub && /^[a-z0-9.+-]+$/.test(sub)) ext = sub
}
if (docMime) mime = docMime
}
const fileName = `${messageId}.${ext}`
let mediaUrl = `/media/${msg.instanceId}/${fileName}`
if (mediaStorage?.isRegistered?.()) {
try {
const result = await mediaStorage.uploadFile({
category: 'whatsapp', usuarioSis: tenantId, sessionJID: msg.instanceId,
file: { originalname: fileName, buffer: buffer as Buffer, mimetype: mime },
})
mediaUrl = result.path
} catch {
const filePath = nodePath.resolve('./media', msg.instanceId, fileName)
await mkdir(nodePath.dirname(filePath), { recursive: true })
await writeFile(filePath, buffer as Buffer)
}
} else {
const filePath = nodePath.resolve('./media', msg.instanceId, fileName)
await mkdir(nodePath.dirname(filePath), { recursive: true })
await writeFile(filePath, buffer as Buffer)
}
await prisma.message.update({ where: { id: messageId }, data: { mediaUrl } })
try { manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl }) } catch {}
res.json({ mediaUrl })
} catch (err: any) {
res.status(500).json({ error: err?.message ?? 'Falha no re-download de mídia' })
}
})
// ── GET /inbox ────────────────────────────────────────────────────────────
// Lista chats da instância com snapshot da última mensagem.
router.get('/inbox', async (req: Request, res: Response) => {
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ensureAccount = ensureAccount;
exports.provisionSatellite = provisionSatellite;
exports.listSatellites = listSatellites;
exports.revokeSatellite = revokeSatellite;
@@ -10,8 +11,36 @@ exports.rotateSatelliteKey = rotateSatelliteKey;
exports.buildSatelliteRoutes = buildSatelliteRoutes;
const express_1 = require("express");
const crypto_1 = __importDefault(require("crypto"));
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const apikey_auth_1 = require("./apikey-auth");
const genKey = () => `nwk_${crypto_1.default.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".
async function ensureAccount(prisma, input) {
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 bcryptjs_1.default.hash(`${crypto_1.default.randomUUID()}${crypto_1.default.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 ──────────────────────────────────────────────────────
@@ -88,6 +117,24 @@ async function rotateSatelliteKey(prisma, id) {
// sem exigir x-nw-email como o fluxo de operação).
function buildSatelliteRoutes(prisma) {
const router = (0, express_1.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, res) => {
const key = req.headers['x-nw-key']?.trim() ?? '';
const allowed = (0, apikey_auth_1.isMasterKey)(key) || (key.startsWith('nwk_') && !!(await (0, apikey_auth_1.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) {
res.status(e.statusCode ?? 500).json({ error: e.message });
}
});
router.use((req, res, next) => {
const key = req.headers['x-nw-key']?.trim() ?? '';
if (!(0, apikey_auth_1.isMasterKey)(key)) {
File diff suppressed because one or more lines are too long
@@ -14,10 +14,41 @@
import type { PrismaClient } from '@prisma/client'
import { Router, type Request, type Response } from 'express'
import crypto from 'crypto'
import { isMasterKey } from './apikey-auth'
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.
@@ -102,6 +133,18 @@ export async function rotateSatelliteKey(prisma: PrismaClient, id: string) {
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 }