feat(ext-api): chave mestra + x-nw-email (middleware REST + WS bridge)

Satélite confiável acessa a conta de qualquer email via EXT_MASTER_KEY:
- apikey-auth: helpers isMasterKey/resolveMasterEmail resolvem o tenant pelo
  User.email; middleware REST refatorado para usá-los.
- ws-bridge: handshake do /api/ext/v1/stream trata a chave mestra + x-nw-email
  (antes só tabela apiKey → o túnel WS tomava 401).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-01 20:55:10 +02:00
parent 7c54309aa8
commit dbb4da8220
8 changed files with 127 additions and 10 deletions
@@ -23,7 +23,7 @@ import type { Server as HttpServer } from 'http'
import type { IncomingMessage } from 'http'
import type { PrismaClient } from '@prisma/client'
import type { HookBus } from '../../../backend/src/core/hook-bus'
import { resolveApiKey } from './apikey-auth'
import { resolveApiKey, isMasterKey, resolveMasterEmail } from './apikey-auth'
const WS_PATH = '/api/ext/v1/stream'
const PING_INTERVAL_MS = 25_000
@@ -59,7 +59,19 @@ export function buildWsBridge(
let tenantId: string | null
try {
tenantId = await resolveApiKey(prisma, apiKey)
// Chave mestra → resolve o tenant pelo x-nw-email do handshake (o túnel do
// satélite injeta o email do usuário autenticado). Chave normal → tabela apiKey.
if (isMasterKey(apiKey)) {
const email = (req.headers['x-nw-email'] as string | undefined)?.trim().toLowerCase()
if (!email) {
socket.write('HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nx-nw-email ausente (chave mestra)')
socket.destroy()
return
}
tenantId = await resolveMasterEmail(prisma, email)
} else {
tenantId = await resolveApiKey(prisma, apiKey)
}
} catch {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n')
socket.destroy()
@@ -67,7 +79,7 @@ export function buildWsBridge(
}
if (!tenantId) {
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nChave inválida, inativa ou expirada')
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nChave inválida/inativa ou conta não encontrada')
socket.destroy()
return
}