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 (
-
-
+
+
Credenciais alteradas no banco
-
{description}
+
{description}