diff --git a/.gitignore b/.gitignore index 99fb4d6cd..f02e6dda9 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ e2e/.auth/ # TypeScript incremental build artifacts *.tsbuildinfo + +# Deno lock files — generated por `deno check`/`deno test` localmente. +# Edge functions são deployadas pelo Lovable Cloud sem necessidade de +# pinning reproduzível, e CI sempre faz fresh resolution via deno.json. +deno.lock +**/deno.lock diff --git a/docs/RUNBOOK_CONNECTIONS.md b/docs/RUNBOOK_CONNECTIONS.md new file mode 100644 index 000000000..cd230032b --- /dev/null +++ b/docs/RUNBOOK_CONNECTIONS.md @@ -0,0 +1,188 @@ +# 🔌 Runbook — Módulo `/admin/conexoes` + +Guia operacional do módulo de Conexões: diagnóstico, rotação de credenciais, +auto-test cron, e interpretação de logs. + +--- + +## 🩺 Sintoma → Causa → Ação + +### "Carregando..." infinito no seletor de empresas (front) + +**Sintoma:** UI fica travada em "Carregando..." por 15-20s antes de ficar vazio. + +**Causa raiz:** `crm-db-bridge` não consegue resolver `EXTERNAL_CRM_URL` / +`EXTERNAL_CRM_SERVICE_ROLE_KEY`. React Query reentra 3× em cada erro 500. + +**Diagnóstico:** + +```sql +-- 1. Confirmar que credenciais existem no DB +SELECT secret_name, secret_source, is_active + FROM public.integration_credentials + WHERE secret_name LIKE 'EXTERNAL_CRM%'; + +-- 2. Ver últimos erros do bridge nos logs estruturados +-- Filtrar por evt='credential_resolved' e source='none' +``` + +**Ação:** + +1. Cadastrar credenciais via `/admin/conexoes` ou diretamente: + ```sql + -- Via secrets-manager (preferido) + -- POST /functions/v1/secrets-manager + -- { "action": "set", "name": "EXTERNAL_CRM_URL", "value": "https://..." } + ``` +2. Aliases legados (`CRM_SUPABASE_URL`) também funcionam como fallback via env. + +--- + +### Banner "Credenciais alteradas" não mostra qual secret mudou + +**Causa raiz:** payload realtime usa coluna `secret_name`, não `name`. +Foi corrigido em PR #70 (`CredentialsChangedBanner.tsx`). + +**Validar:** alterar uma credencial e verificar que o toast mostra o nome. + +--- + +### `AutoTestJobStatusCard` sempre "untested" + +**Causa raiz:** cron `connections-auto-test` órfão (sem schedule). + +**Diagnóstico:** + +```sql +SELECT jobname, schedule, active + FROM cron.job + WHERE jobname = 'connections-auto-test'; +-- Se vazio: migration 20260429163414_* não foi aplicada +``` + +**Ação:** + +```sql +-- Aplicar a migration ou rodar manualmente: +SELECT cron.schedule( + 'connections-auto-test', + '*/15 * * * *', + $$ SELECT net.http_post(...); $$ +); +``` + +**Validar intervalo configurado:** + +```sql +SELECT public.get_connections_auto_test_interval(); +-- Deve retornar 15 (default) ou outro valor configurado via UI. +``` + +--- + +## 🔐 Rotação de Credenciais CRM + +1. **Provedor:** gerar nova `service_role` key no dashboard Supabase do CRM. +2. **Aplicar:** `/admin/conexoes` → Tab "Supabase" → Card CRM → "Rotacionar". +3. **Verificar:** seletor de empresas no front carrega em <3s. +4. **Auditar:** linha em `secret_rotation_log` com user_id e timestamp. + +> Cache TTL é 60s — propagação para edge functions é praticamente instantânea +> via `invalidateCredentialCache()` chamado pelo secrets-manager após rotação. + +--- + +## 🩺 Endpoints de Diagnóstico do `crm-db-bridge` + +Todos com **bypass de auth** — operadores precisam diagnosticar mesmo com JWT +quebrado ou breaker aberto. + +``` +GET /functions/v1/crm-db-bridge?op=ping → liveness +GET /functions/v1/crm-db-bridge?op=diag → boot/runtime metrics +GET /functions/v1/crm-db-bridge?op=breaker_status → circuit breaker +GET /functions/v1/crm-db-bridge?op=creds_health → resolução das credenciais +``` + +Exemplo de `creds_health` em estado saudável: + +```json +{ + "ok": true, "ts": 1745000000000, "health": "healthy", + "credentials": [ + { "name": "EXTERNAL_CRM_URL", "present": true, + "source": "db", "via_alias": false, "resolved_name": "EXTERNAL_CRM_URL", + "value_length": 41, "suffix4": "e.co" }, + { "name": "EXTERNAL_CRM_SERVICE_ROLE_KEY", "present": true, + "source": "db", "via_alias": false, "resolved_name": "EXTERNAL_CRM_SERVICE_ROLE_KEY", + "value_length": 219, "suffix4": "x9wA" }, + { "name": "EXTERNAL_CRM_ANON_KEY", "present": true, + "source": "env", "via_alias": true, "resolved_name": "CRM_SUPABASE_ANON_KEY", + "value_length": 219, "suffix4": "g4V0" } + ] +} +``` + +| `health` | Significado | Ação | +|---|---|---| +| `healthy` | URL + 1 key presentes | nenhuma | +| `degraded` | só URL ou só key | cadastrar a parte faltante | +| `missing` | sem URL | bridge fora do ar — cadastrar credenciais | + +--- + +## 📊 Logs Estruturados + +### `connections-auto-test` + +| Evento | Payload | +|---|---| +| `auto-test` | `{type, name, ok, status, latency_ms, attempts, retried, error}` | +| `auto-test-summary` | `{tested, ok_count, failed, retried, recovered, duration_ms}` | +| `auto-test-error` | `{id, type, error}` (erro por conexão) | +| `auto-test-fatal` | `{error}` (erro global, batch abortado) | + +### `_shared/credentials.ts` + +| Evento | Payload | +|---|---| +| `credential_resolved` | `{name, resolved_name, source, has_value, cached, duration_ms, via_alias}` | + +> `source: "none"` indica credencial não encontrada — alarme precoce. +> Desabilitar verbosidade: `LOG_CREDENTIAL_RESOLUTION=off` no env. + +--- + +## 🛡️ RLS de `integration_credentials` + +Policies aceitam role `admin` **OU** `dev` (alinhado ao `secrets-manager`). +Migration: `20260429163441_align_integration_credentials_rls_with_dev.sql`. + +```sql +-- Validar policies +SELECT policyname, cmd FROM pg_policies + WHERE tablename = 'integration_credentials'; +-- 4 linhas esperadas: SELECT, INSERT, UPDATE, DELETE +``` + +--- + +## 🔁 Edge Functions que dependem de credenciais CRM + +| Função | Aliases consumidos | Impacto se faltar | +|---|---|---| +| `crm-db-bridge` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY`, `_ANON_KEY` | front "Carregando..." | +| `quote-sync` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | sync de orçamentos pausa | +| `expert-chat` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | chat sem contexto de cliente | +| `quote-public-view` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | link público quebra | + +Todas usam `resolveCredential()` — DB-first, env como fallback. + +--- + +## 📚 Referências + +- `supabase/functions/_shared/credentials.ts` — SSOT de resolução +- `supabase/migrations/20260429163414_*.sql` — schedule do cron +- `supabase/migrations/20260429163441_*.sql` — RLS para dev +- `docs/RUNBOOK.md` — runbook geral diff --git a/scripts/typecheck-edge-functions.mjs b/scripts/typecheck-edge-functions.mjs index fc18e8087..d1d40bc17 100644 --- a/scripts/typecheck-edge-functions.mjs +++ b/scripts/typecheck-edge-functions.mjs @@ -66,7 +66,17 @@ function checkFunction(fn) { // `deno check` is the dedicated typecheck command. It's faster than // `deno cache` and doesn't execute code. We pass all files at once so // shared types within the function are resolved together. - const result = spawnSync("deno", ["check", ...files], { + // + // If the function has a local deno.json (with import map for npm:/jsr: + // bare specifiers), pass it via --config so imports like + // `import { Hono } from "hono"` resolve. Without this, bare specifiers + // fail with: Relative import path "X" not prefixed with / or ./ or ../ + const localConfig = join(fnDir, "deno.json"); + const args = ["check"]; + if (existsSync(localConfig)) args.push("--config", localConfig); + args.push(...files); + + const result = spawnSync("deno", args, { encoding: "utf8", env: { ...process.env, NO_COLOR: "1" }, }); diff --git a/src/components/admin/connections/CredentialsChangedBanner.tsx b/src/components/admin/connections/CredentialsChangedBanner.tsx index 9569a5572..bc8f72249 100644 --- a/src/components/admin/connections/CredentialsChangedBanner.tsx +++ b/src/components/admin/connections/CredentialsChangedBanner.tsx @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Loader2, RefreshCw, X } from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; -import { useSecretsManager } from "@/hooks/useSecretsManager"; -import { Button } from "@/components/ui/button"; -import { toast } from "sonner"; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2, RefreshCw, X } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { useSecretsManager } from '@/hooks/useSecretsManager'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; interface CredentialsChangedBannerProps { /** Callback executed in parallel with cache invalidation + secret list refresh. */ @@ -12,7 +12,7 @@ interface CredentialsChangedBannerProps { interface PendingChange { count: number; - lastEvent: "INSERT" | "UPDATE" | "DELETE"; + lastEvent: 'INSERT' | 'UPDATE' | 'DELETE'; lastName: string | null; lastAt: number; } @@ -35,16 +35,19 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann useEffect(() => { const channel = supabase - .channel("admin-conexoes-credentials-changes") + .channel('admin-conexoes-credentials-changes') .on( - "postgres_changes", - { event: "*", schema: "public", table: "integration_credentials" }, + 'postgres_changes', + { event: '*', schema: 'public', table: 'integration_credentials' }, (payload) => { - const event = payload.eventType as "INSERT" | "UPDATE" | "DELETE"; + const event = payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE'; + // Coluna real em integration_credentials é `secret_name` — + // typo histórico (`name`) escondia o nome do secret alterado + // no aviso de auto-refresh. const row = - (payload.new as { name?: string } | null) ?? - (payload.old as { name?: string } | null); - const name = row?.name ?? null; + (payload.new as { secret_name?: string } | null) ?? + (payload.old as { secret_name?: string } | null); + const name = row?.secret_name ?? null; setPending((prev) => ({ count: (prev?.count ?? 0) + 1, lastEvent: event, @@ -71,19 +74,19 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann Promise.resolve().then(() => onRefreshedRef.current?.()), ]); const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); - const cacheOk = cacheRes.status === "fulfilled" && cacheRes.value.ok; - const listOk = listRes.status === "fulfilled"; - const hookOk = hookRes.status === "fulfilled"; + const cacheOk = cacheRes.status === 'fulfilled' && cacheRes.value.ok; + const listOk = listRes.status === 'fulfilled'; + const hookOk = hookRes.status === 'fulfilled'; const credCount = listOk && Array.isArray(listRes.value) ? listRes.value.length : 0; const okCount = [cacheOk, listOk, hookOk].filter(Boolean).length; if (okCount === 3) { - toast.success("Status e cards atualizados", { + toast.success('Status e cards atualizados', { description: `Cache invalidado · ${credCount} credenciais relidas (${elapsed}s)`, }); setPending(null); } else { - toast.warning("Atualização parcial", { + toast.warning('Atualização parcial', { description: `Algumas operações falharam (${elapsed}s)`, }); } @@ -95,16 +98,16 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann if (!pending) return null; const eventLabel = - pending.lastEvent === "INSERT" - ? "criada" - : pending.lastEvent === "DELETE" - ? "removida" - : "alterada"; + pending.lastEvent === 'INSERT' + ? 'criada' + : pending.lastEvent === 'DELETE' + ? 'removida' + : 'alterada'; const description = pending.count === 1 - ? `Credencial ${pending.lastName ? `"${pending.lastName}" ` : ""}${eventLabel} no banco.` - : `${pending.count} alterações detectadas em credenciais (última: ${pending.lastName ?? "—"}, ${eventLabel}).`; + ? `Credencial ${pending.lastName ? `"${pending.lastName}" ` : ''}${eventLabel} no banco.` + : `${pending.count} alterações detectadas em credenciais (última: ${pending.lastName ?? '—'}, ${eventLabel}).`; return (
-