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,346 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI SaaS / Twilio Config
|--------------------------------------------------------------------------
| Painel para editar config operacional Twilio (account_sid, webhook URL,
| cotação, margem) sem precisar de redeploy.
|
| AUTH_TOKEN continua em env var por segurança painel mostra status.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Textarea from 'primevue/textarea';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import Accordion from 'primevue/accordion';
import AccordionPanel from 'primevue/accordionpanel';
import AccordionHeader from 'primevue/accordionheader';
import AccordionContent from 'primevue/accordioncontent';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const loading = ref(false);
const saving = ref(false);
const helpOpen = ref(true);
const form = ref({
account_sid: '',
whatsapp_webhook_url: '',
usd_brl_rate: 5.5,
margin_multiplier: 1.4,
notes: ''
});
const meta = ref({ updated_at: null, updated_by: null });
// Status do AUTH_TOKEN: tentamos uma chamada que NÃO mexe em nada (sync com canal inválido)
// e checamos se a edge devolve "TWILIO_AUTH_TOKEN não configurado". Se sim, env tá vazia.
const tokenStatus = ref('unknown'); // 'configured' | 'missing' | 'unknown'
const tokenChecking = ref(false);
const sidValid = computed(() => !form.value.account_sid || /^AC[a-zA-Z0-9]{32}$/.test(form.value.account_sid));
const urlValid = computed(() => !form.value.whatsapp_webhook_url || /^https?:\/\//.test(form.value.whatsapp_webhook_url));
async function loadConfig() {
loading.value = true;
try {
const { data, error } = await supabase.rpc('get_twilio_config');
if (error) throw error;
if (data) {
form.value = {
account_sid: data.account_sid || '',
whatsapp_webhook_url: data.whatsapp_webhook_url || '',
usd_brl_rate: Number(data.usd_brl_rate) || 5.5,
margin_multiplier: Number(data.margin_multiplier) || 1.4,
notes: data.notes || ''
};
meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha config', life: 4000 });
} finally {
loading.value = false;
}
}
async function saveConfig() {
if (!sidValid.value) {
toast.add({ severity: 'warn', summary: 'SID inválido', detail: 'O Account SID deve começar com AC seguido de 32 caracteres.', life: 4000 });
return;
}
if (!urlValid.value) {
toast.add({ severity: 'warn', summary: 'URL inválida', detail: 'O webhook deve começar com http:// ou https://', life: 4000 });
return;
}
saving.value = true;
try {
const { data, error } = await supabase.rpc('update_twilio_config', {
p_account_sid: form.value.account_sid || null,
p_whatsapp_webhook_url: form.value.whatsapp_webhook_url || null,
p_usd_brl_rate: form.value.usd_brl_rate,
p_margin_multiplier: form.value.margin_multiplier,
p_notes: form.value.notes || null
});
if (error) throw error;
if (data) meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada. A edge function lê do banco a cada chamada.', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha ao salvar', life: 4500 });
} finally {
saving.value = false;
}
}
async function checkTokenStatus() {
tokenChecking.value = true;
try {
// Chamada inofensiva: action inválida → edge function ainda checa env vars antes
// Se devolver "TWILIO_AUTH_TOKEN não configurado" → env vazia
// Se devolver outro erro (ex: "action obrigatória" ou "Não autorizado") → env tem valor
const { data, error } = await supabase.functions.invoke('twilio-whatsapp-provision', {
body: { action: 'sync_usage' }
});
let msg = '';
if (error) {
try {
const ctxBody = await error.context?.json?.();
msg = ctxBody?.error || error.message || '';
} catch {
msg = error.message || '';
}
} else if (data?.error) {
msg = data.error;
}
if (/AUTH_TOKEN.*não configurado/i.test(msg)) {
tokenStatus.value = 'missing';
} else {
// qualquer outro erro (incl. ACCOUNT_SID, "action", etc) → token está setado
tokenStatus.value = 'configured';
}
} catch {
tokenStatus.value = 'unknown';
} finally {
tokenChecking.value = false;
}
}
const tokenSeverity = computed(() => ({
configured: 'success',
missing: 'danger',
unknown: 'secondary'
})[tokenStatus.value]);
const tokenLabel = computed(() => ({
configured: 'configurado ✓',
missing: 'não configurado',
unknown: 'desconhecido (clique em "Verificar")'
})[tokenStatus.value]);
onMounted(async () => {
await loadConfig();
});
</script>
<template>
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1.1rem] font-bold tracking-tight">Configuração Twilio</div>
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">
Edite a config operacional sem redeploy. O <b>Auth Token</b> (secret) continua em variável de ambiente da Edge Function por segurança.
</div>
<div v-if="meta.updated_at" class="text-xs text-[var(--text-color-secondary)] mt-2">
Última atualização: {{ new Date(meta.updated_at).toLocaleString('pt-BR') }}
</div>
</div>
<!-- Card explicativo -->
<div class="rounded-md border border-indigo-400/30 bg-indigo-400/5">
<Accordion :value="helpOpen ? '0' : null" @update:value="(v) => helpOpen = (v === '0')">
<AccordionPanel value="0">
<AccordionHeader>
<div class="flex items-center gap-2">
<i class="pi pi-info-circle text-indigo-400" />
<span class="font-semibold">Como funciona guia rápido</span>
</div>
</AccordionHeader>
<AccordionContent>
<div class="flex flex-col gap-4 text-[0.92rem] leading-relaxed">
<section>
<div class="font-semibold mb-2">Onde cada coisa fica</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="rounded-md border border-emerald-400/30 bg-emerald-400/5 p-3">
<div class="font-medium text-emerald-300">No banco (editável aqui) </div>
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
<li><b>Account SID</b> identificador público da conta</li>
<li><b>Webhook URL</b> endpoint que recebe callbacks do Twilio</li>
<li><b>Cotação USD/BRL</b> usada nos cálculos de custo</li>
<li><b>Multiplicador de margem</b> markup aplicado ao preço por mensagem</li>
</ul>
</div>
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-3">
<div class="font-medium text-amber-300">Em env var (CLI obrigatório) 🔒</div>
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
<li><b>TWILIO_AUTH_TOKEN</b> secret que envia mensagens</li>
</ul>
<div class="text-xs text-[var(--text-color-secondary)] mt-2">
Mantido isolado pra reduzir superfície de ataque. Se vazar, atacante manda WhatsApp/SMS na sua conta até estourar limite.
</div>
</div>
</div>
</section>
<section>
<div class="font-semibold mb-2">Como configurar do zero</div>
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
<li>Crie a conta master em <a href="https://www.twilio.com/console" target="_blank" class="underline text-indigo-300">twilio.com/console</a> e copie o <b>Account SID</b> + <b>Auth Token</b>.</li>
<li>
Configure o secret na Edge Function:
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=seu_token_aqui</pre>
</li>
<li>
Cole o Account SID no campo abaixo (formato <code class="px-1 rounded bg-black/30 text-[11px]">AC + 32 chars</code>).
</li>
<li>
Configure o webhook URL apontando pra sua função de webhook:
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">https://&lt;projeto&gt;.supabase.co/functions/v1/twilio-whatsapp-webhook</pre>
</li>
<li>Ajuste cotação USD/BRL e multiplicador de margem conforme sua estratégia comercial.</li>
<li>Salve. As Edge Functions vão ler a config nova a cada chamada (sem redeploy).</li>
</ol>
</section>
<section>
<div class="font-semibold mb-2">Por que o Auth Token não fica aqui</div>
<p class="text-[var(--text-color-secondary)]">
Decisão de segurança: dados editáveis no painel ficam no banco com RLS estrita (saas_admin). Mas o Auth Token é o único que poder de <b>gerar custos reais</b> (envio de SMS/WhatsApp). Mantê-lo isolado em env var significa que mesmo um vazamento do banco (backup público acidental, RLS bug, dump em dev) não compromete o token.
</p>
<p class="text-[var(--text-color-secondary)] mt-2">
O painel mostra apenas <b>se o token está configurado</b> (/) sem nunca ler ou expor o valor.
</p>
</section>
<section>
<div class="font-semibold mb-2">Como testar depois de salvar</div>
<ol class="list-decimal pl-6 space-y-1 text-[var(--text-color-secondary)]">
<li> em <span class="font-mono text-xs">/saas/twilio-whatsapp</span></li>
<li>Clique em "Sincronizar uso" deve devolver "0 canais atualizados" se ainda não subcontas, sem erro.</li>
<li>Provisione uma subconta de teste pra um tenant.</li>
<li>Use o botão de envio de teste pra validar o pipeline completo.</li>
</ol>
</section>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
<!-- Status do AUTH_TOKEN -->
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="font-semibold flex items-center gap-2">
<i class="pi pi-key text-amber-400" />
TWILIO_AUTH_TOKEN (env var)
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
Secret que autentica todas as chamadas pra API do Twilio. Não armazenado no banco.
</div>
</div>
<div class="flex items-center gap-3">
<Tag :value="tokenLabel" :severity="tokenSeverity" />
<Button label="Verificar" icon="pi pi-search" size="small" outlined :loading="tokenChecking" @click="checkTokenStatus" />
</div>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
<b>Pra trocar:</b>
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=novo_token</pre>
Depois redeploy as Edge Functions afetadas (ou aguarde restart automático).
</div>
</div>
<!-- Form -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold mb-4">Configuração editável</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="text-xs text-[var(--text-color-secondary)]">Account SID (formato AC + 32 chars)</label>
<InputText
v-model="form.account_sid"
placeholder="AC..."
class="w-full mt-1"
:invalid="!sidValid"
:disabled="loading || saving"
/>
<div v-if="!sidValid" class="text-xs text-rose-400 mt-1">SID precisa começar com AC seguido de 32 caracteres alfanuméricos.</div>
</div>
<div class="md:col-span-2">
<label class="text-xs text-[var(--text-color-secondary)]">Webhook URL (callbacks de status do Twilio)</label>
<InputText
v-model="form.whatsapp_webhook_url"
placeholder="https://..."
class="w-full mt-1"
:invalid="!urlValid"
:disabled="loading || saving"
/>
<div v-if="!urlValid" class="text-xs text-rose-400 mt-1">URL deve começar com http:// ou https://</div>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)]">Cotação USD/BRL (1 USD vale)</label>
<InputNumber
v-model="form.usd_brl_rate"
:min="0.01"
:max="99.99"
:minFractionDigits="2"
:maxFractionDigits="4"
mode="decimal"
class="w-full mt-1"
:disabled="loading || saving"
/>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)]">Multiplicador de margem (1.0 = sem margem)</label>
<InputNumber
v-model="form.margin_multiplier"
:min="1"
:max="10"
:minFractionDigits="2"
:maxFractionDigits="4"
mode="decimal"
class="w-full mt-1"
:disabled="loading || saving"
/>
</div>
<div class="md:col-span-2">
<label class="text-xs text-[var(--text-color-secondary)]">Notas internas (não aparece pro tenant)</label>
<Textarea
v-model="form.notes"
rows="3"
class="w-full mt-1"
autoResize
:disabled="loading || saving"
/>
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-2">
<Button label="Recarregar" icon="pi pi-refresh" outlined :disabled="saving" :loading="loading" @click="loadConfig" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" :disabled="!sidValid || !urlValid" @click="saveConfig" />
</div>
</div>
</div>
</template>