Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes

Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
@@ -0,0 +1,632 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: twilio-whatsapp-provision
|--------------------------------------------------------------------------
| Gerencia o ciclo de vida das subcontas Twilio WhatsApp por tenant.
|
| Ações disponíveis (campo `action` no body):
| provision — Cria subconta + provisiona número + configura webhook
| deprovision — Libera número + fecha subconta
| suspend — Suspende subconta (bloqueia envio mas mantém dados)
| reactivate — Reativa subconta suspensa
| sync_usage — Sincroniza consumo da subconta com twilio_subaccount_usage
| search_numbers— Lista números disponíveis para compra
| test_send — Envia mensagem de teste via subconta
|
| Env vars necessárias (Supabase secrets):
| TWILIO_ACCOUNT_SID — Master account SID
| TWILIO_AUTH_TOKEN — Master auth token
| TWILIO_WHATSAPP_WEBHOOK — URL base do webhook (ex: https://xyz.supabase.co/functions/v1/twilio-whatsapp-webhook)
| SUPABASE_URL
| SUPABASE_SERVICE_ROLE_KEY
| USD_BRL_RATE — Taxa de câmbio para calcular preço em BRL (ex: 5.20)
| MARGIN_MULTIPLIER — Multiplicador de margem (ex: 1.4 = +40% sobre custo)
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
}
// ── Twilio API helper ──────────────────────────────────────────────────────
class TwilioClient {
private masterSid: string
private masterToken: string
private baseUrl: string
constructor(sid: string, token: string) {
this.masterSid = sid
this.masterToken = token
this.baseUrl = `https://api.twilio.com/2010-04-01`
}
private authHeader(sid?: string, token?: string): string {
return 'Basic ' + btoa(`${sid ?? this.masterSid}:${token ?? this.masterToken}`)
}
async request(
method: string,
path: string,
params?: Record<string, string>,
subSid?: string,
subToken?: string
): Promise<Record<string, unknown>> {
const url = `${this.baseUrl}${path}`
const auth = this.authHeader(subSid, subToken)
const opts: RequestInit = {
method,
headers: {
'Authorization': auth,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
if (params && method !== 'GET') {
opts.body = new URLSearchParams(params).toString()
}
const finalUrl = (method === 'GET' && params)
? url + '?' + new URLSearchParams(params).toString()
: url
const res = await fetch(finalUrl, opts)
const data = await res.json()
if (!res.ok) {
const msg = (data as { message?: string }).message || `Twilio HTTP ${res.status}`
const code = (data as { code?: number }).code ?? res.status
throw new TwilioError(msg, code, res.status)
}
return data as Record<string, unknown>
}
// Cria subconta
async createSubaccount(friendlyName: string) {
return this.request('POST', '/Accounts.json', { FriendlyName: friendlyName })
}
// Atualiza status da subconta (suspended | active | closed)
async updateSubaccountStatus(subSid: string, status: string) {
return this.request('POST', `/Accounts/${subSid}.json`, { Status: status })
}
// Busca números disponíveis em um país
async searchNumbers(country: string, areaCode?: string, smsEnabled = true) {
const params: Record<string, string> = {
SmsEnabled: String(smsEnabled),
Limit: '20',
}
if (areaCode) params.AreaCode = areaCode
return this.request('GET', `/Accounts/${this.masterSid}/AvailablePhoneNumbers/${country}/Local.json`, params)
}
// Compra número para a subconta
async buyNumber(subSid: string, phoneNumber: string, webhookUrl: string, subToken: string) {
return this.request('POST', `/Accounts/${subSid}/IncomingPhoneNumbers.json`, {
PhoneNumber: phoneNumber,
SmsUrl: webhookUrl,
SmsMethod: 'POST',
StatusCallback: webhookUrl,
}, subSid, subToken)
}
// Libera número da subconta
async releaseNumber(subSid: string, phoneSid: string, subToken: string) {
return this.request('DELETE', `/Accounts/${subSid}/IncomingPhoneNumbers/${phoneSid}.json`, undefined, subSid, subToken)
}
// Envia mensagem WhatsApp via subconta
async sendWhatsApp(subSid: string, subToken: string, from: string, to: string, body: string) {
return this.request('POST', `/Accounts/${subSid}/Messages.json`, {
From: `whatsapp:${from}`,
To: `whatsapp:${to}`,
Body: body,
}, subSid, subToken)
}
// Busca registros de uso da subconta
async getUsageRecords(subSid: string, subToken: string, startDate: string, endDate: string) {
return this.request('GET', `/Accounts/${subSid}/Usage/Records.json`, {
Category: 'sms-outbound',
StartDate: startDate,
EndDate: endDate,
}, subSid, subToken)
}
}
class TwilioError extends Error {
code: number
httpStatus: number
constructor(message: string, code: number, httpStatus: number) {
super(message)
this.name = 'TwilioError'
this.code = code
this.httpStatus = httpStatus
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function ok(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
function err(message: string, status = 400, detail?: unknown) {
return new Response(JSON.stringify({ error: message, detail }), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// ── Main handler ──────────────────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
if (req.method !== 'POST') return err('Método não permitido', 405)
// ── Auth: verifica JWT do usuário ──────────────────────────────
const authHeader = req.headers.get('authorization') ?? ''
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
)
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Verifica autenticação
const { data: { user }, error: authErr } = await supabase.auth.getUser()
if (authErr || !user) return err('Não autorizado', 401)
// ── Parse body ──────────────────────────────────────────────────
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return err('JSON inválido', 400)
}
const action = body.action as string
const tenantId = body.tenant_id as string | undefined
if (!action) return err('Campo `action` obrigatório', 400)
// ── Config: prioriza tabela saas_twilio_config; cai pra env como fallback ──
// AUTH_TOKEN é sempre env (nunca DB) por segurança.
let cfgFromDb: Record<string, unknown> = {}
try {
const { data: cfg } = await supabaseAdmin.rpc('get_twilio_config')
if (cfg && typeof cfg === 'object') cfgFromDb = cfg as Record<string, unknown>
} catch (_e) { /* tabela ainda não existe ou primeiro deploy → fallback puro env */ }
const masterSid = (cfgFromDb.account_sid as string) || Deno.env.get('TWILIO_ACCOUNT_SID') || ''
const masterToken = Deno.env.get('TWILIO_AUTH_TOKEN') || ''
const webhookBase = (cfgFromDb.whatsapp_webhook_url as string) || Deno.env.get('TWILIO_WHATSAPP_WEBHOOK') || ''
const usdBrlRate = Number(cfgFromDb.usd_brl_rate) || parseFloat(Deno.env.get('USD_BRL_RATE') ?? '5.5')
const marginMult = Number(cfgFromDb.margin_multiplier) || parseFloat(Deno.env.get('MARGIN_MULTIPLIER') ?? '1.4')
if (!masterSid) return err('TWILIO_ACCOUNT_SID não configurado. Configure em /saas/twilio-config no painel.', 500)
if (!masterToken) return err('TWILIO_AUTH_TOKEN não configurado. Rode: supabase secrets set TWILIO_AUTH_TOKEN=...', 500)
const twilio = new TwilioClient(masterSid, masterToken)
// ── Verifica se é SaaS admin (para ações administrativas) ───────
async function isSaasAdmin(): Promise<boolean> {
const { data } = await supabaseAdmin
.from('saas_admins')
.select('id')
.eq('user_id', user.id)
.maybeSingle()
return !!data
}
// ── Busca canal do tenant ────────────────────────────────────────
async function getChannel(tid: string) {
const { data, error } = await supabaseAdmin
.from('notification_channels')
.select('*')
.eq('tenant_id', tid)
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.is('deleted_at', null)
.maybeSingle()
if (error) throw error
return data
}
// ── Busca owner_id do tenant ─────────────────────────────────────
async function getOwnerId(tid: string): Promise<string> {
const { data, error } = await supabaseAdmin
.from('tenant_members')
.select('user_id')
.eq('tenant_id', tid)
.in('role', ['tenant_admin', 'admin'])
.limit(1)
.single()
if (error) throw new Error(`Tenant não encontrado: ${error.message}`)
return data.user_id
}
// ══════════════════════════════════════════════════════════════════
// AÇÃO: provision
// ══════════════════════════════════════════════════════════════════
if (action === 'provision') {
if (!tenantId) return err('tenant_id obrigatório', 400)
if (!(await isSaasAdmin())) {
// Tenants também podem provisionar a si mesmos
const { data: member } = await supabaseAdmin
.from('tenant_members')
.select('tenant_id')
.eq('tenant_id', tenantId)
.eq('user_id', user.id)
.in('role', ['tenant_admin', 'admin'])
.maybeSingle()
if (!member) return err('Sem permissão', 403)
}
const phoneNumber = body.phone_number as string | undefined
const country = (body.country as string) ?? 'BR'
const displayName = body.display_name as string | undefined
const costPerMsgUsd = parseFloat((body.cost_per_message_usd as string) ?? '0.005')
const pricePerMsgBrl = parseFloat((body.price_per_message_brl as string) ?? String(costPerMsgUsd * usdBrlRate * marginMult))
try {
// Verifica se já existe canal twilio para este tenant
const existing = await getChannel(tenantId)
if (existing?.twilio_subaccount_sid) {
return err('Tenant já possui subconta Twilio provisionada', 409)
}
const ownerId = await getOwnerId(tenantId)
// 1. Cria subconta Twilio
const subaccountData = await twilio.createSubaccount(
`AgenciaPsi-${tenantId.slice(0, 8)}`
)
const subSid = subaccountData.sid as string
const subToken = subaccountData.auth_token as string
let phoneSid = ''
let finalNumber = phoneNumber ?? ''
// 2. Compra número (se informado ou busca disponível)
if (!finalNumber) {
// Busca primeiro número disponível no país
const available = await twilio.searchNumbers(country)
const numbers = (available.available_phone_numbers as Array<{ phone_number: string }>) ?? []
if (!numbers.length) throw new Error(`Nenhum número disponível em ${country}`)
finalNumber = numbers[0].phone_number
}
const webhookUrl = `${webhookBase}?tenant_id=${tenantId}`
// 3. Compra número na subconta
const numData = await twilio.buyNumber(subSid, finalNumber, webhookUrl, subToken)
phoneSid = numData.sid as string
// 4. Salva no banco
const channelData = {
tenant_id: tenantId,
owner_id: ownerId,
channel: 'whatsapp',
provider: 'twilio',
is_active: true,
display_name: displayName ?? `WhatsApp Twilio — ${tenantId.slice(0, 8)}`,
sender_address: finalNumber,
connection_status: 'connected',
credentials: { subaccount_auth_token: subToken },
twilio_subaccount_sid: subSid,
twilio_phone_number: finalNumber,
twilio_phone_sid: phoneSid,
webhook_url: webhookUrl,
cost_per_message_usd: costPerMsgUsd,
price_per_message_brl: pricePerMsgBrl,
provisioned_at: new Date().toISOString(),
}
let savedChannel
if (existing?.id) {
const { data, error } = await supabaseAdmin
.from('notification_channels')
.update(channelData)
.eq('id', existing.id)
.select('*')
.single()
if (error) throw error
savedChannel = data
} else {
const { data, error } = await supabaseAdmin
.from('notification_channels')
.insert(channelData)
.select('*')
.single()
if (error) throw error
savedChannel = data
}
return ok({
success: true,
channel: savedChannel,
message: `Subconta Twilio provisionada com número ${finalNumber}`,
})
} catch (e) {
console.error('[provision] erro:', e)
return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500)
}
}
// ══════════════════════════════════════════════════════════════════
// AÇÃO: deprovision
// ══════════════════════════════════════════════════════════════════
if (action === 'deprovision') {
if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem deprovisionar', 403)
const channelId = body.channel_id as string
if (!channelId) return err('channel_id obrigatório', 400)
try {
const { data: ch, error } = await supabaseAdmin
.from('notification_channels')
.select('*')
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
const subToken = ch.credentials?.subaccount_auth_token as string
// 1. Libera número
if (ch.twilio_phone_sid && subToken) {
try {
await twilio.releaseNumber(ch.twilio_subaccount_sid, ch.twilio_phone_sid, subToken)
} catch (e) {
console.warn('[deprovision] Erro ao liberar número (ignorado):', e.message)
}
}
// 2. Fecha subconta
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, 'closed')
// 3. Soft-delete do canal
const { error: delErr } = await supabaseAdmin
.from('notification_channels')
.update({
deleted_at: new Date().toISOString(),
is_active: false,
connection_status: 'disconnected',
})
.eq('id', channelId)
if (delErr) throw delErr
return ok({ success: true, message: 'Subconta encerrada e canal removido' })
} catch (e) {
console.error('[deprovision] erro:', e)
return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500)
}
}
// ══════════════════════════════════════════════════════════════════
// AÇÃO: suspend / reactivate
// ══════════════════════════════════════════════════════════════════
if (action === 'suspend' || action === 'reactivate') {
if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem suspender/reativar', 403)
const channelId = body.channel_id as string
if (!channelId) return err('channel_id obrigatório', 400)
try {
const { data: ch, error } = await supabaseAdmin
.from('notification_channels')
.select('*')
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
const twilioStatus = action === 'suspend' ? 'suspended' : 'active'
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, twilioStatus)
await supabaseAdmin
.from('notification_channels')
.update({
is_active: action === 'reactivate',
connection_status: action === 'reactivate' ? 'connected' : 'disconnected',
})
.eq('id', channelId)
return ok({
success: true,
message: action === 'suspend' ? 'Subconta suspensa' : 'Subconta reativada',
})
} catch (e) {
console.error(`[${action}] erro:`, e)
return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500)
}
}
// ══════════════════════════════════════════════════════════════════
// AÇÃO: sync_usage
// ══════════════════════════════════════════════════════════════════
if (action === 'sync_usage') {
if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem sincronizar uso', 403)
const channelId = body.channel_id as string
const now = new Date()
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
const endDate = now.toISOString().split('T')[0]
try {
// Busca todos os canais twilio (ou somente o especificado)
const query = supabaseAdmin
.from('notification_channels')
.select('id, tenant_id, twilio_subaccount_sid, credentials, cost_per_message_usd, price_per_message_brl')
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.is('deleted_at', null)
.not('twilio_subaccount_sid', 'is', null)
if (channelId) query.eq('id', channelId)
const { data: channels, error } = await query
if (error) throw error
const synced = []
for (const ch of channels ?? []) {
try {
const subToken = ch.credentials?.subaccount_auth_token as string
if (!subToken) continue
// Busca mensagens enviadas no mês via notification_logs
const { data: logs } = await supabaseAdmin
.from('notification_logs')
.select('status, estimated_cost_brl')
.eq('tenant_id', ch.tenant_id)
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.gte('created_at', startDate)
.lte('created_at', endDate + 'T23:59:59Z')
const sent = logs?.length ?? 0
const delivered = logs?.filter(l => l.status === 'delivered' || l.status === 'read').length ?? 0
const failed = logs?.filter(l => l.status === 'failed').length ?? 0
const costBrl = logs?.reduce((sum, l) => sum + parseFloat(String(l.estimated_cost_brl ?? 0)), 0) ?? 0
const costUsd = costBrl / usdBrlRate
const revBrl = sent * (ch.price_per_message_brl ?? 0)
// Upsert no twilio_subaccount_usage
const { error: upsertErr } = await supabaseAdmin
.from('twilio_subaccount_usage')
.upsert({
tenant_id: ch.tenant_id,
channel_id: ch.id,
twilio_subaccount_sid: ch.twilio_subaccount_sid,
period_start: startDate,
period_end: endDate,
messages_sent: sent,
messages_delivered: delivered,
messages_failed: failed,
cost_usd: costUsd,
cost_brl: costBrl,
revenue_brl: revBrl,
usd_brl_rate: usdBrlRate,
synced_at: new Date().toISOString(),
}, {
onConflict: 'channel_id,period_start,period_end',
})
if (upsertErr) throw upsertErr
synced.push({ channel_id: ch.id, sent, delivered, failed })
} catch (e) {
console.warn(`[sync_usage] canal ${ch.id}:`, e.message)
}
}
return ok({ success: true, synced })
} catch (e) {
console.error('[sync_usage] erro:', e)
return err(e.message, 500)
}
}
// ══════════════════════════════════════════════════════════════════
// AÇÃO: search_numbers
// ══════════════════════════════════════════════════════════════════
if (action === 'search_numbers') {
if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem buscar números', 403)
const country = (body.country as string) ?? 'US'
const areaCode = body.area_code as string | undefined
try {
const result = await twilio.searchNumbers(country, areaCode)
const numbers = (result.available_phone_numbers as Array<{
phone_number: string
friendly_name: string
locality: string
region: string
capabilities: Record<string, boolean>
}>) ?? []
return ok({
numbers: numbers.map(n => ({
phone_number: n.phone_number,
friendly_name: n.friendly_name,
locality: n.locality,
region: n.region,
sms: n.capabilities?.sms ?? false,
mms: n.capabilities?.mms ?? false,
voice: n.capabilities?.voice ?? false,
}))
})
} catch (e) {
return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500)
}
}
// ══════════════════════════════════════════════════════════════════
// AÇÃO: test_send
// ══════════════════════════════════════════════════════════════════
if (action === 'test_send') {
const channelId = body.channel_id as string
const toNumber = body.to as string
const message = (body.message as string) ?? 'Mensagem de teste — AgenciaPsi'
if (!channelId || !toNumber) return err('channel_id e to são obrigatórios', 400)
// Tenant pode testar o seu próprio canal; admin pode testar qualquer um
try {
const { data: ch, error } = await supabaseAdmin
.from('notification_channels')
.select('*')
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
// Verifica permissão: próprio tenant ou admin
const admin = await isSaasAdmin()
if (!admin) {
const { data: member } = await supabaseAdmin
.from('tenant_members')
.select('tenant_id')
.eq('tenant_id', ch.tenant_id)
.eq('user_id', user.id)
.maybeSingle()
if (!member) return err('Sem permissão', 403)
}
const subToken = ch.credentials?.subaccount_auth_token as string
if (!ch.twilio_subaccount_sid || !subToken) return err('Canal não provisionado', 400)
const result = await twilio.sendWhatsApp(
ch.twilio_subaccount_sid,
subToken,
ch.twilio_phone_number,
toNumber,
message
)
return ok({
success: true,
message_sid: result.sid,
status: result.status,
})
} catch (e) {
return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500)
}
}
return err(`Ação desconhecida: ${action}`, 400)
})