From 9f3aa3356ad81e8ea443e1e07133e9d7ea20ca36 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sat, 22 Aug 2026 19:08:11 -0300 Subject: [PATCH 01/33] fix(agente): editor do rag_bot salvava sem nunca publicar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O editor legado de agente rag_bot (components/ai/AgentEditor.tsx, "caminho pré-EPIC-13") só gravava o rascunho em ai_agents. O runtime (lib/agent-engine/agent/agent-config.ts) le system_prompt/provider/ model da versao apontada por ai_agents.published_version_id, nunca do rascunho -- editar o prompt pela tela nao tinha efeito nenhum, em silencio. Medido numa instalacao real: o prompt generico do seed ("Voce e um(a) atendente virtual amigavel de uma loja online...") continuava ativo depois de varias edicoes salvas pela tela. fn_publish_ai_agent_version (0024/0025/0026) nao serve pra este caminho: exige credential_id nao-nulo na versao e canal WORKING -- nenhum dos dois e como rag_bot opera. fn_publish_rag_bot_version e o par minimo: copia os campos de infraestrutura da versao publicada atual (canal, ferramentas, orcamento -- nada disso e editavel pelo AgentEditor.tsx) e troca so system_prompt/provider/model, vindos do rascunho. provider nunca e hardcoded: resolve de organizations.settings.llm.provider, o mesmo campo que scripts/bootstrap-owner.ts grava a partir do AI_PROVIDER do instalador -- evita repetir o bug irmao (agente seedado direto com provider='anthropic' apesar da org ter escolhido OpenRouter). Botao "Publicar" novo no AgentEditor.tsx, desabilitado enquanto ha alteracao nao salva (publica sempre o que esta persistido, nunca o que esta so na tela). Verificado: pnpm typecheck/lint/test:unit/build limpos; pnpm test:db real (install + update + 839 testes de invariantes, incluindo isolamento RLS) verde; caminho feliz + 4 sabotagens + permissoes (anon/authenticated bloqueados, so service_role) testados manualmente contra fixtures sinteticas em pg17 efemero. Nao medido: pnpm test:e2e e imagens-ok (precisam de infraestrutura nao disponivel nesta maquina). Co-Authored-By: Claude Sonnet 5 --- .../ai/agents/[id]/publish-rag-bot/route.ts | 99 ++++++++++ components/ai/AgentEditor.tsx | 25 ++- hooks/ai/useAgent.ts | 35 ++++ lib/ai/agents/publish-rag-bot.test.ts | 88 +++++++++ lib/ai/agents/publish-rag-bot.ts | 89 +++++++++ supabase/baseline.sql | 150 ++++++++++++++++ ...180000_0168_fn_publish_rag_bot_version.sql | 170 ++++++++++++++++++ supabase/migrations/MANIFEST.md | 1 + 8 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 app/api/v1/ai/agents/[id]/publish-rag-bot/route.ts create mode 100644 lib/ai/agents/publish-rag-bot.test.ts create mode 100644 lib/ai/agents/publish-rag-bot.ts create mode 100644 supabase/migrations/20260822180000_0168_fn_publish_rag_bot_version.sql diff --git a/app/api/v1/ai/agents/[id]/publish-rag-bot/route.ts b/app/api/v1/ai/agents/[id]/publish-rag-bot/route.ts new file mode 100644 index 000000000..b8713f811 --- /dev/null +++ b/app/api/v1/ai/agents/[id]/publish-rag-bot/route.ts @@ -0,0 +1,99 @@ +/** + * POST /api/v1/ai/agents/:id/publish-rag-bot + * + * Publica o rascunho de um agente `rag_bot` — o editor legado + * (`components/ai/AgentEditor.tsx`) só salva em `ai_agents`, mas o runtime lê + * `system_prompt`/`provider`/`model` da versão publicada. Sem esta rota, editar + * pela tela não tem efeito nenhum (medido: fica respondendo com o prompt do + * bootstrap inicial, em silêncio). Ver `supabase/migrations/*_0168_*.sql`. + * + * Atomic flip via fn_publish_rag_bot_version: + * - versão publicada atual → 'superseded' + * - versão nova (system_prompt/provider/model do rascunho, resto copiado da + * atual) → 'published' + * - ai_agents.published_version_id → a nova + */ +import { randomUUID } from "node:crypto"; +import { type NextRequest } from "next/server"; + +import { ok, fail } from "@/lib/api/wrappers"; +import { audit } from "@/lib/audit"; +import { requireRole } from "@/lib/auth/require-role"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { publishRagBotVersion, RAG_BOT_PUBLISH_ERROR_CODES } from "@/lib/ai/agents/publish-rag-bot"; + +export const dynamic = "force-dynamic"; + +const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function POST(_req: NextRequest, ctx: Ctx): Promise { + const requestId = randomUUID(); + const { id } = await ctx.params; + if (!UUID_RX.test(id)) { + return fail("invalid_request", "id inválido.", 400, { requestId }); + } + + const authz = await requireRole("admin", { requestId, resource: "ai_agents" }); + if (!authz.ok) return authz.response; + const { user: authUser, org: activeOrg } = authz; + + const admin = createAdminClient(); + + const result = await publishRagBotVersion(admin, { + orgId: activeOrg.orgId, + agentId: id, + createdBy: authUser.id, + }); + + if (!result.ok) { + if (RAG_BOT_PUBLISH_ERROR_CODES.has(result.code)) { + const status = + result.code === "agent_not_found" || result.code === "version_not_found" + ? 404 + : 422; + return fail(result.code, "Validação de publish falhou.", status, { requestId }); + } + return fail("internal_error", "Erro ao publicar.", 500, { requestId }); + } + + void admin + .from("event_log") + .insert({ + organization_id: activeOrg.orgId, + event_type: "ai_agent.published", + payload: { + agent_id: result.agent_id, + version_id: result.version_id, + previous_version_id: result.previous_version_id, + published_at: result.published_at, + }, + }) + .then(({ error }) => { + if (error) console.error("[ai_agents/publish-rag-bot] event_log error", error.message); + }); + + void audit({ + action: "ai_agent.published", + actorUserId: authUser.id, + organizationId: activeOrg.orgId, + resourceType: "ai_agent", + resourceId: id, + requestId, + metadata: { + version_id: result.version_id, + previous_version_id: result.previous_version_id, + }, + }); + + return ok( + { + agent_id: result.agent_id, + version_id: result.version_id, + previous_version_id: result.previous_version_id, + published_at: result.published_at, + }, + { requestId }, + ); +} diff --git a/components/ai/AgentEditor.tsx b/components/ai/AgentEditor.tsx index f971b9a23..dd0f2435e 100644 --- a/components/ai/AgentEditor.tsx +++ b/components/ai/AgentEditor.tsx @@ -17,7 +17,12 @@ import { import { Textarea } from "@/components/ui/textarea"; import { GuardrailsEditor } from "@/components/ai/GuardrailsEditor"; import { SystemPromptEditor } from "@/components/ai/SystemPromptEditor"; -import { useAgent, useUpdateAgent, type AgentRow } from "@/hooks/ai/useAgent"; +import { + useAgent, + useUpdateAgent, + usePublishRagBotAgent, + type AgentRow, +} from "@/hooks/ai/useAgent"; import { AGENT_CONFIG_DEFAULTS, AGENT_MODELS, @@ -91,6 +96,7 @@ function diffPatch(initial: FormState, current: FormState): AgentPatch { export function AgentEditor({ agentId, initialData, readOnly = false }: Props) { const query = useAgent(agentId, { initialData }); const update = useUpdateAgent(agentId); + const publish = usePublishRagBotAgent(agentId); const agent = query.data; @@ -164,7 +170,13 @@ export function AgentEditor({ agentId, initialData, readOnly = false }: Props) { setFormState(baselineState); } - const disabled = readOnly || update.isPending; + async function handlePublish() { + await publish.mutateAsync().catch(() => { + // toast já mostrado em onError do hook + }); + } + + const disabled = readOnly || update.isPending || publish.isPending; return (
@@ -183,8 +195,17 @@ export function AgentEditor({ agentId, initialData, readOnly = false }: Props) { + + +
+

+ "Salvar" grava o rascunho. O agente só responde com o prompt/modelo salvos + depois de "Publicar". +

diff --git a/hooks/ai/useAgent.ts b/hooks/ai/useAgent.ts index 5e05079da..330614295 100644 --- a/hooks/ai/useAgent.ts +++ b/hooks/ai/useAgent.ts @@ -101,3 +101,38 @@ export function useUpdateAgent(id: string) { }, }); } + +interface PublishRagBotResponse { + data: { + agent_id: string; + version_id: string; + previous_version_id: string | null; + published_at: string; + }; +} + +/** + * Publica o rascunho de um agente `rag_bot` (POST /publish-rag-bot). Sem isso + * o "Salvar" do `AgentEditor.tsx` grava o rascunho mas o runtime continua + * respondendo com a última versão publicada — ver comentário da rota. + */ +export function usePublishRagBotAgent(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationKey: ["ai", "agents", id, "publish-rag-bot"], + mutationFn: async () => { + const res = await apiClient.post( + `/api/v1/ai/agents/${id}/publish-rag-bot`, + {}, + ); + return res.data; + }, + onError: (err) => { + showApiError(err); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: agentQueryKey(id) }); + toast.success("Publicado"); + }, + }); +} diff --git a/lib/ai/agents/publish-rag-bot.test.ts b/lib/ai/agents/publish-rag-bot.test.ts new file mode 100644 index 000000000..42cbb1337 --- /dev/null +++ b/lib/ai/agents/publish-rag-bot.test.ts @@ -0,0 +1,88 @@ +/** + * O editor legado de `rag_bot` só grava o rascunho — sem publish, o runtime + * (que lê `ai_agents.published_version_id` → `ai_agent_versions`) nunca via a + * mudança. Este teste cobre só o mapeamento de erro do wrapper: a função SQL + * em si é exercitada em `tests/invariants` (test:db), não aqui. + */ +import { describe, it, expect, vi } from "vitest"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +import { publishRagBotVersion } from "./publish-rag-bot"; + +function adminComRpc(resposta: { data: unknown; error: { message: string } | null }) { + return { rpc: vi.fn().mockResolvedValue(resposta) } as unknown as SupabaseClient; +} + +describe("publishRagBotVersion", () => { + it("mapeia sucesso pra RagBotPublishOk", async () => { + const admin = adminComRpc({ + data: [ + { + agent_id: "agent-1", + version_id: "version-2", + previous_version_id: "version-1", + published_at: "2026-08-22T20:00:00Z", + }, + ], + error: null, + }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-1", + createdBy: "user-1", + }); + + expect(result).toEqual({ + ok: true, + agent_id: "agent-1", + version_id: "version-2", + previous_version_id: "version-1", + published_at: "2026-08-22T20:00:00Z", + }); + }); + + it("mapeia erro conhecido (P0001) pro código estável, sem virar internal_error", async () => { + const admin = adminComRpc({ data: null, error: { message: "agent_kind_invalid" } }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-mcp", + createdBy: null, + }); + + expect(result).toEqual({ + ok: false, + code: "agent_kind_invalid", + message: "agent_kind_invalid", + }); + }); + + it("mapeia erro NÃO catalogado pra internal_error, sem vazar a mensagem crua como código", async () => { + const admin = adminComRpc({ + data: null, + error: { message: "relation ai_agent_versions does not exist" }, + }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-1", + createdBy: null, + }); + + expect(result.ok).toBe(false); + expect((result as { code: string }).code).toBe("internal_error"); + }); + + it("trata resposta sem linha (RPC ok mas array vazio) como internal_error, não como sucesso vazio", async () => { + const admin = adminComRpc({ data: [], error: null }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-1", + createdBy: null, + }); + + expect(result).toEqual({ ok: false, code: "internal_error", message: "no_row_returned" }); + }); +}); diff --git a/lib/ai/agents/publish-rag-bot.ts b/lib/ai/agents/publish-rag-bot.ts new file mode 100644 index 000000000..d1b53e150 --- /dev/null +++ b/lib/ai/agents/publish-rag-bot.ts @@ -0,0 +1,89 @@ +/** + * Publish wrapper around fn_publish_rag_bot_version (migration 0168). + * + * O editor legado de `rag_bot` (`components/ai/AgentEditor.tsx`, "caminho + * pré-EPIC-13") só grava o rascunho em `ai_agents` — o runtime lê + * `system_prompt`/`provider`/`model` da versão publicada + * (`ai_agents.published_version_id`), nunca do rascunho. Sem este wrapper (e a + * rota que o chama), editar um `rag_bot` pela tela não tinha efeito nenhum. + * + * Não reusa `publishAgentVersion`/`fn_publish_ai_agent_version`: aquela função + * exige `credential_id` não-nulo na versão e canal `WORKING` — nenhum dos dois + * é como `rag_bot` opera. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; + +export const RAG_BOT_PUBLISH_ERROR_CODES = new Set([ + "agent_not_found", + "agent_kind_invalid", + "agent_archived", + "no_existing_version", + "version_not_found", + "org_not_found", + "model_not_found", +]); + +export type RagBotPublishErrorCode = + | "agent_not_found" + | "agent_kind_invalid" + | "agent_archived" + | "no_existing_version" + | "version_not_found" + | "org_not_found" + | "model_not_found"; + +export interface RagBotPublishOk { + ok: true; + agent_id: string; + version_id: string; + previous_version_id: string | null; + published_at: string; +} + +export interface RagBotPublishFail { + ok: false; + code: RagBotPublishErrorCode | "internal_error"; + message: string; +} + +export type RagBotPublishResult = RagBotPublishOk | RagBotPublishFail; + +interface PublishRow { + agent_id: string; + version_id: string; + previous_version_id: string | null; + published_at: string; +} + +export async function publishRagBotVersion( + admin: SupabaseClient, + params: { orgId: string; agentId: string; createdBy: string | null }, +): Promise { + const { data, error } = await admin.rpc("fn_publish_rag_bot_version", { + p_org_id: params.orgId, + p_agent_id: params.agentId, + p_created_by: params.createdBy, + }); + + if (error) { + // Postgres P0001 com a razão como mensagem — mesmo contrato de + // fn_publish_ai_agent_version. + const raw = (error.message ?? "").trim(); + if (RAG_BOT_PUBLISH_ERROR_CODES.has(raw)) { + return { ok: false, code: raw as RagBotPublishErrorCode, message: raw }; + } + return { ok: false, code: "internal_error", message: raw || "publish_failed" }; + } + + const row = Array.isArray(data) ? (data[0] as PublishRow | undefined) : (data as PublishRow | null); + if (!row) { + return { ok: false, code: "internal_error", message: "no_row_returned" }; + } + return { + ok: true, + agent_id: row.agent_id, + version_id: row.version_id, + previous_version_id: row.previous_version_id, + published_at: row.published_at, + }; +} diff --git a/supabase/baseline.sql b/supabase/baseline.sql index 88010abe0..e57d84281 100644 --- a/supabase/baseline.sql +++ b/supabase/baseline.sql @@ -13284,6 +13284,156 @@ comment on table public.api_audit_log is 'app/api/v1/cron/data-retention. Não há camada cold/S3.'; notify pgrst, 'reload schema'; +-- ---- fn_publish_rag_bot_version (migration 0168) ---- +-- +-- O editor de agente `rag_bot` (`components/ai/AgentEditor.tsx`, "caminho legado +-- pré-EPIC-13") só grava o rascunho em `ai_agents` — nunca cria nem publica uma +-- linha em `ai_agent_versions`. O runtime lê `system_prompt`/`provider`/`model` +-- da versão apontada por `ai_agents.published_version_id`, não do rascunho: +-- editar pela tela não tinha NENHUM efeito, em silêncio. +-- +-- `fn_publish_ai_agent_version` (0024/0025/0026) não serve aqui: exige +-- `credential_id` não-nulo na versão e canal `WORKING` — nenhum dos dois é como +-- `rag_bot` opera. Esta função copia os campos de infraestrutura da versão +-- publicada atual (canal, ferramentas, orçamento — nada disso é editável pelo +-- `AgentEditor.tsx`) e só troca `system_prompt`/`provider`/`model`, vindos do +-- rascunho. `provider` NUNCA é hardcoded: resolve de +-- `organizations.settings.llm.provider` (o mesmo campo que +-- `scripts/bootstrap-owner.ts` grava a partir do `AI_PROVIDER` do instalador). +create or replace function public.fn_publish_rag_bot_version( + p_org_id uuid, + p_agent_id uuid, + p_created_by uuid default null +) +returns table ( + agent_id uuid, + version_id uuid, + previous_version_id uuid, + published_at timestamptz +) +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_agent record; + v_old_version record; + v_provider text; + v_model_count integer; + v_new_version_id uuid; + v_new_version_number integer; + v_published_at timestamptz := now(); +begin + select a.id, a.organization_id, a.kind, a.archived_at, a.published_version_id, + a.system_prompt, a.model + into v_agent + from public.ai_agents a + where a.id = p_agent_id + for update; + + if not found then + raise exception 'agent_not_found' using errcode = 'P0001'; + end if; + if v_agent.organization_id <> p_org_id then + raise exception 'agent_not_found' using errcode = 'P0001'; + end if; + if v_agent.kind is distinct from 'rag_bot' then + raise exception 'agent_kind_invalid' using errcode = 'P0001'; + end if; + if v_agent.archived_at is not null then + raise exception 'agent_archived' using errcode = 'P0001'; + end if; + if v_agent.published_version_id is null then + raise exception 'no_existing_version' using errcode = 'P0001'; + end if; + + select v.id, v.organization_id, v.agent_id, v.version_number, v.credential_id, + v.tool_ids, v.trigger_config, v.channel_session_id, v.max_steps, + v.token_budget, v.cost_budget_cents, v.history_message_window, + v.history_token_window, v.handoff_keywords, v.handoff_tool_enabled, + v.followup, v.multimodal_input, v.video_frames_enabled, v.split_messages, + v.split_max_chars, v.cases_enabled, v.operator_enabled, v.operator_model, + v.operator_tool_ids, v.pipeline_ids + into v_old_version + from public.ai_agent_versions v + where v.id = v_agent.published_version_id + for update; + + if not found or v_old_version.agent_id <> p_agent_id or v_old_version.organization_id <> p_org_id then + raise exception 'version_not_found' using errcode = 'P0001'; + end if; + + select coalesce(o.settings #>> '{llm,provider}', 'anthropic') + into v_provider + from public.organizations o + where o.id = p_org_id; + + if v_provider is null then + raise exception 'org_not_found' using errcode = 'P0001'; + end if; + + select count(*) + into v_model_count + from public.ai_models m + where m.provider = v_provider + and m.model_id = v_agent.model + and m.deprecated_at is null; + + if v_model_count = 0 then + raise exception 'model_not_found' using errcode = 'P0001'; + end if; + + v_new_version_number := v_old_version.version_number + 1; + + update public.ai_agent_versions + set status = 'superseded', superseded_at = v_published_at + where id = v_old_version.id; + + insert into public.ai_agent_versions ( + organization_id, agent_id, version_number, system_prompt, provider, model, + credential_id, tool_ids, trigger_config, channel_session_id, max_steps, + token_budget, cost_budget_cents, history_message_window, history_token_window, + handoff_keywords, handoff_tool_enabled, status, published_at, created_by, + followup, multimodal_input, video_frames_enabled, split_messages, split_max_chars, + cases_enabled, operator_enabled, operator_model, operator_tool_ids, pipeline_ids + ) values ( + p_org_id, p_agent_id, v_new_version_number, v_agent.system_prompt, v_provider, v_agent.model, + v_old_version.credential_id, v_old_version.tool_ids, v_old_version.trigger_config, + v_old_version.channel_session_id, v_old_version.max_steps, + v_old_version.token_budget, v_old_version.cost_budget_cents, + v_old_version.history_message_window, v_old_version.history_token_window, + v_old_version.handoff_keywords, v_old_version.handoff_tool_enabled, + 'published', v_published_at, p_created_by, + v_old_version.followup, v_old_version.multimodal_input, v_old_version.video_frames_enabled, + v_old_version.split_messages, v_old_version.split_max_chars, + v_old_version.cases_enabled, v_old_version.operator_enabled, v_old_version.operator_model, + v_old_version.operator_tool_ids, v_old_version.pipeline_ids + ) + returning id into v_new_version_id; + + update public.ai_agents + set published_version_id = v_new_version_id, + updated_at = v_published_at + where id = p_agent_id; + + return query + select p_agent_id, v_new_version_id, v_old_version.id, v_published_at; +end; +$$; + +comment on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) is + 'Publica o rascunho de um agente rag_bot (system_prompt/model) copiando os campos de infraestrutura da versão publicada atual. Ver comentário da migration 0168 para o porquê de não reusar fn_publish_ai_agent_version.'; + +-- Função nova em public nasce exposta a anon/authenticated (as duas origens: +-- ALTER DEFAULT PRIVILEGES do baseline + GRANT a PUBLIC implícito do Postgres +-- ao criar). Só service_role chama esta função (sempre via createAdminClient() +-- na server action). +revoke execute on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) from public, anon; +revoke execute on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) from authenticated; +grant execute on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) to service_role; + +notify pgrst, 'reload schema'; + -- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ---- -- -- ⚠️ ESTE BLOCO É, DE PROPÓSITO, O ÚLTIMO DO ARQUIVO. Apêndice novo entra ANTES diff --git a/supabase/migrations/20260822180000_0168_fn_publish_rag_bot_version.sql b/supabase/migrations/20260822180000_0168_fn_publish_rag_bot_version.sql new file mode 100644 index 000000000..943027b3c --- /dev/null +++ b/supabase/migrations/20260822180000_0168_fn_publish_rag_bot_version.sql @@ -0,0 +1,170 @@ +-- 0168_fn_publish_rag_bot_version +-- +-- O editor de agente `rag_bot` (`components/ai/AgentEditor.tsx`, "caminho legado +-- pré-EPIC-13" segundo `app/app/ai/agents/[id]/page.tsx:71`) só grava o rascunho +-- em `ai_agents` — nunca cria nem publica uma linha em `ai_agent_versions`. O +-- runtime (`lib/agent-engine/agent/agent-config.ts`) lê `system_prompt`/ +-- `provider`/`model` da versão apontada por `ai_agents.published_version_id`, +-- não do rascunho. Resultado medido: editar o prompt/modelo de um `rag_bot` pela +-- tela não tem NENHUM efeito — o agente segue respondendo com o que foi +-- publicado no bootstrap inicial, em silêncio, sem erro em lugar nenhum. +-- +-- `fn_publish_ai_agent_version` (0024/0025/0026) não serve para este caminho: +-- exige `credential_id` não-nulo na versão e `channel_sessions.status = 'WORKING'` +-- — nenhum dos dois é como `rag_bot` opera (ele resolve credencial por +-- organização+provider em tempo de chamada, sem vínculo de versão, e não trava +-- publish num canal offline). Esta função é o par mínimo, específico do +-- `rag_bot`: reusa (copia) os campos "de infraestrutura" da versão publicada +-- atual (canal, ferramentas, orçamento de tokens, etc. — nenhum deles é editável +-- pelo `AgentEditor.tsx`) e só troca `system_prompt`/`provider`/`model`, estes +-- sim vindos do rascunho em `ai_agents`. +-- +-- `provider` NUNCA é hardcoded (essa era exatamente a causa de um segundo bug +-- medido nesta sessão de triagem): resolve de `organizations.settings.llm.provider`, +-- o mesmo campo que `scripts/bootstrap-owner.ts` grava a partir do `AI_PROVIDER` +-- escolhido no instalador — default `'anthropic'` só quando a organização nunca +-- escolheu nada. +-- +-- Exige que já exista uma versão publicada (bootstrap sempre cria a v1) — sem +-- isso não há de onde copiar canal/ferramentas/orçamento, e inventar esses +-- valores aqui seria pior que recusar. + +create or replace function public.fn_publish_rag_bot_version( + p_org_id uuid, + p_agent_id uuid, + p_created_by uuid default null +) +returns table ( + agent_id uuid, + version_id uuid, + previous_version_id uuid, + published_at timestamptz +) +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_agent record; + v_old_version record; + v_provider text; + v_model_count integer; + v_new_version_id uuid; + v_new_version_number integer; + v_published_at timestamptz := now(); +begin + -- Trava o agente e confere posse + tipo. `for update` evita duas publicações + -- concorrentes pisarem uma na outra (mesmo padrão da 0024/0025/0026). + select a.id, a.organization_id, a.kind, a.archived_at, a.published_version_id, + a.system_prompt, a.model + into v_agent + from public.ai_agents a + where a.id = p_agent_id + for update; + + if not found then + raise exception 'agent_not_found' using errcode = 'P0001'; + end if; + if v_agent.organization_id <> p_org_id then + raise exception 'agent_not_found' using errcode = 'P0001'; + end if; + if v_agent.kind is distinct from 'rag_bot' then + raise exception 'agent_kind_invalid' using errcode = 'P0001'; + end if; + if v_agent.archived_at is not null then + raise exception 'agent_archived' using errcode = 'P0001'; + end if; + if v_agent.published_version_id is null then + raise exception 'no_existing_version' using errcode = 'P0001'; + end if; + + -- Versão publicada atual: fonte dos campos de infraestrutura que o + -- `AgentEditor.tsx` não edita (canal, ferramentas, janelas de histórico, + -- orçamento). Travada pelo mesmo motivo do agente. + select v.id, v.organization_id, v.agent_id, v.version_number, v.credential_id, + v.tool_ids, v.trigger_config, v.channel_session_id, v.max_steps, + v.token_budget, v.cost_budget_cents, v.history_message_window, + v.history_token_window, v.handoff_keywords, v.handoff_tool_enabled, + v.followup, v.multimodal_input, v.video_frames_enabled, v.split_messages, + v.split_max_chars, v.cases_enabled, v.operator_enabled, v.operator_model, + v.operator_tool_ids, v.pipeline_ids + into v_old_version + from public.ai_agent_versions v + where v.id = v_agent.published_version_id + for update; + + if not found or v_old_version.agent_id <> p_agent_id or v_old_version.organization_id <> p_org_id then + raise exception 'version_not_found' using errcode = 'P0001'; + end if; + + select coalesce(o.settings #>> '{llm,provider}', 'anthropic') + into v_provider + from public.organizations o + where o.id = p_org_id; + + if v_provider is null then + raise exception 'org_not_found' using errcode = 'P0001'; + end if; + + -- Mesmo piso de sanidade da 0024/0025/0026: não publica modelo que o + -- catálogo desta instalação não conhece (ou já marcou deprecated). + select count(*) + into v_model_count + from public.ai_models m + where m.provider = v_provider + and m.model_id = v_agent.model + and m.deprecated_at is null; + + if v_model_count = 0 then + raise exception 'model_not_found' using errcode = 'P0001'; + end if; + + v_new_version_number := v_old_version.version_number + 1; + + update public.ai_agent_versions + set status = 'superseded', superseded_at = v_published_at + where id = v_old_version.id; + + insert into public.ai_agent_versions ( + organization_id, agent_id, version_number, system_prompt, provider, model, + credential_id, tool_ids, trigger_config, channel_session_id, max_steps, + token_budget, cost_budget_cents, history_message_window, history_token_window, + handoff_keywords, handoff_tool_enabled, status, published_at, created_by, + followup, multimodal_input, video_frames_enabled, split_messages, split_max_chars, + cases_enabled, operator_enabled, operator_model, operator_tool_ids, pipeline_ids + ) values ( + p_org_id, p_agent_id, v_new_version_number, v_agent.system_prompt, v_provider, v_agent.model, + v_old_version.credential_id, v_old_version.tool_ids, v_old_version.trigger_config, + v_old_version.channel_session_id, v_old_version.max_steps, + v_old_version.token_budget, v_old_version.cost_budget_cents, + v_old_version.history_message_window, v_old_version.history_token_window, + v_old_version.handoff_keywords, v_old_version.handoff_tool_enabled, + 'published', v_published_at, p_created_by, + v_old_version.followup, v_old_version.multimodal_input, v_old_version.video_frames_enabled, + v_old_version.split_messages, v_old_version.split_max_chars, + v_old_version.cases_enabled, v_old_version.operator_enabled, v_old_version.operator_model, + v_old_version.operator_tool_ids, v_old_version.pipeline_ids + ) + returning id into v_new_version_id; + + update public.ai_agents + set published_version_id = v_new_version_id, + updated_at = v_published_at + where id = p_agent_id; + + return query + select p_agent_id, v_new_version_id, v_old_version.id, v_published_at; +end; +$$; + +comment on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) is + 'Publica o rascunho de um agente rag_bot (system_prompt/model) copiando os campos de infraestrutura da versão publicada atual. Ver comentário no topo da migration 0168 para o porquê de não reusar fn_publish_ai_agent_version.'; + +-- Função nova em public nasce exposta a `anon`/`authenticated` via +-- ALTER DEFAULT PRIVILEGES do baseline (origem A) e ao criar (origem B, +-- GRANT a PUBLIC implícito do Postgres) — as duas precisam ser fechadas +-- (doutrina de migrations, CLAUDE.md §"Função nova em public nasce EXPOSTA"). +-- Só service_role chama esta função (sempre via createAdminClient() na action). +revoke execute on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) from public, anon; +revoke execute on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) from authenticated; +grant execute on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) to service_role; diff --git a/supabase/migrations/MANIFEST.md b/supabase/migrations/MANIFEST.md index 4c64d77c1..960901af3 100644 --- a/supabase/migrations/MANIFEST.md +++ b/supabase/migrations/MANIFEST.md @@ -202,6 +202,7 @@ aplica. | `20260820030000` | `0164_atribuicao_de_anuncio` | **De qual anúncio um contato do WhatsApp veio.** `contacts.source`/`source_metadata` já existiam (nasceram genéricos, `'whatsapp'`); esta migration não cria coluna, só `fn_estampar_atribuicao_de_anuncio(p_contact, p_platform, p_metadata)` — chamada pelo ingest de canal (WAHA e o canal oficial) quando a primeira mensagem de um contato novo traz dados de um clique em anúncio "Clique para o WhatsApp" da Meta. **Função, não UPDATE direto do aplicativo:** o merge de `source_metadata` (`||`, preserva `waha_lid`/`waha_chat_id`/`notify_name` que `fn_upsert_wa_contact` já gravou) e a guarda de PRIMEIRO TOQUE (`source_metadata->>'ad_platform' is null`) precisam ser UMA operação atômica — duas mensagens quase simultâneas do mesmo contato novo não podem correr a corrida de ler+mesclar+escrever em JS. **Primeiro toque, nunca sobrescreve:** clicar em outro anúncio meses depois, numa conversa já aberta, não reescreve de onde a pessoa veio ORIGINALMENTE — o UPDATE casa zero linhas, silenciosamente, quando já há `ad_platform` gravado. **Os dois revokes do item 9 do CLAUDE.md** (`from public, anon, authenticated` + `grant to service_role`): só o backend chama isto (admin client no ingest), nenhum papel de sessão precisa. Idempotente (`create or replace`) e sem backfill: não altera dado existente. | | `20260820120000` | `0166_indice_do_claim_da_fila` | **O cap global do claim varria `job_queue` inteira a cada rodada.** `claimJobs` (`lib/agent-engine/queue/queue.ts`) abre toda rodada — dentro do advisory lock que serializa os claimers — com `select count(*) from job_queue where status = 'running'`, e nenhum dos quatro índices da tabela servia esse predicado. O parcial das lanes (`uniq_job_queue_one_running_per_contact`) chega perto e **não vale**: o predicado dele é mais ESTREITO (exclui `contact_id is null`, que é todo `watchdog`/`flywheel`), então o planejador não pode responder por ele. Medido em `pgvector/pgvector:pg17` com este baseline, 50.000 linhas `done` + 4 `running`: **Seq Scan / 715 buffers → Index Only Scan / 3 buffers**, índice de **16 kB**. **O custo não depende de linha viva, depende de bloat:** apagando as 50.004 dentro de uma transação e perguntando de novo, o Seq Scan ainda lê os **mesmos 715 buffers** — ele visita PÁGINA, não tupla —, e nada no produto poda `job_queue`. **O `/healthz` do worker NÃO é consertado por isto, e foi medido, não suposto:** `workers/agent-worker/main.ts` e `lib/agent-engine/obs/metrics.ts` fazem `select status, count(*) from job_queue group by status`, que precisa de TODOS os status; com o índice novo o plano continua HashAggregate sobre Seq Scan, 715 buffers, idêntico ao de antes — e um índice CHEIO em `(status)` também não move (medido: o planejador segue escolhendo Seq Scan, e o índice custaria 360 kB). Fica fora do escopo: aquilo roda em probe do Docker a cada 30s, não no caminho quente do claim. **O bloco `do $$` não é cerimônia:** `create index if not exists` casa por NOME e não por definição, e as duas variantes foram medidas em pg17 — homônimo na própria `job_queue` com outra definição vira `NOTICE: ... already exists, skipping` (NOTICE nem chega ao filtro `ERROR\|FATAL` do `update.sh`: no-op perfeitamente silencioso), e homônimo em OUTRA tabela (nome de índice é único por SCHEMA) dá o mesmo no-op **e** faz o `comment on index` acertar o índice errado, cegando o delator. O bloco derruba e recria o homônimo NOSSO em `job_queue`; o de outro objeto ele **não apaga** — `raise exception` com a razão escrita, que é o comportamento certo num script que roda sem `ON_ERROR_STOP` (o erro aparece ao operador e o resto do baseline segue). **O `comment on index` é DELATOR:** índice ausente levanta `relation "idx_job_queue_running" does not exist`, texto conferido contra a lista benigna real do `update.sh` (`already exists\|multiple primary keys\|multiple default values\|is already a member\|already a partition`) — não casa com nenhum termo, logo chega ao cliente; um `already exists` seria engolido. Guardado por `tests/invariants/queue-cap-global-do-claim.test.ts`, que **extrai o `select` do FONTE de produção** em vez de copiá-lo: índice presente e predicado mudado (`::text` a mais, `lower(status)`, `status in (...)`) devolveria Seq Scan com o símbolo intacto. Aditiva e idempotente: sem constraint, sem backfill, sem dado tocado. | | `20260820160000` | `0165_identificador_de_canal_unico_entre_ativos` | **Os dois identificadores de canal que chegaram depois do snapshot não tinham trava nenhuma, e é por eles que o código resolve credencial de envio e o DONO de uma mensagem que acabou de entrar.** `waha_session_name` e `webhook_path_token` são UNIQUE desde o snapshot; `meta_phone_number_id` (0087) e `zernio_account_id` (0131) nasceram sem. Traz **dois índices únicos PARCIAIS** (`where archived_at is null`), pelo precedente da 0107 — canal arquivado é canal excluído e a linha só sobrevive como âncora das FKs RESTRICT, então trava total impediria reconectar o mesmo número depois de excluí-lo. **O que a ausência produzia (issue #236):** três consultas de `lib/channels/` resolviam a sessão só pelo identificador, em client de service role (bypassa RLS). Com duas linhas casando, `maybeSingle()` **não** devolve "a primeira" — medido contra `@supabase/postgrest-js` 2.112.1, devolve `data: null` + `error PGRST116` (HTTP 406). Os três **descartavam o `error`**, então: os dois resolvedores de credencial caíam no fallback do `.env` (a mensagem saía pela conta de OUTRA instalação) e `meta/ingest.ts` devolvia `no_session` com a rota respondendo 200 — a mensagem recebida era **descartada para as duas organizações**. **Classificação: alta, não crítica** — a colisão é atingível por configuração LEGÍTIMA (agência, migração de conta entre organizações), não por reivindicação hostil: `validatePartnerCredentials` (`lib/channels/connect.ts`) exige que o identificador esteja na lista de contas que a chave informada alcança. **A deduplicação vem ANTES da trava e não é destrutiva:** o `update.sh` do clone roda sem `ON_ERROR_STOP` e engoliria o 23505, deixando o clone sem trava e sem aviso. Apagar está fora (sessão de cliente, histórico por FK RESTRICT) e arquivar faria o canal sumir da tela sem ninguém pedir — a perdedora é **RENOMEADA** para `-conflito-`: continua visível, e como o identificador não existe no provider a varredura de saúde (`app/api/v1/cron/channel-health`) grava `FAILED`/`STOPPED` na passada seguinte e **abre aviso na Central** (os três estados estão em `STATUS_QUE_AVISAM`). Fica com o identificador a sessão ativa **mais recente** (criá-la exigiu provar posse da conta na tela de conexão ⇒ é a intenção mais recente); errar o palpite não destrói nada e a linha perdedora ela mesma avisa. **Idempotente por construção:** o sufixo carrega o `id` (único), então a segunda passada casa zero linhas e não há como sufixar duas vezes. Nomes de índice novos (conferidos contra `baseline.sql` e `migrations/`), então o `if not exists` — que casa por NOME — não vira no-op em cima de um homônimo com outra definição. O código foi corrigido nas três camadas junto: filtro de `organization_id` nos três sítios (com o `organizationId` atravessando o seam de canal como campo **obrigatório**, para o typecheck cobrar), `error` que deixa de ser descartado, e o invariante `tests/unit/canal-consulta-por-organizacao.test.ts`, que varre `lib/channels/` derivando as colunas de `CHANNEL_SESSION_REF_COLUMNS` — o quarto canal entra na varredura sozinho. | +| `20260822180000` | `0168_fn_publish_rag_bot_version` | **Editar o prompt de um agente `rag_bot` pela tela não tinha efeito nenhum, em silêncio.** `components/ai/AgentEditor.tsx` ("caminho legado pré-EPIC-13", `app/app/ai/agents/[id]/page.tsx:73`) só grava o rascunho em `ai_agents`; o runtime (`lib/agent-engine/agent/agent-config.ts`) lê `system_prompt`/`provider`/`model` da versão apontada por `ai_agents.published_version_id`, nunca do rascunho — o agente segue respondendo com o que foi publicado no bootstrap inicial, e nenhum erro aparece em lugar nenhum. Achado numa instalação real: o prompt genérico do seed ("Você é um(a) atendente virtual amigável de uma loja online...") continuava ativo depois de o operador editar e salvar várias vezes pela tela. **`fn_publish_ai_agent_version` (0024/0025/0026) não serve pra este caminho:** exige `credential_id` não-nulo na versão e canal `channel_sessions.status = 'WORKING'` — nenhum dos dois é como `rag_bot` resolve credencial (por organização+provider em tempo de chamada, sem vínculo de versão) nem como ele publica (não trava em canal offline). `fn_publish_rag_bot_version` é o par mínimo: copia os campos de infraestrutura da versão publicada atual (canal, ferramentas, orçamento — nenhum editável pelo `AgentEditor.tsx`) e troca só `system_prompt`/`provider`/`model`, vindos do rascunho. **`provider` nunca é hardcoded** — resolve de `organizations.settings.llm.provider` (o mesmo campo que `scripts/bootstrap-owner.ts` grava a partir do `AI_PROVIDER` do instalador); um segundo bug medido na mesma triagem foi um agente seedado direto com `provider='anthropic'` apesar da organização ter escolhido OpenRouter na instalação — `LlmNotConfiguredError` em todo turno, calado, porque a chave que existia (`OPENROUTER_API_KEY`) nunca é fallback de `provider='anthropic'`. Mesmo piso de sanidade da 0024/0025/0026: recusa publicar modelo que `ai_models` não conhece (`model_not_found`) ou que já foi `deprecated_at`. Exige versão publicada existente (`no_existing_version`) — o bootstrap sempre cria a v1; sem isso não haveria de onde copiar canal/ferramentas, e inventar esses valores aqui seria pior que recusar. Os dois revokes do item 9 do CLAUDE.md (`from public, anon, authenticated` + `grant to service_role`): só o backend chama isto (`createAdminClient()` na rota `POST /api/v1/ai/agents/:id/publish-rag-bot`), nenhum papel de sessão precisa. Botão "Publicar" novo no `AgentEditor.tsx`, desabilitado enquanto o rascunho tem alteração não salva (publicar sempre o que está persistido, nunca o que está só na tela). **Verificado:** `install`+`update` do `baseline.sql` num pg17 efêmero (com o prelude de stubs do `scripts/test-db.sh`), caminho feliz e os quatro erros (`agent_kind_invalid`, `agent_not_found`, `no_existing_version`, `agent_archived`) testados manualmente contra fixtures sintéticas, e `has_function_privilege` confirmando `anon`/`authenticated` bloqueados e só `service_role` liberado. **NÃO MEDIDO:** `pnpm test:db` (a suíte de invariantes real, com Postgres + vitest orquestrados) e `pnpm test:e2e` — só a verificação manual acima; e não há teste automatizado cobrindo o botão "Publicar" na tela (só o wrapper `lib/ai/agents/publish-rag-bot.test.ts`, que cobre mapeamento de erro, não a UI). | | `20260820170000` | `0167_poda_da_fila_e_expurgo_do_audit` | **Nada no produto apagava job terminal, e a retenção de 5 anos do audit existia só no COMMENT.** `grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete` devolvia **zero linhas**: `job_queue` crescia desde a instalação e nunca encolhia; `api_audit_log` prometia 5 anos + "hot 90 dias / cold S3" em seis documentos, sem uma linha de código que executasse qualquer das duas metades. São as candidatas naturais a estourar os **500 MB** do plano free antes de qualquer tabela de negócio — e o bloat também custa CPU (715 buffers varridos no `count(*)` do claim com ZERO linhas vivas, medido na #260). Traz **duas `security definer`** (`fn_podar_fila_de_jobs`, `fn_expurgar_auditoria_vencida`), **três índices** e o cron `data-retention` (diário). **DELETE por idade e não particionamento**: particionar `job_queue` exigiria mexer no claim `FOR UPDATE SKIP LOCKED` e nos dois índices ÚNICOS parciais que garantem um turno por lead — trocar essa garantia por disco é péssimo negócio. **O QUE TEM DONO NÃO SAI, e são três cortes:** `pending`/`running` nunca saem (o primeiro ainda vai sair, o segundo está com um worker e o reaper o devolve); terminais são só `done`/`failed`/`dead` (conferidos em `lib/agent-engine/queue/queue.ts`); e **`dead` com aviso ABERTO na Central tem dono** — um humano que não olhou — com o `not exists` **antes** do `limit`, porque filtrar depois faria um lote de protegidos devolver 0, o laço do cron pararia achando que acabou e a poda morreria de fome com backlog na frente. **Cascata declarada:** o DELETE leva junto `send_ledger` e `before_send_traces` (FK `on delete cascade`, as duas também sem poda) e apenas anula o ponteiro em `llm_calls`/`lead_checkpoints`/`lead_state_transitions`; os dois consumidores de `send_ledger` sem janela (`countPriorAcceptedSends` → disclosure de IA, e o gate LGPD de 1º toque de prospecção, `before-send.ts:261`) falham **fechado** — disclosure a mais e veto a mais, nunca a menos —, daí piso de 7 dias e default de 90. **A definer do audit não é porta de adulteração, e cada razão é conferível:** (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`, então ela só sabe apagar pela ponta mais velha; (b) o **piso de 90 dias mora no corpo**, não em quem chama, então nem com a service key se remove rastro recente; (c) revogada das duas origens de EXECUTE e concedida só a `service_role`; (d) não amplia o raio de quem já tem a chave (`service_role` já tem `TRUNCATE` na mesma tabela); (e) **registra a própria erosão** — o cron grava `retention.sweep_run` com a contagem, e essa linha é nova demais para a chamada seguinte alcançar. `api_audit_log` **não tinha** GRANT de DELETE para ninguém (nem para `service_role`), e é por isso que o expurgo não podia sair pelo admin client. **Hot/cold em S3 não foi entregue e a doutrina do `CLAUDE.md` foi corrigida** em vez de fingir: o self-host não tem para onde arquivar (o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`). Índices com **nome próprio da poda** (`idx_audit_expurgo_created_at`) porque `create index if not exists` casa por NOME e um nome genérico viraria no-op silencioso num clone. Aditiva, sem constraint nova (nada a deduplicar antes), idempotente por `create or replace` + `if not exists` + `revoke`. **NÃO MEDIDO:** o comportamento sob milhões de linhas reais — os lotes foram exercitados no Postgres efêmero do `test:db`, não numa VPS com histórico de anos. | ## Reproducibility From f039d4468e940913e19fcdea75cf11cb18fb303a Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sat, 22 Aug 2026 19:41:02 -0300 Subject: [PATCH 02/33] =?UTF-8?q?fix(agente):=20reconex=C3=A3o=20do=20WAHA?= =?UTF-8?q?=20reprovava=20n=C3=BAmero=20j=C3=A1=20aquecido=20no=20go-live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channel_session_health nasce por channel_sessions.id (por SESSÃO), não por phone_number (por NÚMERO). O caminho de recuperação de sessão morta é arquivar + conectar de novo (channel-sessions/[id]/reconnect recusa sessão arquivada e manda reconectar criando outra), e isso sempre gera um id novo — que nascia com health_released_at null, o fail-safe de "número novo", mesmo quando o telefone por trás já tinha passado pelo go-live manual dias antes. Medido em produção (instalação MKT, 2026-08-22): WAHA caiu, o dono arquivou a sessão morta e conectou de novo — rotina. A sessão nova entrou em hold go_live, e como ninguém sabia que precisava resolver esse aviso na Central de novo, o outbound ficou retido por ~2h; um lead mandou mensagem e não recebeu resposta. evaluateSession agora checa, antes de armar o hold, se outra sessão do mesmo phone_number nesta org (arquivada ou não) já foi liberada. Se sim, libera de cara. Número de verdade novo continua nascendo em hold, sem mudança de comportamento. Co-Authored-By: Claude Sonnet 5 --- lib/agent-engine/health/circuit.test.ts | 132 ++++++++++++++++++++++++ lib/agent-engine/health/circuit.ts | 73 ++++++++++++- 2 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 lib/agent-engine/health/circuit.test.ts diff --git a/lib/agent-engine/health/circuit.test.ts b/lib/agent-engine/health/circuit.test.ts new file mode 100644 index 000000000..2e65d25d6 --- /dev/null +++ b/lib/agent-engine/health/circuit.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import type pg from 'pg'; + +import { evaluateSession } from './circuit'; +import { HEALTH_DEFAULTS } from './defaults'; + +const RATES_VAZIAS = { totalSends: 0, blockedSends: 0, sentLeads: 0, respondedLeads: 0 }; + +/** + * Client falso de UMA transação (begin/select for update/commit) — mesmo padrão de + * `drain.test.ts` (mock por substring de SQL), adaptado para `harness.connect()` porque + * `evaluateSession` roda sob `for update` explícito. + */ +function clientFalso(opts: { + healthRow: { + health_hold_active: boolean; + health_released_at: Date | null; + cooldown_elapsed: boolean; + has_open_item: boolean; + }; + phoneNumber: string | null; + outraSessaoJaLiberada: boolean; +}) { + const chamadas: Array<{ sql: string; params: unknown[] }> = []; + const query = vi.fn(async (sql: string, params: unknown[] = []) => { + chamadas.push({ sql, params }); + const s = sql.replace(/\s+/g, ' ').trim(); + if (s === 'begin' || s === 'commit' || s === 'rollback') return { rows: [] }; + if (s.includes('for update')) return { rows: [opts.healthRow] }; + if (s.includes('select phone_number from channel_sessions')) { + return { rows: [{ phone_number: opts.phoneNumber }] }; + } + if (s.includes('ja_liberado')) { + return { rows: [{ ja_liberado: opts.outraSessaoJaLiberada }] }; + } + if (s.includes('update channel_session_health')) return { rows: [], rowCount: 1 }; + if (s.includes('insert into agent_inbox_items')) return { rows: [], rowCount: 1 }; + return { rows: [] }; + }); + return { query, release: vi.fn(), chamadas }; +} + +function harnessFalso(client: ReturnType): pg.Pool { + return { connect: vi.fn(async () => client) } as unknown as pg.Pool; +} + +describe('evaluateSession — go-live entre sessões do mesmo número', () => { + it( + 'sessão nova (health_released_at null) do MESMO phone_number de uma sessão já ' + + 'liberada não entra em hold — libera de cara, sem exigir resolução manual de novo', + async () => { + const client = clientFalso({ + healthRow: { + health_hold_active: false, + health_released_at: null, + cooldown_elapsed: false, + has_open_item: false, + }, + phoneNumber: '553398590909', + outraSessaoJaLiberada: true, + }); + + const delta = await evaluateSession( + harnessFalso(client), + 'org-1', + 'sessao-nova', + RATES_VAZIAS, + HEALTH_DEFAULTS, + ); + + expect(delta).toEqual({ held: 0, released: 1, alerts: 0 }); + const inseriuInboxItem = client.chamadas.some((c) => c.sql.includes('insert into agent_inbox_items')); + expect(inseriuInboxItem).toBe(false); + const liberou = client.chamadas.some( + (c) => c.sql.includes('update channel_session_health') && c.sql.includes('health_released_at = now()'), + ); + expect(liberou).toBe(true); + }, + ); + + it('número DE VERDADE novo (nenhuma outra sessão liberada) continua nascendo em hold go_live', async () => { + const client = clientFalso({ + healthRow: { + health_hold_active: false, + health_released_at: null, + cooldown_elapsed: false, + has_open_item: false, + }, + phoneNumber: '553398590909', + outraSessaoJaLiberada: false, + }); + + const delta = await evaluateSession( + harnessFalso(client), + 'org-1', + 'sessao-nova', + RATES_VAZIAS, + HEALTH_DEFAULTS, + ); + + expect(delta.held).toBe(1); + expect(delta.released).toBe(0); + const engajouGoLive = client.chamadas.some( + (c) => c.sql.includes('health_hold_reason = $3') && c.params[2] === 'go_live', + ); + expect(engajouGoLive).toBe(true); + }); + + it('phone_number ainda desconhecido (QR não pareado) não casa com nada — hold normal', async () => { + const client = clientFalso({ + healthRow: { + health_hold_active: false, + health_released_at: null, + cooldown_elapsed: false, + has_open_item: false, + }, + phoneNumber: null, + outraSessaoJaLiberada: true, // não deve nem ser consultado de verdade, mas garante que não vaza + }); + + const delta = await evaluateSession( + harnessFalso(client), + 'org-1', + 'sessao-nova', + RATES_VAZIAS, + HEALTH_DEFAULTS, + ); + + expect(delta.held).toBe(1); + expect(delta.released).toBe(0); + }); +}); diff --git a/lib/agent-engine/health/circuit.ts b/lib/agent-engine/health/circuit.ts index 8475dbb0b..3ecdbd8ef 100644 --- a/lib/agent-engine/health/circuit.ts +++ b/lib/agent-engine/health/circuit.ts @@ -216,12 +216,66 @@ interface HealthRow { has_open_item: boolean; } +/** + * O número já passou por go-live sob OUTRA sessão? (F2-26 forward-fix, bug + * medido em produção 2026-08-22, instalação MKT.) + * + * `channel_session_health` nasce por `channel_sessions.id` — uma linha por + * SESSÃO, não por NÚMERO. O caminho de recuperação sancionado para uma sessão + * morta é arquivar + conectar de novo (`channel-sessions/[id]/reconnect` + * recusa sessão arquivada e manda o operador reconectar criando uma nova — + * ver o cabeçalho daquela rota), e "conectar de novo" sempre gera um + * `channel_sessions.id` novo. Esse id novo nasce com `health_released_at + * is null` — o fail-safe "número novo" — mesmo quando o NÚMERO por trás + * (mesmo `phone_number`) já viveu dias no ar e já passou pelo go-live manual + * antes. Resultado medido: uma queda de WAHA banal (container reiniciou, + * sessão caiu STOPPED) obrigava o operador a "liberar" o mesmo número de + * novo, e enquanto ninguém achava esse aviso na Central o outbound ficava + * retido — mensagem de cliente sem resposta por horas. + * + * O fail-safe "número novo nasce em hold" continua valendo para número + * DE VERDADE novo (nenhuma sessão anterior com este `phone_number` nesta org + * jamais foi liberada). Ele só deixa de disparar quando existe EVIDÊNCIA + * concreta de que o número já foi aprovado: outra linha de + * `channel_session_health`, de OUTRA sessão do mesmo `phone_number` nesta + * org, com `health_released_at` preenchido — arquivada ou não (arquivar não + * apaga o histórico de aquecimento do número). + * + * `phone_number` pode ainda ser `null` na sessão nova (QR não pareado) — sem + * telefone conhecido não há como comparar, e o fail-safe original prevalece. + */ +async function foiLiberadoPorOutraSessaoDoMesmoNumero( + client: pg.PoolClient, + tenantId: string, + channelSessionId: string, +): Promise { + const { rows: phoneRows } = await client.query<{ phone_number: string | null }>( + `select phone_number from channel_sessions where organization_id = $1 and id = $2`, + [tenantId, channelSessionId], + ); + const phone = phoneRows[0]?.phone_number; + if (!phone) return false; + const { rows } = await client.query<{ ja_liberado: boolean }>( + `select exists ( + select 1 + from channel_session_health h + join channel_sessions s on s.id = h.channel_session_id + where s.organization_id = $1 + and s.phone_number = $2 + and s.id <> $3 + and h.health_released_at is not null + ) as ja_liberado`, + [tenantId, phone, channelSessionId], + ); + return rows[0]?.ja_liberado ?? false; +} + /** * Decide e aplica a transição de UMA sessão sob lock (FOR UPDATE) — dois ticks * concorrentes serializam na row do espelho, então nunca duplicam item nem hold. * Devolve o delta {held, released, alerts} desta sessão. */ -async function evaluateSession( +export async function evaluateSession( harness: pg.Pool, tenantId: string, channelSessionId: string, @@ -308,9 +362,22 @@ async function evaluateSession( if (row.health_released_at === null) { // Número novo (fail-safe): nasce em hold com razão go_live; libera SÓ por ato - // explícito (humano resolve o item = liberação inicial de go-live). + // explícito (humano resolve o item = liberação inicial de go-live) — EXCETO + // quando este mesmo phone_number já foi liberado sob outra sessão (reconexão + // que trocou o id): aí o go-live já foi feito, e repeti-lo só derruba o + // outbound de um número que já está aprovado (ver o cabeçalho da função acima). if (!row.health_hold_active) { - await engageHold('go_live'); + if (await foiLiberadoPorOutraSessaoDoMesmoNumero(client, tenantId, channelSessionId)) { + await client.query( + `update channel_session_health + set health_released_at = now(), health_hold_active = false, health_hold_reason = null, updated_at = now() + where organization_id = $1 and channel_session_id = $2`, + [tenantId, channelSessionId], + ); + delta.released = 1; + } else { + await engageHold('go_live'); + } } else if (!row.has_open_item) { await client.query( `update channel_session_health From 23c8fa3b9d98faff7ca2bf9e8c4d3a1b3cbc6ae9 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sat, 22 Aug 2026 19:59:45 -0300 Subject: [PATCH 03/33] =?UTF-8?q?docs(agents):=20reorganiza=20CLAUDE.md/AG?= =?UTF-8?q?ENTS.md=20sem=20contradi=C3=A7=C3=B5es=20e=20cria=20porta=20de?= =?UTF-8?q?=20entrada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os dois documentos tinham 11 contradições medidas contra o repositório: versão do produto divergente entre AGENTS.md/package.json/CHANGELOG (três valores diferentes), comando de gate errado (npm em vez de pnpm), instrução de schema local que quebra (migrations em vez de baseline.sql), e um punhado de contagens (rotas, testes, specs) já podres. Toda contagem volátil virou comando — quem lê mede na hora em vez de confiar num número que já mentiu quatro vezes neste repo. Adiciona .ai/AI_BOOTSTRAP.md como porta de entrada única, com a ordem de leitura e a regra de precedência entre os documentos. package.json ganha um comentário `//version` explicando por que aquele campo é inerte — a versão do produto vem do CHANGELOG. Co-Authored-By: Claude Sonnet 5 --- .ai/AI_BOOTSTRAP.md | 71 ++ AGENTS.md | 321 ++++---- CLAUDE.md | 728 +++++++++++------- package.json | 22 +- ...umentacao-aponta-para-o-que-existe.test.ts | 1 + 5 files changed, 689 insertions(+), 454 deletions(-) create mode 100644 .ai/AI_BOOTSTRAP.md diff --git a/.ai/AI_BOOTSTRAP.md b/.ai/AI_BOOTSTRAP.md new file mode 100644 index 000000000..9df11ee4a --- /dev/null +++ b/.ai/AI_BOOTSTRAP.md @@ -0,0 +1,71 @@ +# AI_BOOTSTRAP — leia isto primeiro + +Porta de entrada de qualquer agente de código neste repositório (Claude Code, Codex, Cursor, +Copilot, Amp). Não é doutrina: é o roteador. **Dois minutos aqui evitam o dano típico.** + +## 1. Ordem de leitura + +| # | Arquivo | Quando | +| --- | --------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| 1 | **este** | Sempre, antes de qualquer coisa | +| 2 | [`CLAUDE.md`](../CLAUDE.md) | Antes de escrever a primeira linha de código. É a doutrina, e vence qualquer outro documento | +| 3 | [`AGENTS.md`](../AGENTS.md) | Se você não é o Claude Code: mesmo contrato, forma portável | +| 4 | [`docs/index.md`](../docs/index.md) | Quando precisar de detalhe de um assunto — índice dos docs | +| 5 | [`docs/current-state.md`](../docs/current-state.md) | Antes de estimar, prometer ou dizer que algo existe | + +## 2. A regra que governa todas as outras + +**O repositório mede; a prosa descreve.** Código, `package.json`, workflows e `gh api` são a fonte +do estado — documento é sempre uma foto que pode ter envelhecido. Onde os dois discordarem, o +documento está errado: corrija-o no mesmo PR. + +Por isso a documentação de autoridade deste repo evita número volátil e prefere comando. Quando +você for escrever uma afirmação de estado, escreva o comando que a prova. + +## 3. O que este produto é (e por que isso muda seu trabalho) + +CRM de vendas open source com agentes de IA e WhatsApp, **distribuído como código e instalado numa +VPS pelo próprio cliente**. Quem instala é o usuário final. Consequências diretas: + +- Mudança que funciona na sua máquina e quebra no clone fresco é **bug de produto**. +- Schema só chega ao cliente se entrar em `supabase/baseline.sql` — migration solta não chega. +- O nome do produto é revendido: **nunca escreva "Deskcomm" em código que alcança o usuário**. +- A tela é o produto. `curl` diagnostica; ele não prova experiência de usuário. + +## 4. As dez regras que evitam dano + +1. **Toda tabela tenant-aware tem `organization_id` + RLS.** Service role bypassa RLS — quem usa o + admin client filtra `organization_id` à mão, de fonte confiável, **nunca do body**. +2. **`getUser()` no backend, nunca `getSession()`.** +3. **Zod em todo input externo** (body, webhook, env). +4. **`ok()` / `fail()` de `lib/api/wrappers.ts`** — nunca monte `Response` na mão, nunca deixe + `throw` cru na borda. +5. **Mudança de schema = migration versionada + apêndice idempotente no baseline + linha no + MANIFEST.** Os três juntos, ou o self-hoster não recebe. +6. **Trigger Postgres nunca faz HTTP** — emite linha em `event_log`, o worker dispara o efeito. +7. **API key nunca em query string**; bearer no banco só como hash SHA256. +8. **`console.log` é proibido** em código merged — use `lib/logger.ts`. +9. **Tela nova precisa de porta** em `lib/navigation/registry.ts`: existir e ser alcançável são + coisas diferentes. +10. **Nunca invente regra de negócio, SLA ou número.** Se não está escrito, diga que não está e + pergunte. + +## 5. Antes de começar + +```bash +git fetch origin && git merge origin/main # branch atrasada é a causa nº 1 de retrabalho +pnpm install +``` + +Nunca use `reset --hard` ou force push para "atualizar" uma branch, e nunca toque em worktree sujo +que não é seu. + +## 6. Antes de dizer "pronto" + +```bash +pnpm gov:verify # typecheck + lint + lint:channels + test:unit +``` + +Verde aqui **não** é prova completa: `gov:verify` não roda `test:db` (schema/RLS), `test:e2e` (UI) +nem `test:shell` (kit de instalação). Rode o que sua mudança exige e cumpra a Definition of Done de +[`CLAUDE.md`](../CLAUDE.md) — ela é a régua de conclusão, item por item. diff --git a/AGENTS.md b/AGENTS.md index 2e70bbc9c..3b36a8f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,53 +1,59 @@ # AGENTS.md — DeskcommCRM -> Contrato para **qualquer** agente de código (Codex, Cursor, Copilot, Amp, Claude Code). -> Este arquivo é o núcleo portável. A **doutrina completa e não-negociável vive em -> [`CLAUDE.md`](CLAUDE.md)** — leia-o antes de tocar em código. Aqui está o mínimo -> para não causar dano. +> Contrato portável para **qualquer** agente de código (Codex, Cursor, Copilot, Amp, Claude Code). +> A doutrina completa e não-negociável vive em [`CLAUDE.md`](CLAUDE.md) — leia-o antes de tocar em +> código. Aqui está o mínimo para não causar dano. +> +> Precedência: repositório (código, `package.json`, workflows) > `CLAUDE.md` > este arquivo > +> [`docs/index.md`](docs/index.md). Toda afirmação daqui vem com o comando que a mede — **rode o +> comando, não confie no texto**. --- ## Objetivo do projeto -Sistema operacional de vendas open source com agentes de IA nativos, multi-nicho, -WhatsApp como canal primário (via WAHA). Multi-tenant com RLS desde o dia 1, LGPD -nativa. Monetização = self-host em VPS, não assinatura. Posicionamento: [`VISION.md`](VISION.md). +Sistema operacional de vendas open source com agentes de IA nativos, multi-nicho, WhatsApp como +canal primário (via WAHA). Multi-tenant com RLS desde o dia 1, LGPD nativa. Monetização = self-host +em VPS, não assinatura. Posicionamento: [`VISION.md`](VISION.md). -**Consequência que muda como você trabalha:** o produto é distribuído como código. -Quem instala numa VPS **é** o usuário. Uma mudança que funciona na máquina do dev e -quebra no clone fresco é um bug de produto, não um detalhe de ambiente. +**Consequência que muda como você trabalha:** o produto é distribuído como código. Quem instala numa +VPS **é** o usuário. Uma mudança que funciona na máquina do dev e quebra no clone fresco é um bug de +produto, não um detalhe de ambiente. ## Stack (CONFIRMADO em `package.json`) -Next.js 16 (App Router) · React 19 · TypeScript 6 estrito · Tailwind 3 · -shadcn/ui · Supabase (Postgres + Auth + Realtime + Storage) · Upstash Redis · -Vercel AI Gateway (`@ai-sdk/anthropic|openai|google`) · WAHA Plus (engine NOWEB) · -Zod 4 · Vitest 4 · Playwright 1 · Sentry 10. +Next.js 16 (App Router) · React 19 · TypeScript 6 estrito · Tailwind 3 · shadcn/ui · +Supabase (Postgres + Auth + Realtime + Storage) · Upstash Redis · Vercel AI Gateway +(`@ai-sdk/anthropic|openai|google`) · WAHA Plus (engine NOWEB) · Zod 4 · Vitest 4 · Playwright 1 · +Sentry 10. Só a **major**, de propósito: é onde o idioma muda, e é o que -`tests/unit/agents-md-versoes.test.ts` verifica contra o `package.json`. Declarar a minor -aqui fazia todo bump do Dependabot reprovar o `verify` (5 dos 8 pacotes) e não cobria nada -que a major já não cobrisse — issue #235. Para a versão exata, `package.json` é a fonte. +`tests/unit/agents-md-versoes.test.ts` cobra contra o `package.json`. Declarar a minor aqui fazia +todo bump do Dependabot reprovar o `verify` sem cobrir nada que a major já não cobrisse (issue +#235). Para a versão exata, a fonte é o `package.json`. -Runtime: **Node ≥22** (`.nvmrc` = 22; os quatro workflows fixam `node-version: 22` — -`ci` ×2, `perf`, `e2e`). Gerenciador: **pnpm 9.15.9** (`packageManager`). -Versão do produto: **1.0.0** (`CHANGELOG.md`, SemVer — mudança que afeta quem roda VPS entra lá). +- **Runtime:** Node ≥22 — `.nvmrc` e o `node-version` dos workflows. +- **Gerenciador:** pnpm, versão fixada no campo `packageManager` do `package.json`. +- **Versão do produto:** topo do `CHANGELOG.md` (`grep -m2 -E '^## \[' CHANGELOG.md`), SemVer, com + tag git correspondente. Mudança que afeta quem roda VPS entra lá. O campo `version` do + `package.json` **não** é a versão do produto e não é lido em runtime. ## Estrutura que importa -| Path | O quê | -|---|---| -| `app/api/v1/` | 166 route handlers REST (versionado por path) — 169 contando `app/api/**` | -| `app/api/internal/`, `app/api/mcp/`, `app/api/v1/cron/` | superfícies não-cookie (secret/bearer próprio) | -| `app/app/` | UI autenticada do tenant · `app/admin/` UI de plataforma | -| `app/actions/` | Server Actions (auth, onboarding, team, settings) | -| `lib/agent-engine/`, `lib/ai/` | runtime do agente, guardrails, RAG, dispatcher | -| `lib/api/wrappers.ts` | `ok()` / `fail()` — **use sempre**, não monte Response na mão | -| `lib/auth/require-role.ts` | `requireRole()` — guard canônico de RBAC | -| `lib/supabase/{browser,server,admin}.ts` | clients canônicos | -| `workers/` | workers de `event_log` + crons | -| `supabase/migrations/` | schema versionado · `supabase/baseline.sql` = o que o self-host aplica | -| `proxy.ts` | middleware do Next 16 (auth de borda, `X-Request-Id`) | +| Path | O quê | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `app/api/v1/` | Route handlers REST, versionados por path | +| `app/api/internal/`, `app/api/mcp/`, `app/api/v1/cron/` | Superfícies não-cookie (secret / bearer próprio) | +| `app/app/`, `app/admin/` | UI autenticada do tenant · UI de plataforma | +| `app/actions/` | Server Actions (auth, onboarding, team, settings) | +| `lib/agent-engine/`, `lib/ai/` | Runtime do agente, guardrails, RAG, dispatcher | +| `lib/api/wrappers.ts` | `ok()` / `fail()` — **use sempre**, não monte `Response` na mão | +| `lib/auth/require-role.ts` | `requireRole()` — guard canônico de RBAC | +| `lib/supabase/browser.ts`, `lib/supabase/server.ts`, `lib/supabase/admin.ts` | Clients canônicos | +| `lib/navigation/registry.ts` | Registro de telas — é o que dá porta a uma tela nova | +| `workers/` | Workers de `event_log` + crons | +| `supabase/migrations/` | Schema versionado · `supabase/baseline.sql` = o que o self-host aplica | +| `proxy.ts` | Middleware do Next 16 (auth de borda, `X-Request-Id`) | ## Comandos (CONFIRMADO em `package.json`) @@ -56,182 +62,201 @@ pnpm install # deps (frozen-lockfile no CI) pnpm dev # dev server pnpm build # next build pnpm lint # eslint +pnpm lint:channels # nenhuma feature nomeia um provider de canal pnpm typecheck # tsc --noEmit (estrito) pnpm test:unit # vitest — EXCLUI tests/invariants e tests/e2e pnpm test:db # invariantes de banco + gate do baseline (PRECISA de Docker) pnpm test:e2e # Playwright (PRECISA de app rodando + banco semeado) -pnpm gov:verify # typecheck + lint + test:unit ← verificação única atual +pnpm test:shell # kit self-host (update.sh, scheduler, validadores do install.sh) +pnpm gov:verify # atalho local = typecheck + lint + lint:channels + test:unit ``` -⚠️ **`pnpm gov:verify` NÃO cobre tudo.** Ele omite `test:db` e `test:e2e`. Se sua -mudança toca schema, RLS ou UI, `gov:verify` verde **não** é prova — rode `pnpm test:db` -(exige Docker) e/ou `pnpm test:e2e` você mesmo. Ver [`docs/harness-audit.md`](docs/harness-audit.md). +⚠️ **`pnpm gov:verify` NÃO cobre tudo.** Ele omite `test:db`, `test:e2e` e `test:shell`. Se sua +mudança toca schema, RLS, UI ou o kit, `gov:verify` verde **não** é prova — rode as suítes que +faltam você mesmo. Ver [`docs/harness-audit.md`](docs/harness-audit.md). -**O que o CI cobre.** `.github/workflows/ci.yml`: `verify` = typecheck + lint + test:unit; -`invariants` = `pnpm test:db` (isolamento RLS + invariantes de governança contra Postgres -efêmero pg17). `.github/workflows/perf.yml`: `build-and-size` = `pnpm build`. -`.github/workflows/e2e.yml` roda **45 das 46 specs** Playwright contra um Supabase local de -verdade com o `baseline.sql` aplicado — o mesmo banco que o self-hoster tem. **É check -obrigatório desde 2026-08-08.** A **única** de fora é `vps-fresh-onboarding` (WAHA + Redis + -Resend + Nuvemshop; é a P0 da doutrina de QA) — ou seja, `e2e` verde não prova a jornada de -instalação fresca. `followup-journey`, `webhooks` e `capacidades-do-agente` estiveram fora e -**voltaram**: rodam hoje (`e2e.yml`, listas `SPECS_PARTE_1`/`SPECS_PARTE_2`). +## O que o CI cobre -`.github/workflows/publish-image.yml`: `imagens-ok` = as três imagens Docker constroem. **Obrigatório -desde 2026-08-13.** +| Check | Workflow | O que roda | +| ---------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `verify` | `.github/workflows/ci.yml` | `typecheck` + `lint` + `lint:channels` + `test:unit` + `test:shell` | +| `invariants` | `.github/workflows/ci.yml` | `pnpm test:db` — Postgres efêmero pg17, `baseline.sql` em install **e** update, isolamento RLS + governança | +| `build-and-size` | `.github/workflows/perf.yml` | `pnpm build` | +| `e2e` | `.github/workflows/e2e.yml` | Playwright contra Supabase local com o `baseline.sql` aplicado — o mesmo banco que o self-hoster tem | +| `imagens-ok` | `.github/workflows/publish-image.yml` | As três imagens Docker constroem | -**Os cinco são checks obrigatórios** na branch protection da `main` — medido em 2026-08-14 @ `741c4ec8`: +**Os cinco são checks obrigatórios na branch protection da `main`.** Meça em vez de acreditar: -```console -$ gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' -verify, build-and-size, invariants, e2e, imagens-ok +```bash +gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' ``` -> Este bloco estava errado em quatro pontos até 2026-08-14 (dizia "três checks", "28 das 32 -> specs", "e2e não é obrigatório ainda" e listava como excluídas três specs que já rodavam). -> A pior era a do `e2e`: quem lesse mediria um PR contra a régua errada. **Reconte antes de -> citar** — `ls tests/e2e/*.spec.ts | wc -l` e o comando acima. +O `e2e` roda todas as specs de `tests/e2e/`, menos as declaradas em `FORA_DO_CI` — hoje só +`vps-fresh-onboarding` (precisa de WAHA + Redis + Resend + Nuvemshop). **Ou seja: `e2e` verde não +prova a jornada de instalação fresca**, que é a P0 da doutrina de QA Visual e o produto que se +vende; se você mexeu nela, a prova é sua. `tests/unit/e2e-cobertura-completa.test.ts` reprova spec +que sumiu de todas as listas: + +```bash +ls tests/e2e/*.spec.ts | wc -l # specs no disco +grep -A20 'FORA_DO_CI: >-' .github/workflows/e2e.yml # o que o CI declara não cobrir +``` ## Padrões de código (observados no repo, não inventados) -- **Route handler:** valida input com Zod → guard (`requireRole` / `requirePlatformAdmin` / - secret) → query com `organization_id` explícito → `audit()` se mutação → `ok()` / `fail()`. +- **Route handler:** valida input com Zod → guard (`requireRole` / `requirePlatformAdmin` / secret) + → query com `organization_id` explícito → `audit()` se mutação → `ok()` / `fail()`. - Erro: `fail(code, message, status)` com código de `lib/api/errors.ts`. Nunca `throw` cru na borda. - JSON **snake_case** na API. Dinheiro em `_cents` + `currency`. Datas ISO-8601 UTC. - Log: `lib/logger.ts` (estruturado). **`console.log` é proibido** em código merged. -- Testes ao lado do código (`lib/foo/bar.test.ts`) ou em `tests/{unit,api,invariants,e2e}/`. +- Testes ao lado do código (`lib/foo/bar.test.ts`) ou em `tests/unit`, `tests/api`, + `tests/invariants`, `tests/e2e`. - Comentários em PT-BR são a norma neste repo — mantenha o idioma do arquivo que editar. ### Marca própria (white-label) — o produto é revendido, e o nome não é seu -- **Nunca escreva "Deskcomm"/"DeskcommCRM" em código que alcança o usuário.** `tests/unit/branding.test.ts` varre `app|components|lib|workers|hooks` e reprova; a allowlist **só encolhe**. -- A marca resolve do **banco** (`platform_branding` para a instalação, `organizations.settings.branding` para a organização). `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX` no `.env` são **semente e piso de rollback**, não a fonte. -- Precisa da marca **fora do DOM** (e-mail, remetente, ícone, `issuer` do MFA)? Use `marcaDaSaida()` de `lib/branding/saida.ts` — um hex e uma frente legível, tema claro. Nunca entregue `MarcaResolvida` a um template de e-mail. -- Resolvedor de marca **nunca lança**: ele roda em `app/layout.tsx`, e um throw ali é 500 em todas as telas. -- **O PDF de LGPD não leva marca** — ele nomeia o controlador (`organizations.legal_name`) e o DPO. Isso é decisão, não omissão; há gate no mapa de arquitetura. -- Contexto de venda em `docs/white-label.md`; mapa em `docs/architecture/marca-propria.architecture.json`. +- **Nunca escreva "Deskcomm"/"DeskcommCRM" em código que alcança o usuário.** + `tests/unit/branding.test.ts` varre `app|components|lib|workers|hooks` e reprova; a allowlist + **só encolhe**. +- A marca resolve do **banco** (`platform_branding` para a instalação, + `organizations.settings.branding` para a organização). `APP_NAME` / `APP_LOGO_URL` / + `APP_ACCENT_HEX` no `.env` são **semente e piso de rollback**, não a fonte. +- Precisa da marca **fora do DOM** (e-mail, remetente, ícone, `issuer` do MFA)? Use `marcaDaSaida()` + de `lib/branding/saida.ts` — um hex e uma frente legível, tema claro. Nunca entregue + `MarcaResolvida` a um template de e-mail. +- Resolvedor de marca **nunca lança**: ele roda em `app/layout.tsx`, e um throw ali é 500 em todas + as telas. +- **O PDF de LGPD não leva marca** — ele nomeia o controlador (`organizations.legal_name`) e o DPO. + É decisão, não omissão; há gate no mapa de arquitetura. +- Contexto de venda em [`docs/white-label.md`](docs/white-label.md); mapa em + `docs/architecture/marca-propria.architecture.json`. ## Diretórios e arquivos SENSÍVEIS -- **`supabase/baseline.sql`** — é o que o `install.sh`/`update.sh` do self-host aplicam. - Toda mudança de schema tem que aparecer aqui **como apêndice idempotente**, senão - não chega em quem instalou. Ver doutrina de Migrations em `CLAUDE.md`. -- **`supabase/migrations/*.sql` já aplicadas** — nunca edite. Corrija com migration nova. -- **`lib/supabase/admin.ts`** — service role **bypassa RLS**. 89 rotas o usam; toda - query precisa filtrar `organization_id` manualmente, resolvido de fonte confiável - (cookie/JWT/webhook secret/path token), **nunca do body**. -- **`lib/auth/public-paths.ts`** — adicionar path aqui remove a checagem de auth de borda. - Só com guard próprio dentro da rota. +- **`supabase/baseline.sql`** — é o que o `install.sh`/`update.sh` do self-host aplicam. Toda + mudança de schema tem que aparecer aqui **como apêndice idempotente**, senão não chega em quem + instalou. Ver a doutrina de Migrations em [`CLAUDE.md`](CLAUDE.md). +- **`supabase/migrations/`, arquivos já aplicados** — nunca edite. Corrija com migration nova. +- **`lib/supabase/admin.ts`** — service role **bypassa RLS**. Toda query precisa filtrar + `organization_id` manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path + token), **nunca do body**. Quem já o usa: + `grep -rl 'supabase/admin\|createAdminClient' app/api --include='route.ts'`. +- **`lib/auth/public-paths.ts`** — adicionar path aqui remove a checagem de auth de borda. Só com + guard próprio dentro da rota. - **`.env*`** — não abra, não copie valor, não logue. Só `.env.example` é template. -- **`docker-compose.traefik.yml`** — numa VPS que já tem proxy reverso próprio - (Hostinger, Coolify, Dokploy…), é o único lugar que dá ao contêiner `app` as labels - de roteamento. Todo `up -d` leva os **dois** arquivos de compose: +- **`docker-compose.traefik.yml`** — numa VPS que já tem proxy reverso próprio (Hostinger, Coolify, + Dokploy…), é o único lugar que dá ao contêiner `app` as labels de roteamento. Todo `up -d` leva os + **dois** arquivos de compose: `docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml --env-file .env up -d app`. - Esquecer o segundo `-f` recria o contêiner sem labels: o proxy deixa de enxergá-lo e o - domínio inteiro responde `404`, com o contêiner `healthy` — o healthcheck é um probe TCP - interno e não sabe nada de roteamento. Runbook: `docs/runbooks/deploy.md`. + Esquecer o segundo `-f` recria o contêiner sem labels: o proxy deixa de enxergá-lo e o domínio + inteiro responde `404`, com o contêiner `healthy` — o healthcheck é um probe TCP interno e não + sabe nada de roteamento. Runbook: [`docs/runbooks/deploy.md`](docs/runbooks/deploy.md). ## Arquivos GERADOS — não editar à mão -- `lib/database.types.ts` (6.1k linhas — gerado do schema Supabase) +- `lib/database.types.ts` (gerado do schema Supabase) - `graphify-out/` (grafo de conhecimento; regenerado por `/graphify .`) - `pnpm-lock.yaml`, `tsconfig.tsbuildinfo`, `next-env.d.ts`, `.next/` ## Como validar uma alteração -1. `pnpm typecheck` e `pnpm lint` zerados. +1. `pnpm typecheck`, `pnpm lint` e `pnpm lint:channels` zerados. 2. `pnpm test:unit` verde. -3. Tocou schema/RLS/tabela tenant-aware → `pnpm test:db` (sobe Postgres efêmero via Docker, - aplica `baseline.sql` em modo install **e** update, roda os invariantes). -4. Tocou UI ou fluxo de usuário → `pnpm test:e2e` com evidência visual. **`curl` não conta** - como prova de UX (doutrina de QA Visual em `CLAUDE.md`). -5. Mudou schema → migration versionada em `supabase/migrations/` **+** apêndice idempotente - em `supabase/baseline.sql` **+** linha em `supabase/migrations/MANIFEST.md`. Os três juntos. -6. Criou função em `public` → `revoke execute on function ... from public, anon;` e depois - `grant` só a quem precisa. São **duas** origens de `EXECUTE` e revogar uma só deixa a - função exposta como RPC alcançável pela anon key. Detalhe em `CLAUDE.md`, item 9 da - doutrina de Migrations. - -## Testes existentes (CONFIRMADO) - -Medido em 2026-08-14 @ `741c4ec8`, com o comando ao lado de cada número: - -- **257** arquivos de teste unitário em `tests/unit/` (`git ls-files 'tests/unit/*.test.ts' 'tests/unit/*.test.tsx' | wc -l`). O repo tem **491** arquivos `*.test.ts(x)` no total (`git ls-files '*.test.ts' '*.test.tsx' | wc -l`) — a diferença vive junto ao código, fora de `tests/`, e também roda em `test:unit`. -- **102** arquivos de invariante de banco em `tests/invariants/` (`git ls-files 'tests/invariants/*.test.ts' | wc -l`) — RLS/isolamento cross-tenant, - RBAC, governança (G1–G6). Excluídos do `test:unit` de propósito; rodam via `pnpm test:db` - **e no job `invariants` do CI**. -- **46** specs Playwright em `tests/e2e/` (`ls tests/e2e/*.spec.ts | wc -l`). **45 rodam no CI** (via `e2e.yml`, - **obrigatório**). A única de fora é `vps-fresh-onboarding`, por dependência de serviço externo - (WAHA/Redis/Resend/Nuvemshop). Ver issue #63. - -## Limitações conhecidas (estado em 2026-07-29, contra `origin/main` @ 789dfa6) - -- **1 das 46 specs E2E segue fora do CI** (`vps-fresh-onboarding`), e o `e2e` **é** check - obrigatório desde 2026-08-08. Ou seja: um PR que quebre o `e2e` não entra — mas a jornada de - instalação fresca, que é o produto que se vende, continua sem gate. Se você mexeu nela, a - prova é sua. *(Corrigido em 2026-08-14; a redação anterior — "4 das 32, não-obrigatório" — - mudava a régua de qualquer triagem que a lesse.)* -- Rate limit HTTP: `lib/auth/rate-limit.ts` cobre **login, signup, recuperação de senha e - aceite de convite** (contando por IP **e** por identificador hasheado); `checkRateLimit` cobre - o webhook de captação e o dispatcher de IA. **Crons e MCP seguem sem.** Meça antes de agir: +3. Tocou schema/RLS/tabela tenant-aware → `pnpm test:db` (sobe Postgres efêmero via Docker, aplica + `baseline.sql` em modo install **e** update, roda os invariantes). +4. Tocou UI ou fluxo de usuário → `pnpm test:e2e` com evidência visual. **`curl` não conta** como + prova de UX (doutrina de QA Visual em [`CLAUDE.md`](CLAUDE.md)). +5. Mudou schema → migration versionada em `supabase/migrations/` **+** apêndice idempotente em + `supabase/baseline.sql` **+** linha em `supabase/migrations/MANIFEST.md`. Os três juntos. +6. Criou função em `public` → `revoke execute on function ... from public, anon;` e depois `grant` + só a quem precisa. São **duas** origens de `EXECUTE`, e revogar uma só deixa a função exposta + como RPC alcançável pela anon key. Detalhe em [`CLAUDE.md`](CLAUDE.md), item 9 da doutrina de + Migrations. +7. Tocou `Dockerfile*`, `docker-compose*.yml` ou `hostgator-setup-kit/` → `pnpm test:shell`. + +## Testes existentes — meça, não cite de cabeça + +Contagem de arquivo de teste envelhece a cada PR. Os comandos: + +```bash +git ls-files 'tests/unit/*.test.ts' 'tests/unit/*.test.tsx' | wc -l # suíte unitária central +git ls-files '*.test.ts' '*.test.tsx' | wc -l # total (inclui testes ao lado do código) +git ls-files 'tests/invariants/*.test.ts' | wc -l # invariantes de banco (RLS, RBAC, governança) +ls tests/e2e/*.spec.ts | wc -l # specs Playwright +``` + +Os invariantes de `tests/invariants/` são excluídos do `test:unit` de propósito — precisam de um +Postgres real e rodam via `pnpm test:db`, no job `invariants` do CI. + +## Limitações conhecidas + +Nenhuma tem número fixo aqui: cada uma vem com a medição. + +- **A jornada de instalação fresca não tem gate.** `vps-fresh-onboarding` está em `FORA_DO_CI` por + depender de serviço externo (WAHA/Redis/Resend/Nuvemshop) — issue #63. +- **Rate limit HTTP é parcial.** `lib/auth/rate-limit.ts` cobre login, signup, recuperação de senha + e aceite de convite (contando por IP **e** por identificador hasheado); `checkRateLimit` cobre o + webhook de captação e o dispatcher de IA. **Crons e MCP seguem sem.** Meça antes de agir: `grep -rln 'authRateLimited\|checkRateLimit(' app lib --include='*.ts' --include='*.tsx'`. - Esta linha dizia "existe em 2 pontos; login e signup estão sem" — era o estado anterior à - issue #64, e o `docs/threat-model.md` ainda carrega a versão velha, com nota de reauditoria. -- Fallback do rate limit é **em memória** — sem Upstash configurado o limite é por processo. -- `Idempotency-Key` implementado em **1** rota, apesar de o contrato prometer nos POSTs de criação. -- **`.env.example` está completo** — medido em 2026-08-14: das 45 chaves de `lib/env.ts`, a - única ausente é `NODE_ENV`, que não é configuração do operador. Esta linha dizia que faltavam - 6, "incluindo 3 secrets"; os três (`IMPERSONATE_COOKIE_SECRET`, `INTERNAL_CRON_SECRET`, - `LGPD_SIGNING_KEY`) estão lá. Se você adicionar env var, adicione nos dois lugares (item 9 do - DoD) — a regra continua valendo, o que caiu foi a dívida. -- `lib/auth/invite-token.ts` cai em `"dev-fallback"` como secret HMAC se nenhum secret existir - (inalcançável em produção, porque `INTERNAL_SECRET` é obrigatório e derruba o boot). -- **89 dos 169 handlers de `app/api/**` usam service role** — sem gate automático para o filtro de - `organization_id`. Escrevendo handler novo, o filtro é responsabilidade sua. -- Detalhes e prioridade: [`docs/harness-audit.md`](docs/harness-audit.md), +- **O fallback do rate limit é em memória** — sem Upstash configurado, o limite é por processo. +- **`Idempotency-Key` tem adoção parcial**, apesar de o contrato prometer nos POSTs de criação: + `grep -rln 'Idempotency-Key' app/api --include='*.ts'`. +- **`lib/auth/invite-token.ts` cai em `"dev-fallback"`** como secret HMAC se nenhum secret existir + (inalcançável em produção: `INTERNAL_SECRET` é obrigatório e derruba o boot). +- **Handler com service role não tem gate automático** para o filtro de `organization_id`. + Escrevendo handler novo, o filtro é responsabilidade sua. +- Prioridade e detalhe: [`docs/harness-audit.md`](docs/harness-audit.md), [`docs/current-state.md`](docs/current-state.md) e [`docs/threat-model.md`](docs/threat-model.md). ## Regras de segurança - Sempre `getUser()` no backend. **Nunca `getSession()`** (confia no cookie sem revalidar). -- API key/token **nunca** em query string — só header. Plaintext do bearer é mostrado - **uma vez**; no banco só hash SHA256. +- API key/token **nunca** em query string — só header. O plaintext do bearer é mostrado **uma vez**; + no banco, só hash SHA256. - HMAC de webhook com `crypto.timingSafeEqual`. Fail-closed quando o secret falta. -- Nunca logue segredo, token, CPF, telefone ou e-mail. Sentry tem `beforeSend` que - higieniza — não confie nele como única camada. -- Não commite screenshot/dump com dado real de cliente. +- Nunca logue segredo, token, CPF, telefone ou e-mail. O Sentry tem `beforeSend` que higieniza — não + confie nele como única camada. +- Não commite screenshot ou dump com dado real de cliente. ## Packaging — se você tocou `Dockerfile*`, `docker-compose*.yml` ou `hostgator-setup-kit/` Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md). O não-negociável: - **Nenhum serviço de `docker-compose.prod.yml` constrói na máquina do cliente.** Todo serviço - declara `image:` de uma imagem publicada; `build:` só existe **ao lado**, como escape. - Serviço `build:`-only é pulado por `docker compose pull` e imune a `up -d` sem `--build` — - ele não é só caro de instalar, ele **nunca é atualizado**. + declara `image:` de uma imagem publicada; `build:` só existe **ao lado**, como escape. Serviço + `build:`-only é pulado por `docker compose pull` e imune a `up -d` sem `--build` — ele não é só + caro de instalar, ele **nunca é atualizado**. - **Publicação é ato do CI**, nunca da sua máquina: build ARM local não roda na VPS amd64. - **Instalação de cliente aponta para número de versão**, nunca para tag móvel. Aqui `latest` significa **topo da `main`**, não última release — quem quer a última release usa `stable`. - **Dependência upstream é referenciada com tag fixa, nunca republicada** (WAHA é licenciado). - **Bump de versão não pode exigir que o operador da VPS edite arquivo à mão.** -`pnpm test:shell` é o único gate que exercita o kit. Rode-o. +`pnpm test:shell` é a única suíte que exercita o kit — ela roda dentro do check `verify`, e você +deve rodá-la localmente antes de abrir o PR. ## Critério de conclusão -Vale a **Definition of Done em [`CLAUDE.md`](CLAUDE.md)** — conte lá em vez de confiar num número aqui (`sed -n '/^## Definition of Done/,/^Um staff engineer/p' CLAUDE.md | grep -cE '^[0-9]+\. '`; esta linha já disse 15 e o DoD tem 16). A régua tem que DELIMITAR a seção: a primeira versão desta linha oferecia `grep -c '^[0-9]\+\. \*\*' CLAUDE.md`, que devolve **25** — casa toda linha numerada em negrito do arquivo (anti-patterns, packaging, higiene de branches, migrations) e perde os itens 1–10 do próprio DoD, que não são negrito. Trocar o número pelo comando só ajuda se o comando responder à pergunta. Não declare pronto -sem: typecheck/lint zerados, testes relevantes verdes, RLS testada se tocou tabela -tenant-aware, migration + baseline + MANIFEST se mudou schema, prova visual se mudou UI, e a -regra de packaging acima se mudou o artefato que o self-hoster instala. +Vale a **Definition of Done de [`CLAUDE.md`](CLAUDE.md)** — conte lá em vez de confiar num número +aqui: + +```bash +sed -n '/^## Definition of Done/,/^Um staff engineer/p' CLAUDE.md | grep -cE '^[0-9]+\. ' +``` + +A régua tem que DELIMITAR a seção: um `grep` no arquivo inteiro casa toda linha numerada de +anti-patterns, packaging e migrations, e perde os itens do próprio DoD. Não declare pronto sem: +typecheck/lint zerados, testes relevantes verdes, RLS testada se tocou tabela tenant-aware, +migration + baseline + MANIFEST se mudou schema, prova visual se mudou UI, e a regra de packaging +acima se mudou o artefato que o self-hoster instala. ## Regra final — não invente -Este repositório tem PRDs, specs, regras de negócio e doutrina escritos -(`docs/prd/`, `docs/specs/`, `docs/business-rules/`, `docs/doctrine/`). -**Nunca invente regra de negócio, número, SLA ou comportamento de produto.** -Se a regra não está escrita, diga que não está e pergunte — não preencha a lacuna com -suposição plausível. Ao documentar, marque o que é `CONFIRMADO` (provado por código) e o -que é `INFERIDO`. +Este repositório tem PRDs, specs, regras de negócio e doutrina escritos (`docs/prd/`, `docs/specs/`, +`docs/business-rules/`, `docs/doctrine/`). **Nunca invente regra de negócio, número, SLA ou +comportamento de produto.** Se a regra não está escrita, diga que não está e pergunte — não preencha +a lacuna com suposição plausível. Ao documentar, marque o que é `CONFIRMADO` (provado por código) e +o que é `INFERIDO`. diff --git a/CLAUDE.md b/CLAUDE.md index 103f058c0..b5dbe7ab3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,427 +1,567 @@ # CLAUDE.md — DeskcommCRM -> Instruções pra futuras sessões Claude trabalhando neste repo. Leitura obrigatória antes de qualquer task de código. +> **Doutrina de código deste repositório.** Autoridade final sobre convenção, schema e +> anti-pattern. Leitura obrigatória antes de qualquer task de código. -**Este arquivo é a doutrina — a autoridade final sobre convenção e anti-pattern.** Complementos, na ordem em que ajudam: +## Precedência — qual documento vence -- [`AGENTS.md`](AGENTS.md) — mesmo contrato em forma portável (para Codex/Cursor/Copilot e afins). É derivado deste arquivo, não o substitui. **Ao mudar doutrina aqui, verifique se `AGENTS.md` desatualizou.** -- [`docs/index.md`](docs/index.md) — índice dos 149 docs, com regra de precedência quando dois docs discordam. Use antes de sair varrendo `docs/`. -- [`docs/current-state.md`](docs/current-state.md) — o que está pronto, incompleto e quebrado. **Leia antes de estimar ou prometer qualquer coisa.** -- [`docs/harness-audit.md`](docs/harness-audit.md) — onde a verificação tem buraco. Importante: `pnpm gov:verify` **não** cobre `test:db` nem `test:e2e` — verde ali não é prova para mudança de schema ou de UI. +| # | Documento | Papel | +| --- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 1 | [`.ai/AI_BOOTSTRAP.md`](.ai/AI_BOOTSTRAP.md) | Porta de entrada: o que ler, nesta ordem, e as regras que evitam dano | +| 2 | **`CLAUDE.md`** (este) | Doutrina. Vence qualquer outro documento em conflito de regra | +| 3 | [`AGENTS.md`](AGENTS.md) | O mesmo contrato em forma portável (Codex, Cursor, Copilot, Amp). É derivado deste arquivo — ao mudar doutrina aqui, atualize-o | +| 4 | [`docs/index.md`](docs/index.md) | Índice dos documentos, com a regra de precedência interna de `docs/` | + +Acima dos quatro está **o repositório**: código, `package.json`, workflows e `gh api` medem o +estado; a prosa apenas o descreve. Onde os dois discordarem, a prosa está errada — corrija-a. + +**Por isso este arquivo evita número volátil.** Contagem de rota, de teste e de spec envelhece +entre um PR e o próximo, e uma triagem que a use como régua mede contra o número errado. Onde a +afirmação puder virar comando, ela vira comando. + +Complementos por assunto: + +- [`docs/current-state.md`](docs/current-state.md) — o que está pronto, incompleto e quebrado. Leia antes de estimar ou prometer. +- [`docs/harness-audit.md`](docs/harness-audit.md) — onde a verificação tem buraco. - [`docs/threat-model.md`](docs/threat-model.md) — superfície de ataque real do self-host. +- [`VISION.md`](VISION.md) — posicionamento, nicho e monetização. --- -## Visão (1 parágrafo) +## Visão -DeskcommCRM é um sistema operacional de vendas open source com agentes de IA nativos — multi-nicho (e-commerce, clínicas, imobiliárias, infoprodutos, serviços), com WhatsApp como canal primário (via WAHA). Agentes com RAG por tenant atendem, qualificam e movem o funil junto com humanos; CRM inteiro exposto via MCP. Monetização = self-host em VPS (parceria HostGator), não assinatura. Arquitetura multi-tenant com RLS desde o dia 1; LGPD nativa. Posicionamento completo: `VISION.md`. +DeskcommCRM é um sistema operacional de vendas open source com agentes de IA nativos — +multi-nicho (e-commerce, clínicas, imobiliárias, infoprodutos, serviços), com WhatsApp como canal +primário (via WAHA). Agentes com RAG por tenant atendem, qualificam e movem o funil junto com +humanos; o CRM inteiro é exposto via MCP. Monetização = self-host em VPS (parceria HostGator), +não assinatura. Multi-tenant com RLS desde o dia 1; LGPD nativa. + +**A consequência que muda como você trabalha:** o produto é distribuído como código, e quem +instala numa VPS **é** o usuário. Mudança que funciona na sua máquina e quebra no clone fresco é +bug de produto, não detalhe de ambiente. --- ## Stack canônica -- **Frontend:** Next.js 16 App Router (Turbopack) + React 19 + TypeScript 6 estrito + Tailwind + shadcn/ui (style: `new-york`, neutral) -- **Backend:** Next.js Route Handlers (mesmo repo); workers via `event_log` table + cron +- **Frontend:** Next.js 16 App Router · React 19 · TypeScript 6 estrito · Tailwind 3 · shadcn/ui (style `new-york`, neutral) +- **Backend:** Route Handlers no mesmo repo; workers via tabela `event_log` + cron - **DB:** Supabase (Postgres). RLS em toda tabela tenant-aware. Extensions: `uuid-ossp`, `pgcrypto`, `vector` -- **Auth:** Supabase Auth via `@supabase/ssr`. Cookie SameSite=Strict, HttpOnly, Secure -- **Realtime:** Supabase Realtime (postgres_changes + broadcast) -- **Storage:** Supabase Storage (bucket `whatsapp-media` privado, URLs assinadas) +- **Auth:** Supabase Auth via `@supabase/ssr`. Cookie `SameSite=Strict`, `HttpOnly`, `Secure` +- **Realtime:** Supabase Realtime (`postgres_changes` + broadcast) · **Storage:** bucket `whatsapp-media` privado, URLs assinadas - **WhatsApp:** WAHA Plus, engine NOWEB -- **Filas/eventos:** `event_log` table + workers (não usar Inngest/Trigger no MVP) +- **Filas/eventos:** tabela `event_log` + workers (sem Inngest/Trigger no MVP) - **Rate limit:** Upstash Redis sliding window -- **AI:** Vercel AI Gateway (Anthropic primário; OpenAI backup pra embeddings); strings tipo `"anthropic/claude-sonnet-4-6"` -- **Validação:** Zod em todo input externo (request body, webhook payload, env) -- **Observability:** Sentry com `beforeSend` sanitizado +- **AI:** Vercel AI Gateway (Anthropic primário; OpenAI backup para embeddings); modelos como `"anthropic/claude-sonnet-4-6"` +- **Validação:** Zod 4 em todo input externo (body, webhook, env) +- **Testes:** Vitest 4 · Playwright 1 · **Observability:** Sentry 10 com `beforeSend` sanitizado +- **Runtime:** Node ≥22 (`.nvmrc`) · **Gerenciador:** pnpm (campo `packageManager` do `package.json`) + +Só a **major** é declarada, aqui e no `AGENTS.md`: é onde o idioma da biblioteca muda, e é o que +`tests/unit/agents-md-versoes.test.ts` cobra contra o `package.json`. Para a versão exata, a fonte +é o `package.json`. **Versão do produto** é a do topo do `CHANGELOG.md` +(`grep -m2 -E '^## \[' CHANGELOG.md`), com tag git correspondente — o campo `version` do +`package.json` não é fonte de nada e não é lido em runtime. + +--- + +## Como rodar local + +O passo a passo completo (extensões do Postgres, `.env.local`, WAHA) está no +[`README.md`](README.md) e em [`docs/SETUP.md`](docs/SETUP.md). O essencial: + +```bash +nvm use # Node 22 +npm install -g pnpm && pnpm install +cp .env.example .env.local # guia em docs/SETUP.md +docker compose up -d # WAHA local (opcional em dev sem WhatsApp) +pnpm dev # http://localhost:3000 +``` + +**Schema local: aplique `supabase/baseline.sql`, NUNCA a cadeia de `supabase/migrations/`.** +Migrations antigas são stubs `SELECT 1;` — a cadeia não sobe do zero, `supabase db push` "passa" e +deixa o banco vazio. O baseline é o mesmo artefato que o `install.sh` aplica na VPS. --- ## Convenções críticas (NÃO NEGOCIÁVEIS) ### Multi-tenancy + - `organization_id uuid not null references organizations(id) on delete cascade` em **toda** tabela tenant-aware -- RLS policy `tenant_isolation__all` aplicada via helper `fn_user_org_ids()` -- Service role bypassa RLS — handlers que usam admin client **DEVEM** filtrar `organization_id` manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path token), **NUNCA do body** +- RLS policy `tenant_isolation__all`, aplicada via helper `fn_user_org_ids()` +- Service role **bypassa RLS**: handler que usa o admin client **DEVE** filtrar `organization_id` + manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path token) e **NUNCA do body** - Toda query que cruza tabelas tenant-aware filtra `organization_id` explicitamente -- Teste de isolamento (cria 2 tenants, verifica não-vazamento) é obrigatório no CI antes de merge +- Teste de isolamento (2 tenants, prova de não-vazamento) é obrigatório antes do merge — roda no check `invariants` + +Quais handlers usam o admin client hoje: +`grep -rl 'supabase/admin\|createAdminClient' app/api --include='route.ts'` ### Idempotência & event sourcing leve -- Mensagens WhatsApp e eventos externos: `unique (organization_id, external_id)` + captura `code === '23505'` no INSERT -- POSTs de criação na API aceitam header `Idempotency-Key: ` (TTL 24h via Upstash) -- **Trigger Postgres NUNCA faz HTTP.** Trigger emite linha em `event_log`; worker (cron / Realtime listener) consome e dispara side effect + +- Mensagem de WhatsApp e evento externo: `unique (organization_id, external_id)` + captura de `code === '23505'` no INSERT +- POST de criação aceita `Idempotency-Key: ` (TTL 24h via Upstash). **A adoção é parcial** — + o contrato promete em todo POST de criação e a implementação não chegou lá. Meça antes de + afirmar cobertura: `grep -rln 'Idempotency-Key' app/api --include='*.ts'` +- **Trigger Postgres NUNCA faz HTTP.** Trigger emite linha em `event_log`; worker (cron ou listener + de Realtime) consome e dispara o side effect ### API REST `/api/v1/` + - Versionamento por path. JSON snake_case. UUID v4. ISO-8601 UTC. Dinheiro em `_cents` + `currency` ISO-4217 -- Wrapper sucesso: `{ data, meta?: { cursor, has_more, total } }` -- Wrapper erro: `{ error: { code, message, details? } }` — usar helpers `ok()` / `fail()` de `lib/api/wrappers.ts` +- Sucesso: `{ data, meta?: { cursor, has_more, total } }` · Erro: `{ error: { code, message, details? } }` +- Use sempre `ok()` / `fail()` de `lib/api/wrappers.ts` com código de `lib/api/errors.ts`. Nunca + monte `Response` na mão nem deixe `throw` cru na borda - Paginação: cursor opaco base64+HMAC por default -- Auth dual: cookie session (frontend) OU `Authorization: Bearer tok_...` (server-to-server) -- **API key NUNCA em query string** (vaza em logs Vercel/CF). Sempre header -- Plaintext de bearer token mostrado **uma vez** na criação; depois apenas hash SHA256 no DB -- Rate limit headers: `X-RateLimit-*` + `Retry-After` em 429 -- `X-Request-Id` em toda response (correlaciona com audit log) +- Auth dual: cookie de sessão (frontend) **ou** `Authorization: Bearer tok_...` (server-to-server) +- **API key NUNCA em query string** (vaza em log de proxy/CDN) — sempre header +- Plaintext do bearer é mostrado **uma vez**, na criação; no banco só hash SHA256 +- `X-RateLimit-*` + `Retry-After` no 429 · `X-Request-Id` em toda response (correlaciona com o audit log) ### Auth & RBAC -- Sempre `getUser()` (valida JWT no backend). NUNCA `getSession()` (confia no cookie local) -- 4 roles dentro do tenant: `viewer` (1) < `agent` (2) < `manager` (3) < `admin` (4) -- Super-admin de plataforma é uma role transversal — `is_platform_admin` (decisão final na Spec 01) -- MFA TOTP é **opcional e ligado por quem administra** — não é mais forçado por papel. Quem exige são duas políticas independentes que SOMAM: `platform_admins.mfa_required` (para o super-admin) e `organizations.settings.security.mfa_required` (para o `admin` do tenant). O padrão de ambas é **não exigir**, e o `bootstrap-owner.ts` grava `false` explícito. Regra pura em `lib/auth/politica-mfa.ts` - - **Por que mudou:** o gate era `isPlatformAdmin || role === "admin"`, sem opção, e o `install.sh` cria o dono como platform admin — então TODA instalação self-host recebia um bloqueador de tela cheia logo depois do onboarding, um passo que o wizard nunca anunciou. Decisão do dono do produto; segurança que expulsa o usuário na primeira tela não protege ninguém - - **⚠️ CADASTRAR e PROVAR são perguntas diferentes.** A política decide o cadastro. Já `mfaEmDivida()` — o 403 `mfa_required` das rotas — NÃO consulta a política: quem TEM fator prova na sessão, sempre. Ligá-lo à política faria quem ativa a verificação por vontade própria ter o fator ignorado - - Ligar/desligar vive em **Configurações › Segurança**; desligar o próprio fator exige sessão `aal2` (senão uma sessão roubada desliga a proteção com um clique) -- Permissão por pipeline (`user_pipeline_access`) **NÃO** entra no MVP + +- Sempre `getUser()` (valida o JWT no backend). **NUNCA `getSession()`** (confia no cookie local) +- 4 papéis dentro do tenant: `viewer` (1) < `agent` (2) < `manager` (3) < `admin` (4). Guard + canônico: `requireRole()` em `lib/auth/require-role.ts` +- Super-admin de plataforma é papel transversal — `is_platform_admin` +- Permissão por pipeline (`user_pipeline_access`) **não** entra no MVP +- **MFA TOTP é opcional e ligado por quem administra.** Quem exige são duas políticas + independentes que somam: `platform_admins.mfa_required` (super-admin) e + `organizations.settings.security.mfa_required` (admin do tenant). O default de ambas é **não + exigir**, e `scripts/bootstrap-owner.ts` grava `false` explícito. A regra pura vive em + `lib/auth/politica-mfa.ts` + - **Por quê:** o gate era `isPlatformAdmin || role === "admin"`, sem opção, e o `install.sh` cria + o dono como platform admin — então toda instalação self-host recebia um bloqueador de tela + cheia logo depois do onboarding, um passo que o wizard nunca anunciou. Segurança que expulsa o + usuário na primeira tela não protege ninguém + - **CADASTRAR e PROVAR são perguntas diferentes.** A política decide o cadastro. O 403 + `mfa_required` das rotas (`mfaEmDivida()`) **não** consulta a política: quem TEM fator prova na + sessão, sempre. Ligá-lo à política faria quem ativou a verificação por vontade própria ter o + fator ignorado + - O liga/desliga vive em **Configurações › Segurança**; desligar o próprio fator exige sessão + `aal2` — senão uma sessão roubada desliga a proteção com um clique ### Audit log + - Toda mutação POST/PATCH/DELETE bem-sucedida → 1 entrada em `api_audit_log` (fire-and-forget, p99 ≤500ms) -- **Rodada de cron que não fez nada NÃO é mutação e não audita** — e a que fez, audita. `routing-worker` (1×/min) e `attendant-heartbeat` (1×/5min) auditavam incondicionalmente: ~51.840 linhas/mês numa instalação que não atende ninguém, e numa VPS real **95% do audit log** era batida de cron vazia (`docs/testing/user-journey-map.md`, achado 17). A guarda certa é *auditar quando houve efeito*, nunca *parar de auditar* — as duas direções são medidas por `tests/unit/cron-audita-so-quando-ha-efeito.test.ts`, que varre o AST de **toda** rota de `app/api/v1/cron/` -- Audit é append-only, e isso é do SCHEMA e não da prosa: nenhum papel tem GRANT de UPDATE/DELETE em `api_audit_log` — **nem `service_role`**. Para conferir na fonte em vez de acreditar nesta linha: +- Falha de escrita no audit gera alerta no Sentry; **não** bloqueia a mutação principal +- **Rodada de cron que não fez nada NÃO é mutação e não audita** — e a que fez, audita. Auditar + incondicionalmente enchia o log com batida vazia (numa VPS real, a maior parte do audit log). A + guarda certa é _auditar quando houve efeito_, nunca _parar de auditar_; as duas direções são + medidas por `tests/unit/cron-audita-so-quando-ha-efeito.test.ts`, que varre o AST de **toda** + rota de `app/api/v1/cron/` +- **Append-only é do SCHEMA, não da prosa:** nenhum papel tem GRANT de UPDATE/DELETE em + `api_audit_log` — nem `service_role`. Confira na fonte: ```bash psql "$SUPABASE_DB_URL" -c "select grantee, privilege_type from information_schema.role_table_grants where table_name='api_audit_log' and privilege_type in ('DELETE','UPDATE','TRUNCATE');" ``` - **`TRUNCATE` entra na consulta de propósito, e o resultado não é vazio.** Ele - está concedido a `anon`, `authenticated` e `service_role` — resíduo de o dump - enumerar os privilégios desta tabela (as demais recebem `GRANT ALL`, e quem as - protege é a RLS). Uma sonda que pergunte só por `DELETE`/`UPDATE` devolve zero - linhas e deixa quem leu concluindo que a tabela não pode ser esvaziada, quando - o privilégio que a esvazia INTEIRA está lá. Não é alcançável pela REST (o - PostgREST não emite `TRUNCATE`), então não é buraco de superfície — mas a - frase "append-only é do schema" só é inteira com esta ressalva escrita. -- **Retenção default de 5 anos, configurável, e agora EXECUTADA.** O expurgo é `public.fn_expurgar_auditoria_vencida` (`security definer`, **piso de 90 dias dentro do corpo**, revogada de anon/authenticated), chamada em lotes pelo cron `app/api/v1/cron/data-retention` (diário). O knob é `AUDIT_LOG_RETENTION_DAYS`. **Não há camada cold/S3** — o "hot 90 dias, cold (S3) o resto" que este arquivo afirmava por meses nunca existiu em código (auditoria de 2026-08-14: zero ocorrência de arquivamento), e um self-host não tem para onde arquivar: o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`. Para ver o que está em vigor: `grep -n "RETENCAO_AUDITORIA_DIAS" lib/retencao/politica.ts` -- Por que uma `security definer` de expurgo não é porta de adulteração (o argumento inteiro está no cabeçalho da migration 0167): ela **não tem seletor de linha** — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`; o piso mora **no corpo**, não em quem chama; não é alcançável pela REST; não amplia o raio de quem já tem a service key; e **registra a própria erosão** (`retention.sweep_run`, com a contagem, numa linha nova demais para a chamada seguinte alcançar) -- Falha de write em audit gera alerta Sentry, não bloqueia mutação principal + **`TRUNCATE` entra na consulta de propósito, e o resultado não vem vazio:** ele está concedido a + `anon`, `authenticated` e `service_role`, resíduo de o dump enumerar os privilégios desta tabela. + Não é alcançável pela REST (o PostgREST não emite `TRUNCATE`), então não é buraco de superfície — + mas uma sonda que pergunte só por `DELETE`/`UPDATE` devolve zero linhas e deixa quem leu + concluindo que a tabela não pode ser esvaziada, quando o privilégio que a esvazia INTEIRA está lá + +- **Retenção default de 5 anos, configurável e executada.** O expurgo é + `public.fn_expurgar_auditoria_vencida` (`security definer`, **piso de 90 dias dentro do corpo**, + revogada de anon/authenticated), chamada em lotes pelo cron `app/api/v1/cron/data-retention` + (diário). O knob é `AUDIT_LOG_RETENTION_DAYS`; a regra em vigor: + `grep -n "RETENCAO_AUDITORIA_DIAS" lib/retencao/politica.ts` +- **Não há camada cold/S3.** Um self-host não tem para onde arquivar: o Storage do cliente é a + MESMA cota, já dividida com `whatsapp-media` +- Por que uma `security definer` de expurgo não é porta de adulteração: ela **não tem seletor de + linha** — nenhum parâmetro de org, ator, ação ou id, e o único predicado é + `created_at < now() - N dias`; o piso mora **no corpo**, não em quem chama; não é alcançável pela + REST; não amplia o raio de quem já tem a service key; e **registra a própria erosão** + (`retention.sweep_run`, com a contagem, numa linha nova demais para a chamada seguinte alcançar). + Argumento completo no cabeçalho da migration 0167 ### LGPD -- Anonimização preferida sobre delete. Nome do contato vira `Cliente Anonimizado #N` + +- Anonimização é preferida sobre delete. Nome do contato vira `Cliente Anonimizado #N` - Cascade de redact: contact + conversations + messages (mídia removida do storage) + activities (preserva timestamps) - Reversão de anonimização: 403 `lgpd_anonymization_irreversible` -- SLA: data_request entregue D+7; redact executado D+15 -- Action audit obrigatória: `lgpd.data_request_received`, `lgpd.export_generated`, `lgpd.redact_executed`, `lgpd.consent_changed` - -### WAHA -- Plus obrigatório (Core não suporta multi-tenant, sem retry, sem S3) -- Engine NOWEB default; WEBJS apenas se precisar stickers animados / botões -- Auth: env do WAHA recebe **hash SHA512 hex** da api key; cliente envia plaintext em `X-Api-Key` -- Webhooks: HMAC SHA512 com `crypto.timingSafeEqual` -- Anti-banimento: throttle 1 msg/1.2s + jitter ≤800ms. Campanha 1 msg/5s. Warm-up 7-14d. Spinning de copy. Janela 7h-22h (domingo LIBERADO por default desde 2026-08-20; a janela é knob por canal) -- STOP detection: a regra mora em `lib/opt-out/deteccao.ts` e é a MESMA nos dois lados — - a ingestão (que grava `is_blocked=true`) e o runtime do agente. **Não é mais a palavra - solta:** só bloqueia palavra ISOLADA (mensagem inteira = a palavra) ou verbo de cessação - com OBJETO DE COMUNICAÇÃO ("parar de me mandar", "sair da lista"). Enquanto eram duas - regras, a ingestão bloqueava paciente que perguntou "tem como parar a dor?" — medido em - clínica, 12 falsos positivos num corpus de 32 frases de nicho. - Para ver o vocabulário em vigor sem confiar nesta linha: - `grep -n 'PALAVRAS_DE_OPT_OUT' -A20 lib/opt-out/deteccao.ts`, e as frases de controle em - `tests/unit/opt-out-deteccao.test.ts`. **Espanhol ainda NÃO é coberto** (`baja`, `salir`, - `no quiero recibir`) — ver PR #275. -- Mídia: subir pro Supabase Storage primeiro, passar URL ao WAHA (não inline base64) -- Multi-device: assinar `message.any` (não só `message`); tratar `fromMe=true` sem duplicar -- Grupos: SKIP CRM binding se `chatId.endsWith('@g.us')`. Sender é `p.author`, não `p.from` -- Cron `recover-stuck-messages` (`app/api/v1/cron/recover-stuck-messages/route.ts`, agendado no `scheduler` do `docker-compose.prod.yml`): marca `status='sending'` há >5min como `failed` **e abre aviso na Central** (`agent_inbox_items` kind `message_send_stuck`). Não toca em `queued`: esse estado tem dono (o agent-engine reagenda por `SEND_QUEUED_RETRY_MS`), e falhá-lo perderia mensagem que ia sair. Não reenvia — envio em dobro é pior que não-envio +- SLA: `data_request` entregue em D+7; redact executado em D+15 +- Audit obrigatório: `lgpd.data_request_received`, `lgpd.export_generated`, `lgpd.redact_executed`, `lgpd.consent_changed` + +### WhatsApp / WAHA + +- WAHA **Plus** obrigatório (o Core não suporta multi-tenant, não tem retry nem S3). Engine NOWEB + default; WEBJS só se precisar de sticker animado / botão +- Auth: o env do WAHA recebe **hash SHA512 hex** da api key; o cliente envia o plaintext em `X-Api-Key` +- Webhook: HMAC SHA512 com `crypto.timingSafeEqual`, fail-closed quando o secret falta +- Anti-banimento: throttle 1 msg/1,2s + jitter ≤800ms; campanha 1 msg/5s; warm-up 7–14 dias; + spinning de copy; janela 7h–22h (domingo liberado por default; a janela é knob por canal) +- **Opt-out (STOP):** a regra mora em `lib/opt-out/deteccao.ts` e é a MESMA nos dois lados — a + ingestão (que grava `is_blocked=true`) e o runtime do agente. **Não é palavra solta:** bloqueia + palavra ISOLADA (a mensagem inteira é a palavra) ou verbo de cessação com OBJETO DE COMUNICAÇÃO + ("parar de me mandar", "sair da lista"). Enquanto eram duas regras, a ingestão bloqueava paciente + que perguntou "tem como parar a dor?". Vocabulário em vigor: + `grep -n 'PALAVRAS_DE_OPT_OUT' -A20 lib/opt-out/deteccao.ts`; frases de controle em + `tests/unit/opt-out-deteccao.test.ts`. **Espanhol ainda não é coberto** (`baja`, `salir`, `no quiero recibir`) +- Mídia: sobe para o Supabase Storage primeiro e passa a URL ao WAHA (nunca base64 inline) +- Multi-device: assine `message.any` (não só `message`); trate `fromMe=true` sem duplicar +- Grupos: pule o binding com o CRM se `chatId.endsWith('@g.us')`. O remetente é `p.author`, não `p.from` +- O cron `app/api/v1/cron/recover-stuck-messages/route.ts` marca `status='sending'` há >5min como + `failed` **e abre aviso na Central** (`agent_inbox_items`, kind `message_send_stuck`). Não toca em + `queued` — esse estado tem dono (o agent-engine reagenda por `SEND_QUEUED_RETRY_MS`) e falhá-lo + perderia mensagem que ia sair. Não reenvia: envio em dobro é pior que não-envio ### Marca própria (white-label) -- **Uma imagem Docker serve todas as marcas.** Nada de `NEXT_PUBLIC_*` para marca, nada de `public/favicon.ico`, nada de imagem por revendedor — a imagem é pré-buildada e o `update.sh` regrava `APP_IMAGE` incondicionalmente -- **O banco está ACIMA do `.env`.** `platform_branding` (instalação) e `organizations.settings.branding` (organização) são a fonte; `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX` são **semente e piso de rollback** (o `agent.sh` reverte a imagem, nunca o banco) -- **Resolvedor NUNCA lança.** `lib/branding/instalacao.ts` e `lib/branding/saida.ts` degradam para o padrão do produto e seguem: `branding()` roda em `app/layout.tsx`, e um throw ali é 500 em todas as telas -- **Saída sem DOM usa `marcaDaSaida()`** (`lib/branding/saida.ts`) — e-mail, remetente, ícone, `issuer` do MFA. Um hex e uma frente legível, tema **claro** sempre. Nunca passe `MarcaResolvida` a template de e-mail -- **O PDF de LGPD NUNCA leva marca.** Ele nomeia o **controlador** (`organizations.legal_name`) e o DPO resolvido. Nomear ali o revendedor — que é operador — inverteria papéis num documento que responde a direito legal. Vigiado em `tests/unit/mapas-de-arquitetura.test.ts` -- Vazamento de marca no código é vigiado por `tests/unit/branding.test.ts` (varre `app|components|lib|workers|hooks`), com allowlist que **só encolhe**. Contexto de venda em [`docs/white-label.md`](docs/white-label.md); mapa em `docs/architecture/marca-propria.architecture.json` + +O produto é revendido, e o nome não é seu. + +- **Nunca escreva "Deskcomm"/"DeskcommCRM" em código que alcança o usuário.** + `tests/unit/branding.test.ts` varre `app|components|lib|workers|hooks` e reprova; a allowlist **só encolhe** +- **Uma imagem Docker serve todas as marcas.** Nada de `NEXT_PUBLIC_*` para marca, nada de + `public/favicon.ico`, nada de imagem por revendedor +- **O banco está ACIMA do `.env`:** `platform_branding` (instalação) e + `organizations.settings.branding` (organização) são a fonte; `APP_NAME` / `APP_LOGO_URL` / + `APP_ACCENT_HEX` são **semente e piso de rollback** (o `agent.sh` reverte a imagem, nunca o banco) +- **O resolvedor NUNCA lança:** `lib/branding/instalacao.ts` e `lib/branding/saida.ts` degradam para + o padrão do produto e seguem. `branding()` roda em `app/layout.tsx`, e um throw ali é 500 em todas as telas +- **Saída sem DOM usa `marcaDaSaida()`** (`lib/branding/saida.ts`) — e-mail, remetente, ícone, + `issuer` do MFA: um hex e uma frente legível, tema claro sempre. Nunca passe `MarcaResolvida` a + template de e-mail +- **O PDF de LGPD NUNCA leva marca.** Ele nomeia o **controlador** (`organizations.legal_name`) e o + DPO resolvido. Nomear ali o revendedor — que é operador — inverteria papéis num documento que + responde a direito legal. Vigiado em `tests/unit/mapas-de-arquitetura.test.ts` +- Contexto de venda em [`docs/white-label.md`](docs/white-label.md); mapa em `docs/architecture/marca-propria.architecture.json` ### Doutrina DIRC (antes de adicionar campo) -- **D**uplicar — vive aqui mesmo? -- **I**ntegrar — vem de outra tabela via FK? -- **R**eferenciar — só ponteiro? -- **C**alcular — pode ser computado on-demand? + +**D**uplicar — vive aqui mesmo? · **I**ntegrar — vem de outra tabela via FK? · +**R**eferenciar — só ponteiro? · **C**alcular — dá para computar on-demand? ### Modelagem -- 5 tabelas core CRM: `crm_pipelines`, `crm_stages`, `crm_leads`, `crm_lead_activities` (polimórfica timeline), `crm_lead_links` (polimórficos vínculos) + +- 5 tabelas core de CRM: `crm_pipelines`, `crm_stages`, `crm_leads`, `crm_lead_activities` + (timeline polimórfica), `crm_lead_links` (vínculos polimórficos) - `position_in_stage numeric` (fractional indexing via `midpoint()`) — **NUNCA `int`** -- `external_id` nullable (mensagem outbound `sending` ainda não tem ID WAHA) -- `type` é `text` + `check constraint`, **não enum** (enum é difícil de estender) - - **Exceção deliberada — colunas de vocabulário ABERTO:** onde um clone pode ter linhas com valor - legado (ex.: `crm_lead_activities.type`), o CHECK **não** entra: a constraint faria o `update.sh` - do clone quebrar, e a doutrina de migrations proíbe. Nesses casos o vocabulário vive só no +- `external_id` nullable (mensagem outbound em `sending` ainda não tem ID do WAHA) +- `type` é `text` + check constraint, **não enum** (enum é difícil de estender) + - **Exceção deliberada — coluna de vocabulário ABERTO:** onde um clone pode ter linha com valor + legado (ex.: `crm_lead_activities.type`), o CHECK **não** entra: a constraint quebraria o + `update.sh` do clone, e a doutrina de migrations proíbe. Nesses casos o vocabulário vive só no TypeScript, o emissor usa **constante compartilhada, nunca string literal**, e a coluna fica **fora** do invariante `tests/invariants/vocabulario-banco-x-typescript.test.ts` — que cobre - apenas colunas que JÁ têm CHECK. Ver o cabeçalho desse arquivo antes de "completar" o schema. -- `tags text[]` + GIN index; promove pra coluna gerada apenas quando vira hot path + apenas colunas que JÁ têm CHECK. Leia o cabeçalho desse arquivo antes de "completar" o schema +- `tags text[]` + índice GIN; promove para coluna gerada só quando virar hot path - `custom_fields jsonb` com schema declarativo em `pipeline.settings.fields`; Zod construído dinamicamente -- `vocabulary jsonb` em pipeline permite renomear lead/deal/won/lost (e-commerce: lead=Cliente, deal=Pedido, won=Pago, lost=Cancelado) +- `vocabulary jsonb` no pipeline permite renomear lead/deal/won/lost (e-commerce: lead=Cliente, + deal=Pedido, won=Pago, lost=Cancelado) --- ## Anti-patterns proibidos -1. String que deveria ser FK (ex: `owner_email text` em vez de `owner_user_id uuid`) +1. String que deveria ser FK (`owner_email text` em vez de `owner_user_id uuid`) 2. Duplicação sem source of truth declarado 3. Evento sem consumer (emite e ninguém escuta) 4. FK ausente que vira inferência por nome 5. Campo sincronizado por cron quando devia ser realtime/trigger -6. `jsonb` lock-in (UI lê path direto sem schema central) -7. Cascade fantasma (deletar contact cascade em messages perde histórico) -8. Polimórfico sem padronização (`target_kind` cada lugar grava diferente) -9. **Trigger Postgres faz HTTP** (letal — espera rede dentro da transação) -10. Service role usado em request handler sem filtrar `organization_id` manualmente +6. `jsonb` lock-in (UI lê path direto, sem schema central) +7. Cascade fantasma (deletar contact em cascade nas messages perde histórico) +8. Polimórfico sem padronização (`target_kind` gravado diferente em cada lugar) +9. **Trigger Postgres fazendo HTTP** (letal — espera rede dentro da transação) +10. Service role em request handler sem filtrar `organization_id` manualmente 11. `getSession()` no backend 12. API key em query string -13. Bearer plaintext armazenado no DB (deve ser hash SHA256) -14. `console.log` deixado em código merged (use logger estruturado ou Sentry breadcrumb) +13. Bearer plaintext armazenado no banco (deve ser hash SHA256) +14. `console.log` em código merged (use `lib/logger.ts` ou breadcrumb do Sentry) --- ## Paths importantes -| Path | Conteúdo | -|---|---| -| `docs/prd/00-prd-master.md` | Visão geral, escopo MVP, KPIs | -| `docs/prd/01-prd-platform-base.md` | Auth, tenancy, RBAC, LGPD framework | -| `docs/prd/02-...06-` | Customer 360, WhatsApp, Pipeline, IA-RAG, Nuvemshop | -| `docs/specs/` | Specs técnicas detalhadas (schema SQL, payloads exatos) | -| `docs/business-rules/` | Regras de negócio fora do código | -| `docs/research/reference-synthesis.md` | Arquitetura herdada do curso WAHA | -| `tasks/todo.md` | Workflow de construção atual | -| `lib/api/wrappers.ts` | `ok()`, `fail()`, tipos `ApiSuccess` / `ApiError` | -| `lib/api/errors.ts` | Códigos de erro canônicos | -| `lib/env.ts` | Validação Zod das env vars (lança no startup se faltar crítica) | -| `lib/supabase/{browser,server,admin}.ts` | Clients canônicos | -| `app/api/v1/health/route.ts` | Health check (Supabase + Redis + WAHA) | -| `supabase/migrations/` | Schema versionado | -| `docs/runbooks/deploy.md` | **Deploy em produção — leia ANTES de mexer na VPS** | +| Path | Conteúdo | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `app/api/v1/` | Route handlers REST (versionado por path) | +| `app/api/internal/`, `app/api/mcp/`, `app/api/v1/cron/` | Superfícies não-cookie (secret / bearer próprio) | +| `app/app/`, `app/admin/` | UI autenticada do tenant · UI de plataforma | +| `app/actions/` | Server Actions (auth, onboarding, team, settings) | +| `lib/agent-engine/`, `lib/ai/` | Runtime do agente, guardrails, RAG, dispatcher | +| `lib/api/wrappers.ts` | `ok()` / `fail()` e os tipos `ApiSuccess` / `ApiError` | +| `lib/api/errors.ts` | Códigos de erro canônicos | +| `lib/auth/require-role.ts` | `requireRole()` — guard canônico de RBAC | +| `lib/env.ts` | Validação Zod das env vars (lança no startup se faltar crítica) | +| `lib/supabase/browser.ts`, `lib/supabase/server.ts`, `lib/supabase/admin.ts` | Clients canônicos | +| `lib/navigation/registry.ts` | Registro de telas — é o que dá porta a uma tela nova | +| `workers/` | Workers de `event_log` + crons | +| `proxy.ts` | Middleware do Next 16 (auth de borda, `X-Request-Id`) | +| `supabase/migrations/` | Schema versionado · `supabase/baseline.sql` = o que o self-host aplica | +| `docs/prd/00-prd-master.md` | Visão geral, escopo do MVP, KPIs | +| `docs/specs/` | Specs técnicas (schema SQL, payloads exatos) | +| `docs/business-rules/` | Regras de negócio fora do código | +| `docs/runbooks/deploy.md` | **Deploy em produção — leia ANTES de mexer na VPS** | + +### Arquivos e diretórios SENSÍVEIS + +- **`supabase/baseline.sql`** — é o que o `install.sh`/`update.sh` aplicam. Mudança de schema que + não aparece aqui **não chega a quem instalou** +- **`supabase/migrations/`, arquivos já aplicados** — nunca edite; corrija com migration nova +- **`lib/supabase/admin.ts`** — service role bypassa RLS; o filtro de `organization_id` é sua responsabilidade +- **`lib/auth/public-paths.ts`** — adicionar path aqui remove a checagem de auth de borda. Só com + guard próprio dentro da rota +- **`.env*`** — não abra, não copie valor, não logue. Só `.env.example` é template +- **`docker-compose.traefik.yml`** — o único lugar que dá ao contêiner `app` as labels de roteamento (ver Deploy) + +### Arquivos GERADOS — não editar à mão + +`lib/database.types.ts` (gerado do schema Supabase) · `graphify-out/` · `pnpm-lock.yaml` · +`tsconfig.tsbuildinfo` · `next-env.d.ts` · `.next/` --- -## Deploy em produção (NÃO NEGOCIÁVEL) - -**Numa VPS que já tem proxy reverso próprio (Hostinger, Coolify, Dokploy…), todo -`up -d` leva os DOIS arquivos de compose:** +## Testes e gates ```bash -docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml --env-file .env up -d app +pnpm typecheck # tsc --noEmit (estrito) +pnpm lint # eslint +pnpm lint:channels # invariante 1 de docs/doctrine/restricao-de-canal.md: nenhuma feature nomeia um provider +pnpm test:unit # Vitest — EXCLUI tests/invariants/** e tests/e2e/** +pnpm test:db # Postgres efêmero + baseline em install E update + invariantes (PRECISA de Docker) +pnpm test:e2e # Playwright (PRECISA de app rodando + banco semeado) +pnpm test:shell # kit self-host (update.sh, entrypoint do scheduler, validadores do install.sh) +pnpm gov:verify # atalho local = typecheck + lint + lint:channels + test:unit ``` -Omitir `-f docker-compose.traefik.yml` recria o contêiner sem as labels de -roteamento; o Traefik da hospedagem deixa de enxergá-lo e **o domínio inteiro -responde `404 page not found`** — com o contêiner `healthy`, porque o -healthcheck é um probe TCP interno e não sabe nada de roteamento. +**Os invariantes não estão no `test:unit`.** `vitest.config.ts` exclui `tests/invariants/**` de +propósito: a suíte precisa de um Postgres real e roda via `vitest.db.config.ts`, orquestrada por +`scripts/test-db.sh`. Rodar só `pnpm test:unit` e concluir "está tudo verde" é falso verde — o +isolamento de RLS não foi exercitado. -Depois de qualquer deploy, confirme que o domínio responde **307** (redireciona -pro login) e não 404. Verificações e o caso de build local em -`docs/runbooks/deploy.md`. +⚠️ **`pnpm gov:verify` não cobre tudo:** omite `test:db`, `test:e2e` e `test:shell`. Se sua mudança +toca schema, RLS, UI ou o kit, verde ali **não é prova**. Ver [`docs/harness-audit.md`](docs/harness-audit.md). -O caminho normal **não constrói nada na VPS**: commit → push → PR → merge na -`main` → o CI publica no GHCR → a VPS puxa. Imagem construída na VPS é exceção -de emergência e é dívida: existe só naquele disco e qualquer `up -d` sem -`APP_PULL_POLICY=never` a substitui em silêncio. +### O que o CI cobre -Essa frase já foi meia-verdade: valia para o `app` e era falsa para o produto, -porque o serviço `worker` não tinha `image:` — era construído na VPS de todo -cliente e nunca reconstruído por nenhum `update.sh`. Hoje os três serviços -nossos (`app`, `worker`, `scheduler`) são imagens publicadas, e um teste -reprova o retorno do padrão. Ver a doutrina abaixo. +| Check | Workflow | O que roda | +| ---------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `verify` | `.github/workflows/ci.yml` | `typecheck` + `lint` + `lint:channels` + `test:unit` + `test:shell` | +| `invariants` | `.github/workflows/ci.yml` | `pnpm test:db` — `pgvector/pgvector:pg17`, `baseline.sql` em install **e** update, isolamento RLS + governança | +| `build-and-size` | `.github/workflows/perf.yml` | `pnpm build` em Node 22 | +| `e2e` | `.github/workflows/e2e.yml` | Playwright contra Supabase local com o `baseline.sql` aplicado | +| `imagens-ok` | `.github/workflows/publish-image.yml` | As três imagens Docker constroem | ---- - -## Packaging e distribuição — DOUTRINA (NÃO NEGOCIÁVEL) - -Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md); -decisões estruturais e o que foi recusado em -[`docs/adr/0001-packaging-e-distribuicao.md`](docs/adr/0001-packaging-e-distribuicao.md). -O não-negociável, em quatro linhas: - -1. **Nenhum serviço de `docker-compose.prod.yml` constrói na máquina do - cliente.** Todo serviço declara `image:` de uma imagem publicada; `build:` - só existe **ao lado**, como escape. Serviço `build:`-only é invisível para - `docker compose pull` e imune a `up -d` sem `--build` — ele não é só caro de - instalar, ele **nunca é atualizado**. -2. **Publicação é ato do CI.** Nunca da sua máquina: build ARM local não roda - na VPS amd64 do cliente, e a falha só aparece no `up -d` dele. O job - `imagens-ok` reprova quando qualquer uma das três imagens não constrói, e - **é status check obrigatório desde 2026-08-13** — a branch protection tem - `verify, build-and-size, invariants, e2e, imagens-ok`. (Este parágrafo dizia - "ainda não é obrigatório" até 2026-08-14; a ativação era o passo final do - merge da doutrina e aconteceu.) Confira na fonte antes de confiar nesta linha. -3. **Instalação de cliente aponta para número de versão, nunca para tag móvel.** - `latest` aqui significa **topo da `main`**, não última release — quem quer a - última release usa `stable`. `pull_policy` acompanha a mutabilidade da tag: - imutável → `missing`, móvel → `always`. -4. **Dependência upstream é referenciada com tag fixa, nunca republicada.** - Vale para WAHA (licenciado — republicar é passivo jurídico), Redis, Caddy e - `serverless-redis-http`. - -Bump de versão **não pode** exigir que o operador da VPS edite `.env`, compose -ou qualquer arquivo à mão. Se exigir, não entra: vira issue com plano de -migração e vai para uma major. - ---- - -## Como rodar local +**Os cinco são checks obrigatórios na branch protection da `main`.** Confira na fonte em vez de +confiar nesta tabela: ```bash -nvm use # node 22 -npm install -cp .env.example .env.local # preencher -docker compose up -d # WAHA local -npm run dev # http://localhost:3000 +gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' ``` -Ver `README.md` pra detalhes de setup. - ---- - -## Testes +**O `e2e` roda todas as specs de `tests/e2e/`, menos as declaradas em `FORA_DO_CI`** — hoje só +`vps-fresh-onboarding`, que precisa de WAHA + Redis + Resend + Nuvemshop. Ou seja: **`e2e` verde não +prova a jornada de instalação fresca**, que é a P0 da doutrina de QA Visual e o produto que se +vende. Quem está dentro e quem está fora é enumerável, e +`tests/unit/e2e-cobertura-completa.test.ts` reprova spec que sumiu de todas as listas: ```bash -pnpm typecheck # tsc --noEmit (estrito) -pnpm lint # eslint next/core-web-vitals -pnpm test:unit # Vitest (NÃO inclui tests/invariants/** — ver abaixo) -pnpm test:db # Postgres efêmero + baseline install/update + 364 invariantes -pnpm test:e2e # Playwright (requer dev server) +ls tests/e2e/*.spec.ts | wc -l # specs no disco +grep -A20 'FORA_DO_CI: >-' .github/workflows/e2e.yml # o que o CI declara não cobrir ``` -**Os invariantes não estão no `test:unit`.** `vitest.config.ts` exclui `tests/invariants/**` de propósito: essa suíte precisa de um Postgres real e roda via `vitest.db.config.ts`, orquestrada por `scripts/test-db.sh`. Rodar só `pnpm test:unit` e concluir "está tudo verde" é um falso verde — o isolamento RLS não foi exercitado. +Ao mexer em schema, RLS, RBAC, atribuição, escopo, roteamento, follow-up, webhooks ou automações: +rode `pnpm test:db` **localmente** antes de abrir PR. É o único caminho que exercita o +`baseline.sql` que o self-hoster realmente aplica. -Checks **obrigatórios** na branch protection da `main` (verificado na configuração, não só no papel): - -- **`verify`** (`ci.yml`) — typecheck + lint + test:unit. -- **`invariants`** (`ci.yml`) — `pnpm test:db`: sobe `pgvector/pgvector:pg17`, aplica `supabase/baseline.sql` em modo install (`ON_ERROR_STOP=1`) e update (idempotência), e roda os testes de invariante, incluindo o de isolamento RLS entre 2 organizações. -- **`build-and-size`** (`perf.yml`) — `pnpm build` em Node 22. -- **`e2e`** (`e2e.yml`) — sobe Supabase local, aplica o `baseline.sql` e roda **48 das 49 specs** Playwright (medido em 2026-08-14 @ `587a494d`; **reconte antes de citar** — este número já apodreceu **quatro** vezes). A **única** de fora é `vps-fresh-onboarding` (precisa de WAHA + Redis + Resend + Nuvemshop) — e ela é a **P0** da doutrina de QA Visual, ou seja, `e2e` verde **não** prova a jornada de instalação fresca, que é o produto que se vende. +--- - **A receita antiga de recontagem estava errada** e é provavelmente uma das causas do apodrecimento. `grep -oE '[a-z0-9-]+\.spec\.ts' .github/workflows/e2e.yml | sort -u | wc -l` devolve **49**, não 48 — mas *não* pelo motivo que este parágrafo afirmava até 2026-08-14. Ele dizia "conta menções em COMENTÁRIOS do workflow", e isso é falso: medido, o conjunto de specs citadas fora de variável é **vazio**. O excedente é a `FORA_DO_CI`, que é uma **variável YAML** como as outras — o grep não distingue a variável que o CI *invoca* da que ele só *declara*. Medir o arquivo inteiro mede quem é citado, não quem é invocado. O que roda são as `SPECS_PARTE_*`: +## QA Visual com recursos reais — DOUTRINA + +**O DeskcommCRM é distribuído open-source: a experiência de quem instala numa VPS É o produto.** +Toda feature nova (ou fix de comportamento visível) DEVE ser provada como um usuário leigo a usaria +de verdade — pelo frontend, num ambiente que imita a instalação fresca — antes de "pronto". + +O que conta como recurso real: + +- **Prova pela tela**, dirigindo o browser (Playwright), com conta de teste real. `curl` e chamada + de API **não** provam UX — validam o backend. Use curl só como diagnóstico +- **Banco fresco estilo VPS:** Postgres limpo com `supabase/baseline.sql` (não a cadeia de + migrations) + `scripts/bootstrap-owner.ts` — o que o `install.sh` faz +- **Dependências como na VPS:** WAHA local, Redis local (`redis` + `serverless-redis-http`), cron + drenado por endpoint. E **teste com os envs opcionais AUSENTES** (ex.: sem `RESEND_API_KEY`): é o + estado real de um primeiro deploy, e é onde moram os piores bugs de primeira impressão +- **Efeito colateral externo provado com receiver real** (webhook outbound, envio). Mock não + estressa o egress real — anti-SSRF, projeção de payload, https em prod +- **Medida de front-end por ferramenta, nunca a olho:** `getBoundingClientRect` / `getComputedStyle` + no Playwright + +**Prioridade: primeira impressão acima de tudo.** Onboarding e as primeiras ações (criar conta, +conectar canal, primeiro lead, primeiro convite) são a primeira impressão — bug ali é abandono. + +Registro obrigatório, senão o progresso é invisível: mapa de jornadas em +[`docs/testing/user-journey-map.md`](docs/testing/user-journey-map.md) (casos, prioridade `[P0]`, +achados), spec em `tests/e2e/` que dirige o **frontend**, evidência visual em +`.superpowers/evidence/`. Bug achado executando → conserta na causa raiz, com migration versionada +se tocar schema, commit próprio e re-teste verde como prova. + +**Receita de ambiente fresco (não-óbvia):** banco = `baseline.sql` num Supabase local **pg17** +(`config.toml major_version = 17`; o baseline usa `GRANT MAINTAIN`, privilégio pg17+); +`next build` + `next start` (produção — `next dev` compila lento demais e o Turbopack quebra +`cookies()`); **worktree com `node_modules` real, nunca symlink** (o Turbopack rejeita symlink "out +of filesystem root") e **fora de `/tmp`** (é limpo no meio da sessão — commite cada marco). - ```bash - ls tests/e2e/*.spec.ts | wc -l # 49 em disco - python3 - <<'PY' # 48 que o CI invoca - import re - y = open(".github/workflows/e2e.yml", encoding="utf-8").read() - print(len({s for _, c in re.findall(r'(SPECS_PARTE_\d+):\s*>-\n((?:[ ]{8,}.*\n)+)', y) - for s in re.findall(r'[a-z0-9-]+\.spec\.ts', c)})) - PY - ``` - - **E a recontagem já não é o conserto.** A quarta vez era a condição que o PR #242 pôs para parar de recontar — ela aconteceu. O conserto devido é `tests/unit/e2e-cobertura-completa.test.ts` passar a cobrar também o texto daqui, como já cobra as três listas do workflow (medido em 2026-08-14: ele não cobra — `grep -c 'CLAUDE.md' tests/unit/e2e-cobertura-completa.test.ts` devolve 0). Prosa que nenhum gate lê é prosa que diverge — e uma triagem que a use como régua mede contra o número errado, que é o modo de falha nº 1 do procedimento. -- **`imagens-ok`** (`publish-image.yml`) — reprova quando qualquer uma das três imagens Docker não constrói. **É obrigatório desde 2026-08-13**; este arquivo dizia o contrário em outro parágrafo (ver a doutrina de packaging acima, já corrigida). +--- -Todos os **cinco** são **obrigatórios** — medido em 2026-08-14 na branch protection: +## Migrations & banco — DOUTRINA + +**Este projeto é open-source: toda mudança de schema DEVE sair como migration versionada.** Quem +clonou uma versão antiga precisa conseguir atualizar aplicando as migrations em ordem. **Nunca** +aplique `ALTER`/`CREATE` solto no banco sem o arquivo correspondente. + +1. **Arquivo versionado** em `supabase/migrations/`, no padrão `__.sql`. + `NNNN` é o próximo sequencial (`ls supabase/migrations/ | tail -3`) +2. **Idempotente sempre que possível:** `add column if not exists`, `create ... if not exists`, + `create or replace function`. Reaplicar não pode quebrar nem duplicar efeito +3. **Portável em `psql` puro** (clones podem não usar o CLI/MCP do Supabase): sem + `create temporary table ... on commit drop` fora de transação explícita; sem `BEGIN`/`COMMIT` + explícito (o runner já envolve em transação). Prefira CTEs, subqueries de janela e colunas-mapa +4. **Data migration genérica:** se corrige ou deduplica dados, escreva pensando em QUALQUER banco de + clone (não hardcode IDs do seu tenant). Repointe FKs conferindo o catálogo (`information_schema`) + para não perder histórico +5. **Registre no MANIFEST:** uma linha em `supabase/migrations/MANIFEST.md` (tabela "Applied") com + versão, nome e o QUÊ/PORQUÊ +6. **Reflita no `supabase/baseline.sql` — OBRIGATÓRIO.** O baseline é um dump `--schema-only` mais + um **apêndice idempotente** no fim do arquivo (blocos rotulados + `-- ---- (migration NNNN) ----`). O kit aplica **só o baseline**: no `install.sh` (banco + novo, `ON_ERROR_STOP=1`) e no `update.sh` (re-aplica em banco existente, **sem** a flag). Toda + mudança pós-snapshot entra no apêndice, idempotente e auto-curativa. Migration que só existe em + `supabase/migrations/` **não chega ao self-hoster** +7. **Aplique e prove:** capture o estado ANTES/DEPOIS e prove invariantes (ex.: contagem que não + pode mudar). Se mexeu em contrato, regenere `lib/database.types.ts`. Valide o baseline num + Postgres descartável (`pgvector/pgvector:pg17`) em modo install **e** update — ambos têm que passar +8. **Backfill antes da constraint:** constraint nova falha se os dados atuais a violam. Deduplique ou + corrija ANTES de criar — na migration **e** no apêndice do baseline +9. **Função nova em `public` nasce EXPOSTA — revogue as DUAS origens:** -```console -$ gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' -verify, build-and-size, invariants, e2e, imagens-ok -``` + ```sql + revoke execute on function public.fn_x(...) from public, anon; + grant execute on function public.fn_x(...) to ; + ``` -Duas correções que este bloco já pagou: o `e2e` entrou para a lista depois de o arquivo ser escrito, e -a versão anterior dizia que ele "ainda não é obrigatório"; depois o `imagens-ok` entrou e o arquivo -seguiu dizendo "quatro". Uma triagem que leia qualquer uma dessas versões mede contra a régua errada — -que é o modo de falha nº 1 do procedimento de triagem. **Reconfira na fonte antes de confiar em -qualquer lista aqui**, com o comando acima. + São origens distintas de `EXECUTE`, e tratar só uma deixa a função exposta com o gate verde: + **(A)** o grant direto a `anon` do `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` + do baseline, que vale para toda função criada depois dele — isto é, para todo apêndice novo — e + que `revoke from public` **não** remove; **(B)** o grant a `PUBLIC` que o Postgres dá a qualquer + função ao criá-la, que `revoke from anon` **não** remove. Sem os dois, o PostgREST expõe a função + como RPC alcançável pela anon key, que vai para o browser. Vigiado por + `tests/invariants/hardening-definer-varredura.test.ts` -Ao mexer em schema, RLS, RBAC, atribuição, escopo, roteamento, follow-up, webhooks ou automações: rode `pnpm test:db` **localmente** antes de abrir PR. É o único caminho que exercita o `baseline.sql` que o self-hoster realmente aplica. +**Resumo:** arquivo em `supabase/migrations/` **+** apêndice idempotente no `supabase/baseline.sql` +**+** linha no MANIFEST. Os três andam juntos. Nunca edite migration já aplicada — corrija com +forward-fix. --- -## QA Visual com Recursos Reais — DOUTRINA (produto self-host) +## Deploy em produção — NÃO NEGOCIÁVEL -**O DeskcommCRM é distribuído open-source: a experiência de quem instala numa VPS É o produto.** Toda feature nova (ou fix de comportamento visível) DEVE ser provada como um **usuário leigo a usaria de verdade** — pelo frontend, num ambiente que imita a instalação fresca — antes de "pronto". Não é opcional; é critério de aceite de toda sessão que toca UI ou fluxo de usuário. +**Numa VPS que já tem proxy reverso próprio (Hostinger, Coolify, Dokploy…), todo `up -d` leva os +DOIS arquivos de compose:** -**O que "recurso real" significa (e o que NÃO conta):** -- **Conta.** Prova pela tela, dirigindo o browser (Playwright), logando com conta de teste real. `curl`/chamada de API **não** provam UX — validam o backend, mas não o que o usuário vê, clica e entende. Use curl só como diagnóstico. -- **Banco fresco estilo VPS.** Postgres limpo aplicado do `supabase/baseline.sql` (não das `migrations/` — a cadeia fresh não sobe) + `scripts/bootstrap-owner.ts` (o que o `install.sh` faz). O ambiente do teste = o que o clone recém-instalado tem: sem os seus dados, sem os seus envs opcionais. -- **Dependências como na VPS.** WAHA local, Redis local (`redis` + `serverless-redis-http`), cron drain via endpoint. E **teste com os envs opcionais AUSENTES** (ex.: sem `RESEND_API_KEY`) — é o estado real de um primeiro deploy, e é onde moram os piores bugs de primeira impressão. -- **Efeito colateral externo provado com receiver real.** Webhook outbound, envio — suba um receiver HTTP de verdade e prove o que chegou (ou que foi barrado). Mock não estressa o egress real (anti-SSRF, projeção de payload, https em prod). - -**Prioridade: primeira impressão acima de tudo.** Onboarding e as primeiras ações (criar conta, conectar canal, primeiro lead, primeiro convite) são a primeira impressão do usuário — bug ali é abandono. Teste esses caminhos primeiro e com o maior rigor. +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml --env-file .env up -d app +``` -**Registro obrigatório (senão o progresso é invisível):** -- Mapa de jornadas vivo em `docs/testing/user-journey-map.md` — casos por jornada, prioridade (`[P0]` primeira impressão), e achados. Atualize quando adicionar cobertura ou achar bug. -- Specs em `tests/e2e/*.spec.ts` que dirigem o **frontend** (não só API). Evidência visual (screenshot/trace) em `.superpowers/evidence/`. -- Bug achado executando → **conserta na causa raiz**, com migration versionada se tocar schema (ver doutrina abaixo), commit próprio, e re-teste verde como prova. +Omitir `-f docker-compose.traefik.yml` recria o contêiner sem as labels de roteamento; o Traefik da +hospedagem deixa de enxergá-lo e **o domínio inteiro responde `404 page not found`** — com o +contêiner `healthy`, porque o healthcheck é um probe TCP interno e não sabe nada de roteamento. -**Medidas de front-end por ferramenta, nunca a olho** (`getBoundingClientRect`/`getComputedStyle` no Playwright). Ver `feedback_protocolo_execucao_visivel` na memória. +Depois de qualquer deploy, confirme que o domínio responde **307** (redireciona para o login) e não 404. Verificações e o caso de build local em [`docs/runbooks/deploy.md`](docs/runbooks/deploy.md). -**Receita de ambiente fresco (não-óbvia):** banco = `baseline.sql` num Supabase local **pg17** (`config.toml major_version = 17`; o baseline usa `GRANT MAINTAIN`, privilégio pg17+); `next build` + `next start` (produção — `next dev` compila lento demais e o Turbopack quebra `cookies()`); **worktree com `node_modules` real, nunca symlink** (Turbopack rejeita symlink "out of filesystem root") e **fora de `/tmp`** (é limpo no meio da sessão — commite cada marco). Detalhes em [[project_invite_e2e_and_bugs]]. +O caminho normal **não constrói nada na VPS**: commit → push → PR → merge na `main` → o CI publica +no GHCR → a VPS puxa. Imagem construída na VPS é exceção de emergência e é dívida: existe só naquele +disco, e qualquer `up -d` sem `APP_PULL_POLICY=never` a substitui em silêncio. --- -## Higiene de branches — DOUTRINA (NÃO NEGOCIÁVEL) +## Packaging e distribuição — DOUTRINA -**`main` é produção e é a fonte da verdade. Toda branch começa e se mantém atualizada com a `main`.** Trabalho iniciado numa branch atrasada gera conflito e retrabalho — é a causa número um de "cagada" em ambiente multi-sessão. Regra: +Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md); decisões estruturais e o +que foi recusado em [`docs/adr/0001-packaging-e-distribuicao.md`](docs/adr/0001-packaging-e-distribuicao.md). +O não-negociável, em quatro linhas: -1. **ANTES de começar QUALQUER trabalho numa branch, atualize-a com a `main`:** `git fetch origin && git merge origin/main` (traz produção pra dentro). Se a branch ainda não tem commits próprios, é fast-forward puro (`git merge --ff-only origin/main`). Não codar antes disso. -2. **NUNCA `reset --hard`/force pra "atualizar"** — apaga trabalho. Só dois caminhos: **fast-forward** (branch sem commits próprios) ou **merge da `main` pra dentro** (preserva os dois lados). `main` nunca é reescrita. -3. **NUNCA toque numa branch/worktree com working tree sujo que não é seu.** Antes de atualizar qualquer branch, cheque `git status` e `git worktree list` — se está suja e é de outra sessão, **deixe quieto** e avise. Merge só entra em árvore limpa. -4. **Quando uma feature entra na `main`, todas as outras branches ficam atrasadas na hora.** Quem for retomar qualquer uma delas aplica a regra 1 primeiro. Ao fim de uma feature, considere propagar a `main` para as branches vivas limpas (FF as sem trabalho próprio; merge nas divergentes limpas; pular as sujas/conflitantes e reportar). -5. **Conflito ao atualizar = pare e resolva com cabeça** (ou escale), nunca escolha um lado no automático numa branch que não é sua. Preservar trabalho > branch "verde rápido". +1. **Nenhum serviço de `docker-compose.prod.yml` constrói na máquina do cliente.** Todo serviço + declara `image:` de uma imagem publicada; `build:` só existe **ao lado**, como escape. Serviço + `build:`-only é invisível para `docker compose pull` e imune a `up -d` sem `--build` — ele não é + só caro de instalar, ele **nunca é atualizado**. Os três serviços nossos (`app`, `worker`, + `scheduler`) são imagens publicadas, e um teste reprova o retorno do padrão +2. **Publicação é ato do CI**, nunca da sua máquina: build ARM local não roda na VPS amd64 do + cliente, e a falha só aparece no `up -d` dele +3. **Instalação de cliente aponta para número de versão, nunca para tag móvel.** Aqui `latest` + significa **topo da `main`**, não última release — quem quer a última release usa `stable`. + `pull_policy` acompanha a mutabilidade da tag: imutável → `missing`, móvel → `always` +4. **Dependência upstream é referenciada com tag fixa, nunca republicada** — WAHA (licenciado; + republicar é passivo jurídico), Redis, Caddy e `serverless-redis-http` + +Bump de versão **não pode** exigir que o operador da VPS edite `.env`, compose ou qualquer arquivo à +mão. Se exigir, não entra: vira issue com plano de migração e vai para uma major. --- -## Migrations & Banco — DOUTRINA (projeto open-source) - -**Este projeto é open-source. Toda mudança de schema DEVE sair como migration versionada** — quem clonou uma versão antiga do banco precisa conseguir atualizar aplicando as migrations em ordem. **Nunca** aplique `ALTER`/`CREATE` solto no banco sem o arquivo correspondente. Isto é critério de aceite de TODA sessão, não opcional. +## Higiene de branches — DOUTRINA -Processo padrão (siga sempre): +**`main` é produção e é a fonte da verdade. Toda branch começa e se mantém atualizada com ela.** +Trabalho iniciado numa branch atrasada gera conflito e retrabalho — é a causa número um de estrago +em ambiente multi-sessão. -1. **Arquivo versionado** em `supabase/migrations/` com o padrão do repo: `__.sql` (ex.: `20260706210000_0027_whatsapp_conversation_unification.sql`). `NNNN` é o próximo número sequencial (veja o último em `ls supabase/migrations/`). -2. **Idempotente sempre que possível**: `add column if not exists`, `create ... if not exists`, `create or replace function`. Uma migration deve poder ser re-aplicada sem quebrar nem duplicar efeito. -3. **Portável em `psql` puro** (clones podem não usar o MCP/CLI Supabase): **sem** `create temporary table ... on commit drop` fora de transação explícita; **sem** `BEGIN`/`COMMIT` explícito (o runner já envolve em transação, como as demais migrations). Prefira CTEs, subqueries de janela e colunas-mapa (ex.: `is_merged_into`) a temp tables. -4. **Data migrations genéricas**: se a migration corrige/deduplica dados, escreva pensando em QUALQUER banco de clone (não hardcode IDs do seu tenant). Repointe FKs conferindo o catálogo (`information_schema` FK map) para não perder histórico. -5. **Registre no MANIFEST**: adicione uma linha em `supabase/migrations/MANIFEST.md` (tabela "Applied") descrevendo versão, nome e o QUÊ/PORQUÊ. -6. **Reflita no `supabase/baseline.sql` (OBRIGATÓRIO — é o que o kit self-host aplica).** O baseline é um dump `--schema-only` + um **apêndice idempotente** no fim do arquivo (blocos rotulados `-- ---- (migration NNNN) ----`). O kit HostGator aplica **só o baseline.sql**, tanto no `install.sh` (banco novo, `ON_ERROR_STOP=1`) quanto no `update.sh` (re-aplica em banco existente, **sem** `ON_ERROR_STOP`). Então toda mudança de schema pós-snapshot DEVE ser acrescentada ao apêndice, **idempotente e auto-curativa**: `add column if not exists`, `create ... if not exists`, `create or replace function`, e — se a mudança adiciona constraint — **deduplicar/corrigir os dados ANTES** de criar a constraint (senão o `update.sh` de um clone bugado quebra). Sem isto, clones não recebem a mudança (ou quebram ao atualizar). Migração adicionada só em `migrations/` mas não no baseline **não chega aos self-hosters**. -7. **Aplique e prove**: aplique via `mcp__plugin_supabase_supabase__apply_migration` (ou `supabase db push`), capture o estado ANTES/DEPOIS e prove invariantes (ex.: contagem de linhas que não pode mudar). Se mexeu em contrato, regenere `lib/database.types.ts`. Para mudanças de schema no kit, valide o baseline num Postgres descartável (`pgvector/pgvector:pg17` + extensões) aplicando `install` (fresh, `ON_ERROR_STOP=1`) e `update` (re-aplicar, sem a flag) — ambos têm que passar. -8. **Backfill de dados quebrados existentes**: constraint nova falha se os dados atuais a violam — a migration (e o apêndice do baseline) deve deduplicar/corrigir ANTES de criar a constraint. -9. **Função nova em `public` nasce EXPOSTA — revogue as DUAS origens.** Toda `create function` no schema `public` termina com: +1. **ANTES de começar qualquer trabalho, atualize a branch:** + `git fetch origin && git merge origin/main`. Se a branch ainda não tem commits próprios, é + fast-forward puro (`git merge --ff-only origin/main`) +2. **NUNCA `reset --hard` ou force para "atualizar"** — apaga trabalho. Só dois caminhos: + fast-forward, ou merge da `main` para dentro. A `main` nunca é reescrita +3. **NUNCA toque em branch/worktree com working tree sujo que não é seu.** Cheque `git status` e + `git worktree list` antes; se está suja e é de outra sessão, deixe quieto e avise +4. **Quando uma feature entra na `main`, todas as outras branches ficam atrasadas na hora.** Ao fim + de uma feature, considere propagar a `main` para as branches vivas e limpas +5. **Conflito ao atualizar = pare e resolva com cabeça** (ou escale). Nunca escolha um lado no + automático numa branch que não é sua. Preservar trabalho > branch verde rápido - ```sql - revoke execute on function public.fn_x(...) from public, anon; - grant execute on function public.fn_x(...) to ; - ``` +--- - São duas origens distintas de `EXECUTE`, e tratar só uma deixa a função exposta com o gate verde: **(A)** o grant direto a `anon` do `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` do baseline, que vale para toda função criada depois dele — isto é, para todo apêndice novo — e que `revoke from public` **não** remove; **(B)** o grant a `PUBLIC` que o Postgres dá a qualquer função ao criá-la, que `revoke from anon` **não** remove. Sem os dois, o PostgREST expõe a função como RPC alcançável pela anon key, que vai para o browser. Vigiado por `tests/invariants/hardening-definer-varredura.test.ts`, que varre todas as `security definer` de `public` (issue #128 — a versão anterior checava uma lista fixa de 6, e 8 de 25 estavam expostas). +## Regra final — não invente -**Resumo do fluxo de uma mudança de schema:** arquivo em `migrations/` (fonte da verdade p/ Supabase CLI) **+** apêndice idempotente no `baseline.sql` (p/ o kit self-host) **+** linha no MANIFEST. Os dois artefatos de schema andam juntos. Nunca edite migrations já aplicadas — corrija com uma "forward-fix" nova (e mais um apêndice no baseline). +Este repositório tem PRDs, specs, regras de negócio e doutrina escritos (`docs/prd/`, `docs/specs/`, +`docs/business-rules/`, `docs/doctrine/`). **Nunca invente regra de negócio, número, SLA ou +comportamento de produto.** Se a regra não está escrita, diga que não está e pergunte — não preencha +a lacuna com suposição plausível. Ao documentar, marque o que é `CONFIRMADO` (provado por código) e +o que é `INFERIDO`. --- -## Skills relevantes a usar (Claude Code) +## Skills relevantes (Claude Code) - `superpowers:brainstorming` — antes de implementar feature não-trivial -- `superpowers:writing-plans` — pra task com mais de 1 etapa de DB/API +- `superpowers:writing-plans` — task com mais de uma etapa de DB/API - `superpowers:test-driven-development` — feature crítica (LGPD, RLS, anti-banimento) -- `superpowers:systematic-debugging` — bugs reportados +- `superpowers:systematic-debugging` — bug reportado - `superpowers:verification-before-completion` — antes de declarar "pronto" -- `tomik-db-doctrine` — referência cruzada de doutrina de schema -- `supabase:supabase` — qualquer task com Supabase -- `vercel:nextjs` — App Router, Server Components, edge runtime -- `vercel:ai-gateway` — config de fallback de provider -- `frontend-design` — UI distinta (não cair em shadcn-default genérico) +- `supabase:supabase` · `vercel:nextjs` · `vercel:ai-gateway` · `frontend-design` · `tomik-db-doctrine` --- ## Definition of Done -Antes de declarar uma task pronta: - -1. `npm run typecheck` passa zerado -2. `npm run lint` zerado -3. Testes unit/e2e relevantes existem e passam -4. RLS testada se feature toca tabela tenant-aware -5. Audit log emitido se há mutação relevante -6. Rate limit aplicado se rota é pública -7. Zod valida todo input externo -8. Sem `console.log` esquecido -9. Env vars novas adicionadas em `.env.example` + `lib/env.ts` -10. Doc atualizada se mudou contrato (PRD/spec) -11. **Mudança de schema saiu como migration versionada + linha no MANIFEST** (ver Doutrina de Migrations) — clones conseguem atualizar -12. **Se tocou UI/fluxo de usuário: provado pela tela como um leigo faria**, em ambiente fresco estilo VPS, com evidência visual (ver Doutrina de QA Visual com Recursos Reais) — curl não conta -13. **Living System Checklist respondido** (lei em `docs/doctrine/sistema-vivo.md`; racional no manual `docs/doctrine/sistema-vivo/`) — a feature não é ilha: tem entrada + saída, emite atividade/log, aparece na tela, tem porta na navegação, tem mecanismo anti-morte, **declara seu laço de retorno** (invariante 7 — o que muda no sistema quando ela erra), e o mapa vivo (`docs/architecture/`) reflete peça nova com ≥2 arestas. Resposta que não **nomeia o artefato concreto** (consumidor real, tela real, log real) não conta -14. **Tela nova tem porta** — declarada em `lib/navigation/registry.ts` com seu grupo, ou na allowlist de `tests/unit/navegacao-completude.test.ts` **com justificativa escrita**. Ter tela e ser alcançável são coisas diferentes: o CI reprova tela que existe mas em que só se chega digitando a URL -15. **Se tocou Dockerfile, compose ou setup kit: a mudança chega a quem já instalou** (lei em `docs/doctrine/packaging.md`) — nenhum serviço de produção ficou `build:`-only; variável nova tem default que não quebra `.env` antigo; a atualização não pede edição manual de arquivo; e, se mudou o que a imagem contém, o `update.sh` alcança essa peça. Rode `pnpm test:shell` — é o único gate que exercita o kit -16. **Se o PR muda comportamento, procure a afirmação de estado sobre esse comportamento.** Só - sobre o que você mudou, e só nos documentos de autoridade — não saia caçando pelo repo. A - documentação afirma como o mundo *está*, e uma auditoria de 2026-08-14 achou **227 - afirmações desatualizadas em 393 medidas** - ([`docs/audits/2026-08-14-afirmacoes-de-estado.md`](docs/audits/2026-08-14-afirmacoes-de-estado.md)). - Onde a afirmação puder virar **comando**, troque em vez de corrigir: um número corrigido - envelhece de novo; um `rode isto para saber` não envelhece nunca +1. `pnpm typecheck` zerado +2. `pnpm lint` zerado +3. `pnpm lint:channels` zerado (nenhuma feature nomeia um provider de canal) +4. Testes unit/e2e relevantes existem e passam +5. RLS testada se a feature toca tabela tenant-aware +6. Audit log emitido se há mutação relevante +7. Rate limit aplicado se a rota é pública +8. Zod valida todo input externo +9. Sem `console.log` esquecido +10. Env var nova adicionada em `.env.example` **e** `lib/env.ts` +11. Doc atualizada se mudou contrato (PRD/spec) +12. **Mudança de schema saiu como migration versionada + apêndice no baseline + linha no MANIFEST** — clones conseguem atualizar +13. **Se tocou UI ou fluxo de usuário: provado pela tela como um leigo faria**, em ambiente fresco estilo VPS, com evidência visual — curl não conta +14. **Living System Checklist respondido** (lei em [`docs/doctrine/sistema-vivo.md`](docs/doctrine/sistema-vivo.md)) — a feature não é ilha: tem entrada e saída, emite atividade/log, aparece na tela, tem porta na navegação, tem mecanismo anti-morte, **declara seu laço de retorno** (o que muda no sistema quando ela erra), e o mapa vivo (`docs/architecture/`) reflete a peça nova com ≥2 arestas. Resposta que não **nomeia o artefato concreto** (consumidor real, tela real, log real) não conta +15. **Tela nova tem porta** — declarada em `lib/navigation/registry.ts` com seu grupo, ou na allowlist de `tests/unit/navegacao-completude.test.ts` **com justificativa escrita**. Ter tela e ser alcançável são coisas diferentes +16. **Se tocou Dockerfile, compose ou o kit: a mudança chega a quem já instalou** — nenhum serviço de produção ficou `build:`-only; variável nova tem default que não quebra `.env` antigo; a atualização não pede edição manual de arquivo. Rode `pnpm test:shell` +17. **Se o PR muda comportamento, procure a afirmação de estado sobre esse comportamento** — só sobre o que você mudou, e só nos documentos de autoridade. Onde a afirmação puder virar **comando**, troque em vez de corrigir: um número corrigido envelhece de novo; um `rode isto para saber` não envelhece nunca Um staff engineer aprovaria? Se não, itera. diff --git a/package.json b/package.json index 4d6f5d5f3..ad69c234f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "deskcomm-crm", + "//version": "Metadado inerte, e de propósito. A versão do PRODUTO é o topo do CHANGELOG.md (SemVer) com tag git correspondente; a imagem publicada recebe APP_VERSION do CI (tag → número, fora de tag → SHA curto). Nada em runtime lê este campo — até a 1.2.1 o /api/v1/health lia `npm_package_version`, que é undefined sob `CMD [\"node\",\"server.js\"]`, e TODA instalação respondia 0.1.0. Bumpar aqui não lança nada e reintroduz a ambiguidade.", "version": "0.1.0", "private": true, "description": "DeskcommCRM — sistema operacional de vendas open source com agentes de IA nativos e WhatsApp (WAHA). Self-hosted, multi-tenant, LGPD by-design.", @@ -8,26 +9,23 @@ }, "scripts": { "dev": "next dev", - "dev:webpack": "next dev", "build": "next build", - "build:webpack": "next build", "start": "next start", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "lint": "eslint .", + "lint:channels": "tsx scripts/lint-channels.ts", "format": "prettier --write \"**/*.{ts,tsx,md,json,css}\"", "format:check": "prettier --check \"**/*.{ts,tsx,md,json,css}\"", - "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", - "db:migrate": "echo 'TODO: wire supabase db push or pg-migrate' && exit 0", - "db:reset": "supabase db reset", - "test:e2e": "playwright test", - "e2e:build": "bash scripts/e2e-build.sh", - "e2e:env": "bash scripts/gerar-env-e2e.sh", - "test:journeys": "playwright test -c tests/journeys/playwright.config.ts", + "gov:verify": "pnpm typecheck && pnpm lint && pnpm lint:channels && pnpm test:unit", "test:unit": "vitest run", "test:db": "bash scripts/test-db.sh", + "test:invariants": "pnpm test:db", + "test:e2e": "playwright test", + "test:journeys": "playwright test -c tests/journeys/playwright.config.ts", "test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash hostgator-setup-kit/test-validators.sh", - "test:invariants": "bash scripts/test-db.sh", - "lint:channels": "tsx scripts/lint-channels.ts", - "gov:verify": "pnpm typecheck && pnpm lint && pnpm lint:channels && pnpm test:unit", + "e2e:build": "bash scripts/e2e-build.sh", + "e2e:env": "bash scripts/gerar-env-e2e.sh", + "db:reset": "supabase db reset", "worker": "tsx --env-file=.env --env-file=.env.local workers/agent-worker/main.ts", "flywheel:judge": "tsx --env-file=.env --env-file=.env.local scripts/flywheel-judge-live.ts" }, diff --git a/tests/unit/documentacao-aponta-para-o-que-existe.test.ts b/tests/unit/documentacao-aponta-para-o-que-existe.test.ts index 2863fa9cb..cc31abf8c 100644 --- a/tests/unit/documentacao-aponta-para-o-que-existe.test.ts +++ b/tests/unit/documentacao-aponta-para-o-que-existe.test.ts @@ -34,6 +34,7 @@ const RAIZ = path.resolve(__dirname, "../.."); /** Documentos que alguém lê para DECIDIR. Ampliar esta lista é sempre bem-vindo. */ const AUTORIDADE = [ + ".ai/AI_BOOTSTRAP.md", "CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md", From de477263eef7982a84d504649335abf68f376a53 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sat, 22 Aug 2026 20:00:09 -0300 Subject: [PATCH 04/33] test(unit): conserta 8 gates cegos em checkout Windows (CRLF/separador de path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todo checkout Windows normaliza .github/workflows/e2e.yml, specs de tradução e telas .tsx para CRLF, e oito testes deste repo não sobreviviam a isso — todos verdes no CI Linux, todos vermelhos ou (pior) silenciosamente inertes numa máquina Windows: - e2e-cobertura-completa: `.` não casa `\r` em JS; as três listas de spec do workflow liam vazias, derrubando o controle positivo e acusando as 49 specs de uma vez. - traducao-nao-defasa: o selo `` casa com `$`; o `\r` sobrando fazia duas traduções corretamente seladas reprovarem. - marca-logo-spec-ancora-a-rota: o helper que extrai o corpo da função nunca achava o fecho `\n}\n` — a suíte inteira morria na coleta, 0 testes rodados. - import-puro-sem-env: chamava `npx` direto; no Windows é `npx.cmd`, e o ENOENT resultante passava pelo controle positivo porque ele só checava "a importação falhou", não "falhou pelo motivo certo". Troca para invocar o CLI do tsx via `process.execPath`, mais uma asserção que reprova falha por runner quebrado. - telemetria-tem-um-leitor-so, provedores-x-registry, marca-sem-divergencia-de-hidratacao: comparavam `path.relative()` (que devolve `\` no Windows) contra allowlist escrita com `/` — o desencontro ora derrubava o controle positivo, ora deixava a allowlist inteira INERTE sem avisar ninguém. Acrescenta `caminhoRelativo()` em tests/unit/helpers/varrer-codigo.ts como ponto único de normalização. - vocabulario-do-funil: o regex de texto visível excluía `\n` como primeiro caractere depois do `>`, cego ao caso mais comum de JSX (texto na própria linha). Corrigido, ele achou 4 vazamentos reais de "pipeline"/"Kanban" em copy de usuário — consertados nos mesmos quatro arquivos: SourceDetail, tenant/_form, KanbanBoard, NewLeadDialog ("Ver no Kanban" → "Ver no funil" etc.). Cada conserto tem um teste de mutação nos comentários do diff (spec removida da lista / copy ruim reinjetada / runner quebrado de propósito) provando que o gate volta a acender quando o defeito volta. vitest run: 469/469 arquivos, 5216/5216 testes. typecheck limpo. Co-Authored-By: Claude Sonnet 5 --- app/app/settings/tenant/_form.tsx | 2 +- app/app/webhooks/_components/SourceDetail.tsx | 2 +- components/kanban/KanbanBoard.tsx | 2 +- components/kanban/NewLeadDialog.tsx | 2 +- tests/unit/e2e-cobertura-completa.test.ts | 18 ++++++++++-- tests/unit/helpers/varrer-codigo.ts | 20 +++++++++++++ tests/unit/import-puro-sem-env.test.ts | 24 ++++++++++++++- .../marca-logo-spec-ancora-a-rota.test.ts | 16 ++++++++-- ...rca-sem-divergencia-de-hidratacao.test.tsx | 16 ++++++---- tests/unit/provedores-x-registry.test.ts | 11 ++++--- .../unit/telemetria-tem-um-leitor-so.test.ts | 26 +++++++++++------ tests/unit/traducao-nao-defasa.test.ts | 14 +++++---- tests/unit/vocabulario-do-funil.test.ts | 29 ++++++++++++++++--- 13 files changed, 145 insertions(+), 37 deletions(-) diff --git a/app/app/settings/tenant/_form.tsx b/app/app/settings/tenant/_form.tsx index a24f9328b..b9a06176f 100644 --- a/app/app/settings/tenant/_form.tsx +++ b/app/app/settings/tenant/_form.tsx @@ -159,7 +159,7 @@ export function TenantForm({ initial }: Props) { placeholder="ex: Sem orçamento, Concorrente" />

- Adicionados ao set padrão. Cada pipeline pode ter seus próprios motivos. + Adicionados ao set padrão. Cada funil pode ter seus próprios motivos.

diff --git a/app/app/webhooks/_components/SourceDetail.tsx b/app/app/webhooks/_components/SourceDetail.tsx index 77a522b3b..557f2a97b 100644 --- a/app/app/webhooks/_components/SourceDetail.tsx +++ b/app/app/webhooks/_components/SourceDetail.tsx @@ -203,7 +203,7 @@ export function SourceDetail({ source, open, onOpenChange }: Props) { {testOk ? (

- Ver no Kanban + Ver no funil

) : null} diff --git a/components/kanban/KanbanBoard.tsx b/components/kanban/KanbanBoard.tsx index 612003d2f..735aff71f 100644 --- a/components/kanban/KanbanBoard.tsx +++ b/components/kanban/KanbanBoard.tsx @@ -225,7 +225,7 @@ export function KanbanBoard({ if (data.stages.length === 0) { return ( - Nenhum lead nesta pipeline ainda. + Nenhum lead neste funil ainda. ); } diff --git a/components/kanban/NewLeadDialog.tsx b/components/kanban/NewLeadDialog.tsx index 61ea063a5..074e3402e 100644 --- a/components/kanban/NewLeadDialog.tsx +++ b/components/kanban/NewLeadDialog.tsx @@ -133,7 +133,7 @@ export function NewLeadDialog({ open, onOpenChange, pipelineId, stages, contactI Novo Lead - Crie um lead manualmente neste pipeline. + Crie um lead manualmente neste funil.
diff --git a/tests/unit/e2e-cobertura-completa.test.ts b/tests/unit/e2e-cobertura-completa.test.ts index acbe90778..833573578 100644 --- a/tests/unit/e2e-cobertura-completa.test.ts +++ b/tests/unit/e2e-cobertura-completa.test.ts @@ -57,8 +57,16 @@ const DIR_SPECS = path.join(RAIZ, "tests", "e2e"); * em vez de devolver lista vazia. */ function listaDoWorkflow(yml: string, chave: string): string[] { + // CRLF primeiro, senão o parser devolve lista VAZIA em qualquer checkout + // Windows (git converte o workflow para `\r\n` por default). Em JS, `.` não + // casa `\r` — ele é terminador de linha —, então `\S.*\n` para antes do `\r` + // e a alternativa `\n` nunca chega. As três listas voltam vazias, e o efeito é + // o pior possível: o controle positivo estoura e a asserção de completude acusa + // TODAS as specs de uma vez, num repositório onde nada está errado. Um gate que + // acende sem defeito é um gate que se aprende a ignorar. + const conteudo = yml.replace(/\r\n/g, "\n"); const re = new RegExp(`^\\s*${chave}:\\s*>-\\s*\\n((?:\\s{8,}\\S.*\\n)+)`, "m"); - const m = re.exec(yml); + const m = re.exec(conteudo); if (m === null) return []; return m[1]! .split(/\s+/) @@ -79,7 +87,9 @@ describe("cobertura do e2e no CI", () => { // Sem isto, um regex que parou de casar devolveria três listas vazias e a // asserção de vigência passaria por vacuidade, enquanto a de completude // acusaria as 39 specs de uma vez. Verde e vermelho errados pelo mesmo motivo. - expect(noDisco.length, "nenhuma spec no disco — o diretório mudou de lugar?").toBeGreaterThan(30); + expect(noDisco.length, "nenhuma spec no disco — o diretório mudou de lugar?").toBeGreaterThan( + 30, + ); expect(parte1.length, "SPECS_PARTE_1 não foi lida do workflow").toBeGreaterThan(10); expect(parte2.length, "SPECS_PARTE_2 não foi lida do workflow").toBeGreaterThan(10); expect(foraDoCi.length, "FORA_DO_CI não foi lida do workflow").toBeGreaterThan(0); @@ -106,7 +116,9 @@ describe("cobertura do e2e no CI", () => { // acha nada e o job termina VERDE. Uma renomeação silenciosamente desliga a // cobertura daquele arquivo. const fantasmas = [...parte1, ...parte2, ...foraDoCi].filter((f) => !noDisco.includes(f)); - expect(fantasmas, "lista do CI aponta para spec inexistente — renomeada ou apagada").toEqual([]); + expect(fantasmas, "lista do CI aponta para spec inexistente — renomeada ou apagada").toEqual( + [], + ); }); it("as listas são de fato passadas ao Playwright", () => { diff --git a/tests/unit/helpers/varrer-codigo.ts b/tests/unit/helpers/varrer-codigo.ts index 7bfa00dc5..fbe6ebd46 100644 --- a/tests/unit/helpers/varrer-codigo.ts +++ b/tests/unit/helpers/varrer-codigo.ts @@ -40,3 +40,23 @@ function varrer(dir: string): string[] { export function arquivosDeCodigo(raizes: readonly string[]): string[] { return raizes.flatMap((r) => varrer(path.join(RAIZ_DO_REPO, r))); } + +/** + * Caminho relativo à raiz do repo, SEMPRE com `/`. + * + * `path.relative` devolve `lib\ai\log-invocation.ts` num checkout Windows, e toda + * allowlist deste repo é escrita com `/`. Comparar os dois não dá erro: dá o + * silêncio errado. Medido em 2026-08-22, com a varredura crua: + * + * - `telemetria-tem-um-leitor-so`: o controle positivo falha, a allowlist inteira + * parece morta e um arquivo PERMITIDO vira infrator — vermelho sem defeito. + * - `provedores-x-registry`: o mesmo desencontro deixa a allowlist INERTE, e o + * teste segue verde só porque nenhum arquivo isento casa o padrão hoje. É o + * modo pior: o gate perde a isenção sem avisar ninguém. + * + * Quem compara caminho com literal escrito à mão usa esta função, nunca + * `path.relative` cru. + */ +export function caminhoRelativo(absoluto: string): string { + return path.relative(RAIZ_DO_REPO, absoluto).split(path.sep).join("/"); +} diff --git a/tests/unit/import-puro-sem-env.test.ts b/tests/unit/import-puro-sem-env.test.ts index bdf4cb662..953b1d39e 100644 --- a/tests/unit/import-puro-sem-env.test.ts +++ b/tests/unit/import-puro-sem-env.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -32,6 +33,9 @@ import { describe, expect, it } from "vitest"; const RAIZ = join(__dirname, "..", ".."); +/** O CLI do `tsx` resolvido pelo próprio node — sem depender de shim de shell. */ +const CLI_TSX = createRequire(__filename).resolve("tsx/cli"); + /** * Os módulos vigiados: os que um teste unitário importa por causa de função * pura. A lista é explícita porque a alternativa (varrer tudo) acusaria os @@ -50,9 +54,19 @@ function importaComAmbienteLimpo(modulo: string): { ok: boolean; erro: string } const limpo = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", + // Windows: sem `SystemRoot` o processo filho do Node nem sobe (o loader usa + // APIs de sistema que o resolvem por ali). Não é variável do app — a ausência + // que este teste mede continua intacta. + ...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot ?? "" } : {}), } as unknown as NodeJS.ProcessEnv; try { - const saida = execFileSync("npx", ["tsx", "--eval", script], { + // `process.execPath` + o CLI resolvido, e não `npx`: em checkout Windows o + // binário é `npx.cmd`, que `execFileSync` sem `shell: true` não consegue + // executar. O erro era `spawnSync npx ENOENT` — e ele passava pelo CONTROLE + // POSITIVO abaixo, que só exige que `@/lib/env` falhe, sem exigir que tenha + // falhado pelo motivo certo. Gate vermelho por não conseguir medir, com o + // controle dizendo que estava medindo. + const saida = execFileSync(process.execPath, [CLI_TSX, "--eval", script], { cwd: RAIZ, env: limpo, encoding: "utf8", @@ -80,6 +94,14 @@ describe("módulo de função pura importa sem ambiente", () => { // `@/lib/env` DEVE falhar sem ambiente: é literalmente o trabalho dele. const controle = importaComAmbienteLimpo("@/lib/env"); expect(controle.ok, `esperava @/lib/env FALHAR sem env, veio: ${controle.erro}`).toBe(false); + // E falhar pelo motivo CERTO. Sem esta linha, um runner que nem sobe (o + // `spawnSync npx ENOENT` do Windows) satisfaz o controle: ele só pede uma + // falha, e "não consegui medir" é uma falha. O controle passava a afirmar + // que o aparato funcionava exatamente quando ele não funcionava. + expect( + /spawnSync|ENOENT|not recognized/i.test(controle.erro), + `o processo filho nem chegou a rodar: ${controle.erro}`, + ).toBe(false); }); it.each(MODULOS_PUROS)("%s importa com ambiente vazio", { timeout: 60_000 }, (modulo) => { diff --git a/tests/unit/marca-logo-spec-ancora-a-rota.test.ts b/tests/unit/marca-logo-spec-ancora-a-rota.test.ts index 58eef3b50..ed83bdcff 100644 --- a/tests/unit/marca-logo-spec-ancora-a-rota.test.ts +++ b/tests/unit/marca-logo-spec-ancora-a-rota.test.ts @@ -43,8 +43,20 @@ const RAIZ = process.cwd(); const CAMINHO_SPEC = path.join(RAIZ, "tests/e2e/marca-logo.spec.ts"); const CAMINHO_CONFIG = path.join(RAIZ, "playwright.config.ts"); -const SPEC = readFileSync(CAMINHO_SPEC, "utf8"); -const CONFIG = readFileSync(CAMINHO_CONFIG, "utf8"); +/** + * Lê normalizando o fim de linha. + * + * `corpoDaFuncao` procura `"\n}\n"` — o fecho na coluna zero. Num checkout + * Windows o arquivo tem `\r\n`, a busca não acha nada, e o `expect` de dentro do + * helper derruba a SUÍTE INTEIRA na coleta (0 teste rodado). Vermelho sem defeito, + * e sem sequer dizer qual asserção falhou. + */ +function lerNormalizado(caminho: string): string { + return readFileSync(caminho, "utf8").replace(/\r\n/g, "\n"); +} + +const SPEC = lerNormalizado(CAMINHO_SPEC); +const CONFIG = lerNormalizado(CAMINHO_CONFIG); /** O corpo de uma função de topo de arquivo, do `{` ao `\n}` da coluna zero. */ function corpoDaFuncao(fonte: string, assinatura: string): string { diff --git a/tests/unit/marca-sem-divergencia-de-hidratacao.test.tsx b/tests/unit/marca-sem-divergencia-de-hidratacao.test.tsx index 9e4ef0c94..d6044a76b 100644 --- a/tests/unit/marca-sem-divergencia-de-hidratacao.test.tsx +++ b/tests/unit/marca-sem-divergencia-de-hidratacao.test.tsx @@ -52,6 +52,8 @@ import { Sidebar } from "@/components/shell/Sidebar"; import type { ActiveOrg, AuthUser } from "@/lib/auth/types"; import { MarcaDaInstalacaoProvider } from "@/lib/branding/contexto"; +import { caminhoRelativo } from "./helpers/varrer-codigo"; + vi.mock("next/navigation", () => ({ usePathname: () => "/app/ai/agents" })); vi.mock("@/app/actions/shell/toggleSidebar", () => ({ toggleSidebar: vi.fn() })); vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (chave: string) => chave })); @@ -218,7 +220,9 @@ describe("catraca: `branding()` é server-only", () => { // Uma lista vazia faria a asserção principal passar sem medir nada — que é o // modo nº 1 de um gate ficar verde por engano. expect(varridos.length).toBeGreaterThan(500); - const clientes = varridos.filter((f) => /^["']use client["']/m.test(fs.readFileSync(f, "utf8"))); + const clientes = varridos.filter((f) => + /^["']use client["']/m.test(fs.readFileSync(f, "utf8")), + ); expect(clientes.length).toBeGreaterThan(100); }); @@ -227,7 +231,9 @@ describe("catraca: `branding()` é server-only", () => { expect(semComentarios(`{/*\n fala de branding() na prosa\n*/}\nconst x = 1;`)).not.toMatch( /\bbranding\(\)/, ); - expect(semComentarios(`const nome = branding().name; // usa a marca`)).toMatch(/\bbranding\(\)/); + expect(semComentarios(`const nome = branding().name; // usa a marca`)).toMatch( + /\bbranding\(\)/, + ); }); it("os call sites REAIS de `branding()` continuam visíveis à varredura", () => { @@ -241,18 +247,18 @@ describe("catraca: `branding()` é server-only", () => { "app/onboarding/layout.tsx", "lib/legal/operador.ts", ]; - const vistos = varridos.filter(chamaBranding).map((f) => path.relative(RAIZ, f)); + const vistos = varridos.filter(chamaBranding).map((f) => caminhoRelativo(f)); expect(esperados.filter((e) => !vistos.includes(e))).toEqual([]); }); - it("nenhum componente `\"use client\"` chama `branding()`", () => { + it('nenhum componente `"use client"` chama `branding()`', () => { const infratores = varridos.filter( (arquivo) => /^["']use client["']/m.test(fs.readFileSync(arquivo, "utf8")) && chamaBranding(arquivo), ); expect( - infratores.map((f) => path.relative(RAIZ, f)), + infratores.map((f) => caminhoRelativo(f)), "`branding()` lê `window.__PUBLIC_ENV__` no navegador e `process.env` no\n" + "servidor, e as duas fontes divergem desde que o layout raiz passou a\n" + "injetar a marca do BANCO. Num client component isso é hydration mismatch\n" + diff --git a/tests/unit/provedores-x-registry.test.ts b/tests/unit/provedores-x-registry.test.ts index 71c735b98..e9084188c 100644 --- a/tests/unit/provedores-x-registry.test.ts +++ b/tests/unit/provedores-x-registry.test.ts @@ -94,7 +94,11 @@ describe("o endpoint próprio chega até a fábrica", () => { // A assinatura precisa aceitar baseUrl, senão `ai_purpose_bindings.base_url` // seria uma coluna que a tela preenche e o runtime ignora — configuração // que não configura nada. - const modelo = registry["openrouter"]!("chave-de-teste", "meta-llama/llama-3.3-70b-instruct", "https://gateway.exemplo/v1"); + const modelo = registry["openrouter"]!( + "chave-de-teste", + "meta-llama/llama-3.3-70b-instruct", + "https://gateway.exemplo/v1", + ); expect(modelo).toBeDefined(); }); @@ -157,8 +161,7 @@ describe("lista de provedores × os pontos de ESCRITA", () => { // atravessa. A allowlist existe para os casos onde os três SÃO o assunto // (ex.: derivar provider do prefixo de um id de modelo legado). const { readFileSync } = await import("node:fs"); - const { relative } = await import("node:path"); - const { RAIZ_DO_REPO, arquivosDeCodigo } = await import("./helpers/varrer-codigo"); + const { arquivosDeCodigo, caminhoRelativo } = await import("./helpers/varrer-codigo"); const PERMITIDOS = new Set([ // Deriva o provedor do PREFIXO de um id de modelo legado — não é uma @@ -172,7 +175,7 @@ describe("lista de provedores × os pontos de ESCRITA", () => { expect(arquivos.length, "a varredura não enxergou o código").toBeGreaterThan(200); const sobras = arquivos - .map((a) => relative(RAIZ_DO_REPO, a)) + .map((a) => caminhoRelativo(a)) .filter((caminho) => !PERMITIDOS.has(caminho)) .filter((caminho) => TRINCA.test(readFileSync(caminho, "utf8"))); diff --git a/tests/unit/telemetria-tem-um-leitor-so.test.ts b/tests/unit/telemetria-tem-um-leitor-so.test.ts index f37edbc9a..a59c53185 100644 --- a/tests/unit/telemetria-tem-um-leitor-so.test.ts +++ b/tests/unit/telemetria-tem-um-leitor-so.test.ts @@ -26,11 +26,11 @@ * despercebida. */ import { readFileSync } from "node:fs"; -import { join, relative } from "node:path"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { RAIZ_DO_REPO, arquivosDeCodigo } from "./helpers/varrer-codigo"; +import { arquivosDeCodigo, caminhoRelativo } from "./helpers/varrer-codigo"; /** * Quem PODE mencionar `ai_invocations`, e por quê. Uma entrada aqui é uma @@ -48,12 +48,17 @@ const PERMITIDOS: Record = { const CONSULTA = /\.from\(\s*["'`]ai_invocations["'`]\s*\)/; describe("telemetria de IA tem uma tabela só", () => { - const arquivos = arquivosDeCodigo(["app", "lib", "workers", "components", "hooks", "scripts"]).map( - (absoluto) => ({ - caminho: relative(RAIZ_DO_REPO, absoluto), - conteudo: readFileSync(absoluto, "utf8"), - }), - ); + const arquivos = arquivosDeCodigo([ + "app", + "lib", + "workers", + "components", + "hooks", + "scripts", + ]).map((absoluto) => ({ + caminho: caminhoRelativo(absoluto), + conteudo: readFileSync(absoluto, "utf8"), + })); it("a varredura enxerga o código (controle positivo)", () => { // Sem isto, um glob que devolvesse zero arquivos faria o teste abaixo @@ -84,7 +89,10 @@ describe("telemetria de IA tem uma tabela só", () => { const inexistentes = Object.keys(PERMITIDOS).filter( (caminho) => !arquivos.some((a) => a.caminho === caminho), ); - expect(inexistentes, `entradas da allowlist apontando para arquivo que não existe mais`).toEqual([]); + expect( + inexistentes, + `entradas da allowlist apontando para arquivo que não existe mais`, + ).toEqual([]); }); it("log-invocation grava em llm_calls, não na tabela depreciada", () => { diff --git a/tests/unit/traducao-nao-defasa.test.ts b/tests/unit/traducao-nao-defasa.test.ts index ed142f0fb..bdefb2a79 100644 --- a/tests/unit/traducao-nao-defasa.test.ts +++ b/tests/unit/traducao-nao-defasa.test.ts @@ -87,11 +87,15 @@ describe.each(TRADUCOES)("$traducao ($idioma)", (par) => { }); it("tem um selo na linha 1", () => { - const primeira = readFileSync(raiz(par.traducao), "utf8").split("\n", 1)[0] ?? ""; - expect( - primeira, - `${par.traducao} não começa com o selo. Rode: ${COMANDO_DE_RESELO}`, - ).toMatch(SELO); + // `.trimEnd()` pelo mesmo motivo que `lerSelo` o faz: num checkout Windows a + // linha 1 termina em `\r`, e `SELO` ancora em `$`. Sem isto o gate reprova as + // DUAS traduções — com o selo correto no arquivo — e manda re-selar, que é + // exatamente o hábito que `hashDoOriginal` normaliza CRLF para evitar. O teste + // guardava a regra e quebrava a própria regra. + const primeira = (readFileSync(raiz(par.traducao), "utf8").split("\n", 1)[0] ?? "").trimEnd(); + expect(primeira, `${par.traducao} não começa com o selo. Rode: ${COMANDO_DE_RESELO}`).toMatch( + SELO, + ); }); it("o selo aponta para o original deste par, e não para outro arquivo", () => { diff --git a/tests/unit/vocabulario-do-funil.test.ts b/tests/unit/vocabulario-do-funil.test.ts index c7274cf2c..2ac0f6e08 100644 --- a/tests/unit/vocabulario-do-funil.test.ts +++ b/tests/unit/vocabulario-do-funil.test.ts @@ -61,7 +61,23 @@ const PROIBIDAS = /\b(pipelines?|kanban)\b/i; function textosVisiveis(src: string): string[] { const achados: string[] = []; - for (const m of src.matchAll(/>([^<>{}\n][^<>{}]*) + // Nenhum lead nesta pipeline ainda. + // + // + // Depois do `>` vem `\n`, a classe o proibia como primeiro caractere, e o + // trecho inteiro ficava invisível. Num checkout Windows o primeiro caractere é + // `\r` — permitido —, então o mesmo teste acusava no Linux ZERO e no Windows + // QUATRO infrações reais. Não era o Windows exagerando: era o CI que não + // enxergava. Com a classe aberta, os dois medem 4, que é o número certo. + // + // O `{`/`}` seguem fora da classe e são o que segura o falso positivo: qualquer + // expressão JSX interrompe o casamento em vez de virar "texto de tela". + for (const m of src.matchAll(/>([^<>{}]+)` (de um genérico ou de um JSX acima) entra como se @@ -70,7 +86,8 @@ function textosVisiveis(src: string): string[] { if (t.length > 2) achados.push(t); } - const props = /\b(headline|subcopy|placeholder|title|label|aria-label|description|alt)\s*=\s*"([^"]+)"/g; + const props = + /\b(headline|subcopy|placeholder|title|label|aria-label|description|alt)\s*=\s*"([^"]+)"/g; for (const m of src.matchAll(props)) achados.push(m[2] ?? ""); return achados; @@ -80,9 +97,13 @@ describe("o vocabulário do funil na interface", () => { it("nenhuma tela mostra 'pipeline' ou 'kanban' ao usuário", () => { const infratores: string[] = []; for (const f of ARQUIVOS) { - const src = readFileSync(f, "utf8"); + // Normaliza o fim de linha para que a varredura meça o MESMO em qualquer + // checkout — foi a divergência CRLF × LF que expôs o buraco acima. + const src = readFileSync(f, "utf8").replace(/\r\n/g, "\n"); for (const t of textosVisiveis(src)) { - if (PROIBIDAS.test(t)) infratores.push(`${f} → ${t.slice(0, 80)}`); + // Caminho sempre com `/`: a mensagem é para ser colada num editor, e no + // Windows `join` devolve `components\kanban\…`, que nenhum grep aceita. + if (PROIBIDAS.test(t)) infratores.push(`${f.replace(/\\/g, "/")} → ${t.slice(0, 80)}`); } } expect( From 9f9f453d18d1fceb71127020d3192520b17f542e Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sat, 22 Aug 2026 23:25:13 -0300 Subject: [PATCH 05/33] fix(agente): veto do gate pacing derrubava o turno sem reagendar nada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_message só ENSINAVA o modelo quando pacing vetava (outside_window/ warmup_cap/daily_cap) — o run fechava "concluído" com messages_sent: 0 e nada reagendava. Fora do caminho determinístico de re-entrada (runDeterministicReentry, que só cobre outside_window), o lead ficava sem resposta até mandar outra mensagem por conta própria. Medido em produção (instalação MKT, 2026-08-22): warmup_cap bateu no meio de uma conversa; o cliente mandou "Preciso da planta baixa..." e não recebeu nada, nem depois de o teto ser liberado — só reenfileirando o job na mão é que a resposta saiu. reagendarTurnoPorVetoDePacing reusa followup_turn (a mesma peça que rescheduleReentry já usa pra outside_window na re-entrada determinística) pros três códigos do pacing — todos carregam nextAllowedAt; nenhum outro gate carrega. Idempotente por job de origem, best-effort (falha no agendamento não deve derrubar o erro de ensino que o modelo já recebeu). Co-Authored-By: Claude Sonnet 5 --- lib/agent-engine/agent/inbound-turn.ts | 72 ++++++++++++++++- .../reagendar-por-veto-de-pacing.test.ts | 78 +++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 lib/agent-engine/agent/reagendar-por-veto-de-pacing.test.ts diff --git a/lib/agent-engine/agent/inbound-turn.ts b/lib/agent-engine/agent/inbound-turn.ts index c4dc9d6b3..b28a11020 100644 --- a/lib/agent-engine/agent/inbound-turn.ts +++ b/lib/agent-engine/agent/inbound-turn.ts @@ -89,7 +89,7 @@ import { provideCaseUpdateInputSchema, } from './human-cases'; import { buildMcpTurnTools } from '../edge/crm/mcp-tools'; -import { cancelPendingCronsForLead } from '../cron/scheduler'; +import { cancelPendingCronsForLead, scheduleCronJob } from '../cron/scheduler'; import { latestInboundSignal, loadSkills, @@ -931,6 +931,64 @@ export async function avisarCapacidadesAusentes( } } +/** + * Veto do gate `pacing` não pode virar silêncio para o lead (mesmo argumento de + * `comHandoffSeOrcamentoAcabar`, aplicado a um gate diferente). + * + * `runBeforeSend` veta em `outside_window`/`warmup_cap`/`daily_cap` com + * `nextAllowedAt` já calculado (`decidePacing`, `pacing/engine.ts`) — mas até aqui + * o `send_message` do turno só ENSINAVA o modelo (retornava o erro pro loop de + * tools) e o run fechava com `messages_sent: 0`, sem reagendar nada. Fora do + * caminho determinístico de re-entrada (`runDeterministicReentry`, que só cobre + * `outside_window`), NADA reagendava — medido em produção (instalação MKT, + * 2026-08-22): `warmup_cap` bateu com o cliente NO MEIO da conversa, o turno + * terminou "concluído" e a próxima tentativa só aconteceria se o cliente + * mandasse OUTRA mensagem. + * + * A saída é reusar `followup_turn` como o "volte e tente de novo" — é a MESMA + * peça que a re-entrada determinística já usa para `outside_window` + * (`rescheduleReentry`, followup-turn.ts), só que agora coberta para os TRÊS + * códigos de veto do pacing (todos carregam `nextAllowedAt`; nenhum outro gate + * carrega). `followup_turn` resolve conversa/sessão pela ROW do lead (nunca do + * payload), então reagendar só precisa do `leadId` e do instante. + * + * Idempotente por job de origem (mesmo padrão de `rescheduleReentry`): dois sends + * vetados no MESMO turno (o modelo tenta de novo após o erro de ensino) não + * duplicam o reagendamento — cabe UM followup_turn por job que sofreu o veto. + * + * Best-effort: falhar aqui não pode derrubar o erro de ensino que o modelo já + * está recebendo — o pior caso vira "sem reagendamento" (o defeito de antes), + * nunca uma exceção que descarta o turno inteiro por causa do agendamento. + */ +export async function reagendarTurnoPorVetoDePacing( + pool: pg.Pool, + log: Logger, + input: { tenantId: string; leadId: string; jobId: string; at: Date }, +): Promise { + try { + const { rowCount } = await pool.query( + `select 1 from cron_jobs + where organization_id = $1 and contact_id = $2 and payload->>'reschedule_of' = $3`, + [input.tenantId, input.leadId, input.jobId], + ); + if (rowCount !== null && rowCount > 0) return; // já reagendado para este job + await scheduleCronJob(pool, input.tenantId, { + leadId: input.leadId, + spec: { kind: 'at', at: input.at }, + jobKind: 'followup_turn', + payload: { reschedule_of: input.jobId }, + staggerWindowMs: 0, + }); + log.info('turno reagendado após veto do pacing — lead não fica sem próximo passo', { + next_run_at: input.at.toISOString(), + }); + } catch (err) { + log.warn('reagendamento pós-veto de pacing falhou — turno segue sem retry automático', { + error: (err instanceof Error ? err.message : String(err)).slice(0, 120), + }); + } +} + /** * O NÚCLEO DO TURNO, SEMPRE SOB A ESCOLTA DO ORÇAMENTO. * @@ -1746,6 +1804,18 @@ async function executarTurnoDoAgente( }); } if (chain.status === 'vetoed') { + // pacing (outside_window/warmup_cap/daily_cap) é o ÚNICO gate que calcula + // nextAllowedAt — veto dele não pode virar silêncio: reagenda um + // followup_turn pra tentar de novo na próxima janela válida (ver o + // cabeçalho de reagendarTurnoPorVetoDePacing). + if (chain.gate === 'pacing' && chain.nextAllowedAt !== undefined) { + await reagendarTurnoPorVetoDePacing(pool, runLog, { + tenantId, + leadId, + jobId: job.id, + at: chain.nextAllowedAt, + }); + } // Erro de ENSINO pt-br (mesmo shape de get_lead_context/breaker): o // modelo o vê no turno seguinte. NÃO é exceção — não derruba o run. return { ok: false, error: { code: chain.code, message: chain.message } }; diff --git a/lib/agent-engine/agent/reagendar-por-veto-de-pacing.test.ts b/lib/agent-engine/agent/reagendar-por-veto-de-pacing.test.ts new file mode 100644 index 000000000..0db47011e --- /dev/null +++ b/lib/agent-engine/agent/reagendar-por-veto-de-pacing.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import type pg from 'pg'; + +import { reagendarTurnoPorVetoDePacing } from './inbound-turn'; +import type { Logger } from '../obs/logger'; + +/** + * Bug medido em produção (instalação MKT, 2026-08-22): o `send_message` do turno + * só ENSINAVA o modelo quando o gate `pacing` vetava (warmup_cap/daily_cap/ + * outside_window) — o run fechava "concluído" com 0 mensagens enviadas e NADA + * reagendava. O cliente ficava sem resposta até mandar outra mensagem por conta + * própria. `reagendarTurnoPorVetoDePacing` fecha esse buraco reusando o + * `followup_turn` como "volte e tente de novo". + */ +function logFalso(): Logger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger; +} + +function poolFalso(opts: { jaReagendado: boolean; falharNoInsert?: boolean }) { + const chamadas: Array<{ sql: string; params: unknown[] }> = []; + const query = vi.fn(async (sql: string, params: unknown[] = []) => { + chamadas.push({ sql, params }); + const s = sql.replace(/\s+/g, ' ').trim(); + if (s.startsWith('select 1 from cron_jobs')) { + return { rows: opts.jaReagendado ? [{ '?column?': 1 }] : [], rowCount: opts.jaReagendado ? 1 : 0 }; + } + if (s.startsWith('insert into cron_jobs')) { + if (opts.falharNoInsert) throw new Error('conexão caiu'); + return { rows: [{ id: 'cron-novo' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + return { query, chamadas } as unknown as pg.Pool & { chamadas: typeof chamadas }; +} + +const INPUT = { + tenantId: 'org-1', + leadId: 'lead-1', + jobId: 'job-vetado-1', + at: new Date('2026-08-23T10:00:00.000Z'), +}; + +describe('reagendarTurnoPorVetoDePacing', () => { + it('sem reagendamento prévio: cria followup_turn no instante do veto (nextAllowedAt)', async () => { + const pool = poolFalso({ jaReagendado: false }); + const log = logFalso(); + + await reagendarTurnoPorVetoDePacing(pool, log, INPUT); + + const insert = pool.chamadas.find((c) => c.sql.includes('insert into cron_jobs')); + expect(insert).toBeDefined(); + // (organization_id, contact_id, kind, interval_ms, cron_expr, tz, job_kind, payload, next_run_at, max_attempts) + expect(insert?.params[0]).toBe('org-1'); // organization_id + expect(insert?.params[1]).toBe('lead-1'); // contact_id + expect(insert?.params[2]).toBe('at'); // kind do cron spec + expect(insert?.params[6]).toBe('followup_turn'); // job_kind + expect(insert?.params[7]).toEqual({ reschedule_of: 'job-vetado-1' }); // payload + expect(insert?.params[8]).toEqual(INPUT.at); // next_run_at (staggerWindowMs 0 = sem deslocamento) + expect(log.info).toHaveBeenCalled(); + }); + + it('já existe followup_turn reagendado para este job — não duplica (idempotência)', async () => { + const pool = poolFalso({ jaReagendado: true }); + const log = logFalso(); + + await reagendarTurnoPorVetoDePacing(pool, log, INPUT); + + expect(pool.chamadas.some((c) => c.sql.includes('insert into cron_jobs'))).toBe(false); + }); + + it('falha no banco não lança — best-effort, turno segue com o erro de ensino que já tinha', async () => { + const pool = poolFalso({ jaReagendado: false, falharNoInsert: true }); + const log = logFalso(); + + await expect(reagendarTurnoPorVetoDePacing(pool, log, INPUT)).resolves.toBeUndefined(); + expect(log.warn).toHaveBeenCalled(); + }); +}); From 17a0134cfcc972020303b24409fb56dfd5c2f4a8 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 00:14:04 -0300 Subject: [PATCH 06/33] chore(packaging): repoint kit e compose pro fork independente (maugarciasa) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Este fork passa a se atualizar e se instalar sozinho, sem depender do repositório original (melgarafael/DeskcommCRM) nem de permissão de merge lá. Todo ponto que apontava pro namespace/repo original agora aponta pro fork: - docker-compose.prod.yml: default de APP_IMAGE/WORKER_IMAGE/ SCHEDULER_IMAGE (fallback de quem não tem a chave no .env). - .env.hostgator.example: os mesmos três defaults, o que o install.sh copia pra instalação nova. - hostgator-setup-kit/install.sh, comecar.sh, diagnostico.sh: REPO_URL default (clone do kit) e os exemplos de `curl | bash`. - hostgator-setup-kit/_common.sh: IMG_NS (a constante de onde ultima_versao_publicada() e ghcr_status() puxam versão/manifest) e as duas URLs de token/manifest do GHCR que ainda citavam o namespace antigo direto. - hostgator-setup-kit/update.sh: fallback de APP_IMAGE em image_desatualizada(). - hostgator-setup-kit/test-validators.sh, tests/shell/update-guard.test.sh: fixtures e asserções atualizadas pro novo namespace — pnpm test:shell passa de novo (rodado localmente; achado à parte, não-regressão: ".env continua 600" falha neste Windows/Git Bash por causa de como NTFS mapeia bits de permissão POSIX, mesmo sem nenhuma mudança de conteúdo). Não editei texto cosmético (README, CONTRIBUTING, docs de growth, narrativa histórica em comentários) — só o que o instalador/atualizador realmente lê em runtime. Co-Authored-By: Claude Sonnet 5 --- .env.hostgator.example | 6 +-- docker-compose.prod.yml | 6 +-- hostgator-setup-kit/_common.sh | 8 ++-- hostgator-setup-kit/comecar.sh | 4 +- hostgator-setup-kit/diagnostico.sh | 2 +- hostgator-setup-kit/install.sh | 2 +- hostgator-setup-kit/test-validators.sh | 4 +- hostgator-setup-kit/update.sh | 2 +- tests/shell/update-guard.test.sh | 58 +++++++++++++------------- 9 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.env.hostgator.example b/.env.hostgator.example index 4facf0c21..706d0e0d0 100644 --- a/.env.hostgator.example +++ b/.env.hostgator.example @@ -28,16 +28,16 @@ # Os valores abaixo são o PISO seguro para quem preenche à mão (stable = a # última release). O install.sh sobrescreve com o NÚMERO da versão, que é o que # uma instalação de verdade usa — ver a regra de ouro na doutrina. -APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:stable # troque pelo seu fork se publicar o seu +APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:stable APP_PULL_POLICY=always # O worker (agente de IA 24/7) e o scheduler (crons) acompanham a versão do app. # Até 2026-08-13 eles não tinham imagem: eram compilados na sua VPS no install e # nenhum update.sh jamais os reconstruía — o agente ficava congelado no código # do dia da instalação. Deixe-os na mesma versão do APP_IMAGE acima. -WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:stable +WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:stable WORKER_PULL_POLICY=always -SCHEDULER_IMAGE=ghcr.io/melgarafael/deskcomm-scheduler:stable +SCHEDULER_IMAGE=ghcr.io/maugarciasa/deskcomm-scheduler:stable SCHEDULER_PULL_POLICY=always # ----------------------------------------------------------------------------- diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f6f0820c9..8ab5de55e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,7 +32,7 @@ services: # default é quem NÃO tem APP_IMAGE no .env — avaliação, ou um .env que # perdeu a chave. Nenhum desses casos quer código não lançado; quem quiser o # topo da main pede `APP_IMAGE=…:latest` explicitamente. - image: ${APP_IMAGE:-ghcr.io/melgarafael/deskcommcrm:stable} + image: ${APP_IMAGE:-ghcr.io/maugarciasa/deskcommcrm:stable} pull_policy: ${APP_PULL_POLICY:-always} restart: unless-stopped env_file: .env @@ -89,7 +89,7 @@ services: # default): o app pinado numa release e o runtime do agente de # IA rodando código não lançado, sobre o banco da release. `:stable` é a # última release publicada — pareia com o app em vez de correr na frente. - image: ${WORKER_IMAGE:-ghcr.io/melgarafael/deskcomm-worker:stable} + image: ${WORKER_IMAGE:-ghcr.io/maugarciasa/deskcomm-worker:stable} pull_policy: ${WORKER_PULL_POLICY:-always} build: context: . @@ -209,7 +209,7 @@ services: # serviço `restart: unless-stopped`, isso significa que o cron do cliente só # voltava se a VPS tivesse internet e o mirror do Alpine estivesse de pé, # justamente no momento em que a máquina está se recuperando de algo. - image: ${SCHEDULER_IMAGE:-ghcr.io/melgarafael/deskcomm-scheduler:stable} + image: ${SCHEDULER_IMAGE:-ghcr.io/maugarciasa/deskcomm-scheduler:stable} pull_policy: ${SCHEDULER_PULL_POLICY:-always} build: context: . diff --git a/hostgator-setup-kit/_common.sh b/hostgator-setup-kit/_common.sh index 5d11896af..f1ed6e53d 100755 --- a/hostgator-setup-kit/_common.sh +++ b/hostgator-setup-kit/_common.sh @@ -348,7 +348,7 @@ psql_run() { docker run --rm -i postgres:17-alpine psql "$(url_do_schema)" -v ON # O namespace é constante e literal de propósito: ele está gravado no .env de # toda instalação viva, e derivá-lo de variável faria o kit antigo (que já está # no disco do cliente) e o novo montarem strings diferentes. -IMG_NS="ghcr.io/melgarafael" +IMG_NS="ghcr.io/maugarciasa" IMG_APP="${IMG_NS}/deskcommcrm" IMG_WORKER="${IMG_NS}/deskcomm-worker" IMG_SCHEDULER="${IMG_NS}/deskcomm-scheduler" @@ -365,7 +365,7 @@ IMG_SCHEDULER="${IMG_NS}/deskcomm-scheduler" # alguém porque não deu para resolver um número de versão seria trocar um # problema de previsibilidade por um de disponibilidade. ultima_versao_publicada() { - local url="${1:-https://github.com/melgarafael/DeskcommCRM.git}" ref + local url="${1:-https://github.com/maugarciasa/DeskcommCRM.git}" ref command -v git >/dev/null 2>&1 || return 0 # `grep -v -- -` descarta PRERELEASE (v1.11.0-rc1, v1.1.1-jmpo.1 — esta última # existe de verdade neste repo). O `--sort=-v:refname` do git põe o prerelease @@ -387,13 +387,13 @@ ultima_versao_publicada() { ghcr_status() { local img="$1" tag="$2" tok tok="$(curl -fsS --max-time 6 \ - "https://ghcr.io/token?scope=repository:melgarafael/${img}:pull&service=ghcr.io" 2>/dev/null \ + "https://ghcr.io/token?scope=repository:maugarciasa/${img}:pull&service=ghcr.io" 2>/dev/null \ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" || true if [ -z "$tok" ]; then printf '000'; return 0; fi curl -s -o /dev/null --max-time 6 -w '%{http_code}' \ -H "Authorization: Bearer $tok" \ -H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json' \ - "https://ghcr.io/v2/melgarafael/${img}/manifests/${tag}" 2>/dev/null || printf '000' + "https://ghcr.io/v2/maugarciasa/${img}/manifests/${tag}" 2>/dev/null || printf '000' } # As TRÊS imagens existem e são públicas nesta referência? diff --git a/hostgator-setup-kit/comecar.sh b/hostgator-setup-kit/comecar.sh index 13bee40a5..3bd565323 100755 --- a/hostgator-setup-kit/comecar.sh +++ b/hostgator-setup-kit/comecar.sh @@ -9,11 +9,11 @@ # # Uso: # bash comecar.sh -# curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/comecar.sh | bash +# curl -fsSL https://raw.githubusercontent.com/maugarciasa/DeskcommCRM/main/hostgator-setup-kit/comecar.sh | bash # set -euo pipefail -REPO_URL="${REPO_URL:-https://github.com/melgarafael/DeskcommCRM.git}" +REPO_URL="${REPO_URL:-https://github.com/maugarciasa/DeskcommCRM.git}" # Link de parceria com a HostGator. Mesma URL e mesmo rótulo do README: uma # promessa só, num lugar só — duas redações da mesma oferta viram duas ofertas. VPS_URL="https://www.hostgator.com.br/52708-141-3-52.html" diff --git a/hostgator-setup-kit/diagnostico.sh b/hostgator-setup-kit/diagnostico.sh index 3bd5e1ba7..869d16212 100755 --- a/hostgator-setup-kit/diagnostico.sh +++ b/hostgator-setup-kit/diagnostico.sh @@ -19,7 +19,7 @@ # depender dele seria diagnosticar o passado com a ferramenta do passado. E # precisa poder ser baixado avulso, sem clonar nada: # -# curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh | bash +# curl -fsSL https://raw.githubusercontent.com/maugarciasa/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh | bash # # ── O que ele pode assumir que existe ──────────────────────────────────────── # Medido numa VPS real: bash 5.1, docker, docker compose, curl, sed/awk/grep. diff --git a/hostgator-setup-kit/install.sh b/hostgator-setup-kit/install.sh index 3cc5ff0eb..f8e0ea653 100755 --- a/hostgator-setup-kit/install.sh +++ b/hostgator-setup-kit/install.sh @@ -15,7 +15,7 @@ set -euo pipefail # de qualquer 'cd' (step 2 pode entrar num repo clonado à parte). KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -REPO_URL="${REPO_URL:-https://github.com/melgarafael/DeskcommCRM.git}" +REPO_URL="${REPO_URL:-https://github.com/maugarciasa/DeskcommCRM.git}" # Uma constante, dois usos (o fim feliz e o fim travado) — e o comecar.sh tem a # gêmea. Link repetido à mão vira link divergente na primeira troca. COMUNIDADE_URL="https://lp-comunidade.automatiklabs.com.br" diff --git a/hostgator-setup-kit/test-validators.sh b/hostgator-setup-kit/test-validators.sh index 867ebddae..4dd050e15 100755 --- a/hostgator-setup-kit/test-validators.sh +++ b/hostgator-setup-kit/test-validators.sh @@ -1470,7 +1470,7 @@ STUB tag_app="${img_app##*:}" for par in "WORKER_IMAGE:deskcomm-worker" "SCHEDULER_IMAGE:deskcomm-scheduler"; do chave="${par%%:*}"; repo="${par##*:}" - if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/melgarafael/${repo}:${tag_app}" ]; then + if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/maugarciasa/${repo}:${tag_app}" ]; then printf ' ✗ %s não acompanha a versão do app (%s): %s\n' "$chave" "$tag_app" \ "$(grep -E "^${chave}=" "$VPS_PROJ/.env" || echo '(ausente)')" printf ' app numa versão e worker em outra é a matriz que ninguém testou.\n'; exit 1 @@ -1629,7 +1629,7 @@ STUB for par in "APP_IMAGE:deskcommcrm" "WORKER_IMAGE:deskcomm-worker" "SCHEDULER_IMAGE:deskcomm-scheduler"; do chave="${par%%:*}"; repo="${par##*:}" - if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/melgarafael/${repo}:1.10.0" ]; then + if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/maugarciasa/${repo}:1.10.0" ]; then printf ' ✗ %s não foi pinado na versão resolvida (1.10.0): %s\n' "$chave" \ "$(grep -E "^${chave}=" "$VPS_PROJ/.env" || echo '(ausente)')" printf ' instalação de cliente NUNCA nasce em tag móvel — docs/doctrine/packaging.md, invariante 3.\n' diff --git a/hostgator-setup-kit/update.sh b/hostgator-setup-kit/update.sh index 79e586f9b..412505106 100755 --- a/hostgator-setup-kit/update.sh +++ b/hostgator-setup-kit/update.sh @@ -51,7 +51,7 @@ CURRENT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)" # (Veio da `main`; a versão por tag cai exatamente na mesma armadilha, porque a # comparação de tags também fica satisfeita com a imagem velha no lugar.) image_desatualizada() { - local img="${APP_IMAGE:-ghcr.io/melgarafael/deskcommcrm:latest}" local_d remote_d + local img="${APP_IMAGE:-ghcr.io/maugarciasa/deskcommcrm:latest}" local_d remote_d local_d="$(docker image inspect "$img" --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' 2>/dev/null | sed 's/.*@//')" [ -z "$local_d" ] && return 0 # nem baixada ainda → atualizar remote_d="$(docker buildx imagetools inspect "$img" 2>/dev/null | awk '/^Digest:/{print $2; exit}')" diff --git a/tests/shell/update-guard.test.sh b/tests/shell/update-guard.test.sh index ffea983e4..bf8380201 100644 --- a/tests/shell/update-guard.test.sh +++ b/tests/shell/update-guard.test.sh @@ -132,7 +132,7 @@ STUB printf 'services:\n app:\n image: \${APP_IMAGE:-x}\n' > "$PROJ/docker-compose.prod.yml" printf 'select 1;\n' > "$PROJ/supabase/baseline.sql" cat > "$PROJ/.env" < nova.txt; git add -A; git commit --quiet -m "v1.1.0"; git tag v1.1.0 git checkout --quiet v0.9.0 run_update --to v1.1.0 check "a atualização termina com sucesso" test "$RC" -eq 0 -check ".env aponta para a imagem da versão instalada" grep -q '^APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.1.0$' .env +check ".env aponta para a imagem da versão instalada" grep -q '^APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.1.0$' .env check "a chave APP_IMAGE não duplicou" test "$(grep -c '^APP_IMAGE=' .env)" -eq 1 run_update --to v1.1.0 --force check "segunda execução também não duplica" test "$(grep -c '^APP_IMAGE=' .env)" -eq 1 @@ -215,9 +215,9 @@ echo "── 4b. As três imagens sobem juntas, na mesma versão" # runtime do agente de IA — ficava congelado no código do dia da instalação. # Se estas três linhas voltarem a divergir, o defeito voltou. check "o worker é pinado na MESMA versão do app" \ - grep -q '^WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:1.1.0$' .env + grep -q '^WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:1.1.0$' .env check "o scheduler é pinado na MESMA versão do app" \ - grep -q '^SCHEDULER_IMAGE=ghcr.io/melgarafael/deskcomm-scheduler:1.1.0$' .env + grep -q '^SCHEDULER_IMAGE=ghcr.io/maugarciasa/deskcomm-scheduler:1.1.0$' .env check "o worker herda a política da tag imutável" \ grep -q '^WORKER_PULL_POLICY=missing$' .env check "o scheduler herda a política da tag imutável" \ @@ -246,7 +246,7 @@ echo topo > topo.txt; git add -A; git commit --quiet -m "main, depois da release clona_raso() { # clona_raso — igual ao install.sh: --depth 1 git clone --depth 1 --quiet "file://$SRC" "$1" cat > "$1/.env" < "$WORK/agente.out" 2>&1 check "o agente chegou a executar o update (o app de mentira pediu)" \ grep -q '"kind":"run_progress"\|"kind":"run_result"' "$CURL_LOG" check "NÃO reiniciou o container" test -z "$(grep -F 'up -d app' "$DOCKER_LOG" || true)" -check "NÃO reescreveu a imagem do .env" grep -q '^APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:latest$' .env +check "NÃO reescreveu a imagem do .env" grep -q '^APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:latest$' .env check "reportou 'failed', não 'failed_rolled_back'" \ test -n "$(grep -F '"status":"failed"' "$CURL_LOG" || true)" check "não reportou rollback nenhum" test -z "$(grep -F 'failed_rolled_back' "$CURL_LOG" || true)" @@ -368,21 +368,21 @@ pin_caso() { # pin_caso check "$d" test "$r" = "$esperado" } pin_caso "app pinado + worker/scheduler AUSENTES → acusa os dois" \ - "APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.3.0" "worker scheduler" + "APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.3.0" "worker scheduler" pin_caso "app pinado + worker em canal móvel → acusa" \ - "APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.3.0 -WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:stable -SCHEDULER_IMAGE=ghcr.io/melgarafael/deskcomm-scheduler:1.3.0" "worker" + "APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.3.0 +WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:stable +SCHEDULER_IMAGE=ghcr.io/maugarciasa/deskcomm-scheduler:1.3.0" "worker" pin_caso "as três na mesma versão → silêncio" \ - "APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.3.0 -WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:1.3.0 -SCHEDULER_IMAGE=ghcr.io/melgarafael/deskcomm-scheduler:1.3.0" "" + "APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.3.0 +WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:1.3.0 +SCHEDULER_IMAGE=ghcr.io/maugarciasa/deskcomm-scheduler:1.3.0" "" pin_caso "app num canal deliberado (:latest) → não é 'metade', silêncio" \ - "APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:latest" "" + "APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:latest" "" pin_caso "valores entre aspas, como o install grava → silêncio" \ - "APP_IMAGE='ghcr.io/melgarafael/deskcommcrm:1.3.0' -WORKER_IMAGE='ghcr.io/melgarafael/deskcomm-worker:1.3.0' -SCHEDULER_IMAGE='ghcr.io/melgarafael/deskcomm-scheduler:1.3.0'" "" + "APP_IMAGE='ghcr.io/maugarciasa/deskcommcrm:1.3.0' +WORKER_IMAGE='ghcr.io/maugarciasa/deskcomm-worker:1.3.0' +SCHEDULER_IMAGE='ghcr.io/maugarciasa/deskcomm-scheduler:1.3.0'" "" rm -f "$PROJ/.env.pin" @@ -401,7 +401,7 @@ cat > "$PIN_DIR/bin/docker" <<'STUBDOCKER' #!/usr/bin/env bash # inspect de contêiner → devolve o nome da imagem; de imagem → devolve a versão case "$*" in - *"Config.Image"*) printf 'ghcr.io/melgarafael/deskcomm-worker:stable + *"Config.Image"*) printf 'ghcr.io/maugarciasa/deskcomm-worker:stable ' ;; *"image.version"*) printf '%s ' "${DUBLE_VERSION:-1.3.0}" ;; @@ -417,10 +417,10 @@ autopin() { # autopin → ecoa o que a função corrigiu ". '$KIT_DIR_TESTE/_common.sh'; completar_pin_ausente .env" 2>/dev/null ) || true } -R="$(autopin "APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.3.0")" +R="$(autopin "APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.3.0")" check "chave AUSENTE → preenche os dois" test "$R" = "worker scheduler" check " e grava a versão da imagem em execução, não um canal" \ - grep -q "^WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:1.3.0$" "$PIN_DIR/.env" + grep -q "^WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:1.3.0$" "$PIN_DIR/.env" check " com pull_policy de tag imutável" \ grep -q "^WORKER_PULL_POLICY=missing$" "$PIN_DIR/.env" @@ -432,21 +432,21 @@ check " e não altera um byte do .env" test "$ANTES_MD5" = "$(md5sum "$PIN_DIR/ # A REGRA QUE PROTEGE O OPERADOR. Se esta cair, o cron passa a sobrescrever # escolha explícita — e a decisão de implementar a autocorreção deixa de valer. -R="$(autopin "APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.3.0 -WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:stable -SCHEDULER_IMAGE=ghcr.io/melgarafael/deskcomm-scheduler:stable")" +R="$(autopin "APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.3.0 +WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:stable +SCHEDULER_IMAGE=ghcr.io/maugarciasa/deskcomm-scheduler:stable")" check "canal móvel EXPLÍCITO → não toca (é decisão de quem opera)" test -z "$R" check " o :stable escolhido continua lá, intacto" \ - grep -q "^WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:stable$" "$PIN_DIR/.env" + grep -q "^WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:stable$" "$PIN_DIR/.env" -R="$(autopin "APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.3.0 -WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:1.3.0 -SCHEDULER_IMAGE=ghcr.io/melgarafael/deskcomm-scheduler:1.3.0")" +R="$(autopin "APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.3.0 +WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:1.3.0 +SCHEDULER_IMAGE=ghcr.io/maugarciasa/deskcomm-scheduler:1.3.0")" check "já pinada → silêncio" test -z "$R" # Imagem sem o label (build local): não há versão para gravar, e inventar uma # seria pior que não fazer nada. -R="$( printf 'APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:1.3.0\n' > "$PIN_DIR/.env" +R="$( printf 'APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:1.3.0\n' > "$PIN_DIR/.env" cd "$PIN_DIR" && PATH="$PIN_DIR/bin:$PATH" DUBLE_VERSION="" bash -c \ ". '$KIT_DIR_TESTE/_common.sh'; completar_pin_ausente .env" 2>/dev/null || true )" check "imagem sem label de versão → não inventa pin" test -z "$R" From 7a3ab82e6d8051ff5debbda3fcbbb9942eca75ee Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 00:41:34 -0300 Subject: [PATCH 07/33] =?UTF-8?q?test:=20confere=20disparo=20autom=C3=A1ti?= =?UTF-8?q?co=20do=20Actions=20ap=C3=B3s=20habilitar=20workflows=20no=20fo?= =?UTF-8?q?rk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 70009bad27d0e7477a2fc4cef1b0c8be09d2b6ff Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 01:05:55 -0300 Subject: [PATCH 08/33] fix(seguranca): fecha RLS de papel em contacts e RBAC de 3 rotas de canal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois achados de auditoria, mesma causa raiz: verificação de papel que faltava numa camada, com a irmã correta do mesmo domínio provando qual era o piso certo. - contacts: única policy sempre foi tenant_isolation_contacts_all (FOR ALL, só fn_user_org_ids()), com GRANT ALL a authenticated. Qualquer membro do tenant, inclusive viewer (somente-leitura por doutrina), lia/gravava/apagava contato direto pelo PostgREST, sem passar por requireRole. Mesma classe de falha que a migration 0150 já corrigiu para canais/config de IA e deixou o resto do schema para depois. Migration 0169 aplica o mesmo par (SELECT só-tenancy + escrita com fn_role_at_least('agent')), o piso que app/api/v1/contacts/route.ts já exige no POST/PATCH/DELETE. - channels/partner/templates (POST), .../templates/media (POST) e onboarding/whatsapp/session (POST) resolviam org sem checar papel — viewer conseguia criar/sincronizar template aprovado pela Meta, subir imagem de cabeçalho e reiniciar (?restart=1 derruba antes) a sessão oficial de WhatsApp do tenant. As rotas irmãs (channels/templates, channels/official) sempre exigiram admin. Gate com requireRole("admin") nas 3, em vez de reimplementar a checagem inline (anti-padrão "matriz advisória" que a doutrina de lib/auth/require-role.ts proíbe). Verificado: pnpm test:db (112 arquivos, 845 testes, install+update do baseline sem erro) e pnpm test:unit (5221/5222 — a 1 falha é o flake conhecido de lib/ui/icons.test.ts, documentado no próprio vitest.config.ts, não relacionado). typecheck e lint limpos. Co-Authored-By: Claude Sonnet 5 --- .../channels/partner/templates/media/route.ts | 11 +-- .../v1/channels/partner/templates/route.ts | 7 ++ .../v1/onboarding/whatsapp/session/route.ts | 12 ++- supabase/baseline.sql | 32 +++++++ ...23000000_0169_rls_de_papel_em_contacts.sql | 50 +++++++++++ supabase/migrations/MANIFEST.md | 1 + .../invariants/rbac-config-ia-canais.test.ts | 2 +- .../rls-de-papel-em-contacts.test.ts | 83 ++++++++++++++++++ .../rbac-canais-parceiro-e-onboarding.test.ts | 86 +++++++++++++++++++ 9 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 supabase/migrations/20260823000000_0169_rls_de_papel_em_contacts.sql create mode 100644 tests/invariants/rls-de-papel-em-contacts.test.ts create mode 100644 tests/unit/rbac-canais-parceiro-e-onboarding.test.ts diff --git a/app/api/v1/channels/partner/templates/media/route.ts b/app/api/v1/channels/partner/templates/media/route.ts index 4aaaaaf17..4c0c47c23 100644 --- a/app/api/v1/channels/partner/templates/media/route.ts +++ b/app/api/v1/channels/partner/templates/media/route.ts @@ -26,7 +26,7 @@ import { randomUUID } from "node:crypto"; import type { NextRequest } from "next/server"; import { fail, ok } from "@/lib/api/wrappers"; -import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server"; +import { requireRole } from "@/lib/auth/require-role"; import { logger } from "@/lib/logger"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -48,10 +48,11 @@ const TAMANHO_MAX = 5 * 1024 * 1024; export async function POST(req: NextRequest): Promise { const requestId = randomUUID(); - const user = await loadAuthUser(); - if (!user) return fail("unauthenticated", "Faça login.", 401, { requestId }); - const org = await resolveActiveOrg(user); - if (!org) return fail("forbidden", "Sem organização ativa.", 403, { requestId }); + // Sobe pro storage o cabeçalho de uma definição aprovada pela Meta — mesmo + // piso de "channels/templates" e "channels/partner/templates" (admin). + const authz = await requireRole("admin", { requestId, resource: "channel_partner_templates_media" }); + if (!authz.ok) return authz.response; + const org = authz.org; const form = await req.formData().catch(() => null); const file = form?.get("file"); diff --git a/app/api/v1/channels/partner/templates/route.ts b/app/api/v1/channels/partner/templates/route.ts index 836607271..7949c64e0 100644 --- a/app/api/v1/channels/partner/templates/route.ts +++ b/app/api/v1/channels/partner/templates/route.ts @@ -29,6 +29,7 @@ import type { NextRequest } from "next/server"; import { fail, ok } from "@/lib/api/wrappers"; import { audit } from "@/lib/audit"; import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server"; +import { requireRole } from "@/lib/auth/require-role"; import { CHANNEL_SESSION_REF_COLUMNS, DEFAULT_CHANNEL_PROVIDER, @@ -138,6 +139,12 @@ export async function GET(): Promise { */ export async function POST(req: NextRequest): Promise { const requestId = randomUUID(); + // Cria/sincroniza definição aprovada pela Meta e pode derrubar a sessão de + // WhatsApp indiretamente (uploads e mensagens dependem da mesma conexão) — + // mesmo piso de "channels/templates" e "channels/official" (admin). + const authz = await requireRole("admin", { requestId, resource: "channel_partner_templates" }); + if (!authz.ok) return authz.response; + const r = await contexto(requestId); if (!r.ok) return r.res; diff --git a/app/api/v1/onboarding/whatsapp/session/route.ts b/app/api/v1/onboarding/whatsapp/session/route.ts index b5991fdfe..9a97603a0 100644 --- a/app/api/v1/onboarding/whatsapp/session/route.ts +++ b/app/api/v1/onboarding/whatsapp/session/route.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import { ok, fail } from "@/lib/api/wrappers"; import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server"; +import { requireRole } from "@/lib/auth/require-role"; import { ARCHIVED_AT, queryTolerantToMissingArchived } from "@/lib/channels/archived"; import { CHANNEL_PROVIDER_WAHA } from "@/lib/channels/capabilities"; import { @@ -132,10 +133,13 @@ export async function GET() { export async function POST(req: Request) { const requestId = randomUUID(); - const user = await loadAuthUser(); - if (!user) return fail("unauthenticated", "Sessão expirada", 401); - const activeOrg = await resolveActiveOrg(user); - if (!activeOrg) return fail("tenant_not_found", "Sem organização ativa", 404); + // Inicia/reinicia a sessão oficial de WhatsApp do tenant — `?restart=1` + // derruba a sessão antes de reabrir. Mesmo piso de "channels/official" + // (admin): reconectar o canal não é operação de agente. + const authz = await requireRole("admin", { requestId, resource: "onboarding_whatsapp_session" }); + if (!authz.ok) return authz.response; + const user = authz.user; + const activeOrg = authz.org; const waha = getWahaClient(); if (!waha) return fail("waha_not_configured", "Suba o Docker (docker compose up -d waha) e tente novamente.", 503); const sessionName = defaultSessionName(activeOrg.orgId); diff --git a/supabase/baseline.sql b/supabase/baseline.sql index e57d84281..2ddec70de 100644 --- a/supabase/baseline.sql +++ b/supabase/baseline.sql @@ -13434,6 +13434,38 @@ grant execute on function public.fn_publish_rag_bot_version(uuid, uuid, uuid) to notify pgrst, 'reload schema'; +-- ---- RLS de papel em contacts (migration 0169) ---- +-- +-- `contacts` é o dado mais crítico do produto e sua única policy sempre foi +-- tenancy-only, com GRANT ALL a authenticated — qualquer membro do tenant, +-- inclusive viewer, lia/gravava/apagava contato direto pelo PostgREST. Mesmo +-- par da 0150 (SELECT só-tenancy + escrita com fn_role_at_least), piso +-- 'agent' porque é o que app/api/v1/contacts/route.ts e [id]/route.ts já +-- exigem no POST/PATCH/DELETE. Ver comentário completo na migration 0169. + +drop policy if exists "tenant_isolation_contacts_all" on public.contacts; + +drop policy if exists "contacts_select" on public.contacts; +create policy "contacts_select" on public.contacts + for select using ( + organization_id in (select public.fn_user_org_ids()) + or public.fn_is_platform_admin() + ); + +drop policy if exists "contacts_write" on public.contacts; +create policy "contacts_write" on public.contacts + for all using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ) with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ); + +notify pgrst, 'reload schema'; + -- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ---- -- -- ⚠️ ESTE BLOCO É, DE PROPÓSITO, O ÚLTIMO DO ARQUIVO. Apêndice novo entra ANTES diff --git a/supabase/migrations/20260823000000_0169_rls_de_papel_em_contacts.sql b/supabase/migrations/20260823000000_0169_rls_de_papel_em_contacts.sql new file mode 100644 index 000000000..139c7c03a --- /dev/null +++ b/supabase/migrations/20260823000000_0169_rls_de_papel_em_contacts.sql @@ -0,0 +1,50 @@ +-- 0169: RLS de contacts isolava por tenant mas não checava PAPEL +-- +-- Mesma classe de falha que a 0150 já corrigiu para canais/config de IA +-- (achado do relatório de segurança da comunidade; a 0150 deferiu de +-- propósito o resto do schema, "para depois"). `contacts` é o dado mais +-- crítico do produto — nome, telefone, e-mail — e sua única policy sempre +-- foi `tenant_isolation_contacts_all` (FOR ALL, só `fn_user_org_ids()`), com +-- `GRANT ALL ON TABLE public.contacts TO authenticated`. Qualquer membro do +-- tenant, inclusive `viewer` (documentado como somente-leitura em spec 13 +-- §4), podia ler/gravar/apagar contato de qualquer outro contato do mesmo +-- tenant direto pelo PostgREST, com a própria sessão — sem passar por +-- `requireRole`. +-- +-- FORMA: o mesmo par da 0150 — SELECT só-tenancy (todo membro continua +-- LENDO, senão a tela quebra para o viewer) + escrita (insert/update/delete) +-- com `fn_role_at_least(organization_id, 'agent')`, o mesmo piso que +-- `app/api/v1/contacts/route.ts` e `[id]/route.ts` já exigem no POST/PATCH/ +-- DELETE ("spec 13 §4: escrita é agent+, viewer é read-only"). A policy +-- passa a espelhar a API, em vez de ficar um nível mais frouxa que ela. +-- +-- Preserva o acesso de platform admin (`fn_is_platform_admin()`), do jeito +-- que a policy antiga também garantia via `OR fn_is_platform_admin()` — sem +-- isso o suporte de plataforma perderia acesso a contato de qualquer tenant. +-- +-- O worker não entra nesta conta: usa `service_role`, que é `bypassrls`. + +drop policy if exists "tenant_isolation_contacts_all" on public.contacts; + +drop policy if exists "contacts_select" on public.contacts; +create policy "contacts_select" on public.contacts + for select using ( + organization_id in (select public.fn_user_org_ids()) + or public.fn_is_platform_admin() + ); + +drop policy if exists "contacts_write" on public.contacts; +create policy "contacts_write" on public.contacts + for all using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ) with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ); + +-- O PostgREST guarda o schema em cache; sem isto as policies novas só valem +-- no próximo reload dele. +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/MANIFEST.md b/supabase/migrations/MANIFEST.md index 960901af3..98d2ae42d 100644 --- a/supabase/migrations/MANIFEST.md +++ b/supabase/migrations/MANIFEST.md @@ -202,6 +202,7 @@ aplica. | `20260820030000` | `0164_atribuicao_de_anuncio` | **De qual anúncio um contato do WhatsApp veio.** `contacts.source`/`source_metadata` já existiam (nasceram genéricos, `'whatsapp'`); esta migration não cria coluna, só `fn_estampar_atribuicao_de_anuncio(p_contact, p_platform, p_metadata)` — chamada pelo ingest de canal (WAHA e o canal oficial) quando a primeira mensagem de um contato novo traz dados de um clique em anúncio "Clique para o WhatsApp" da Meta. **Função, não UPDATE direto do aplicativo:** o merge de `source_metadata` (`||`, preserva `waha_lid`/`waha_chat_id`/`notify_name` que `fn_upsert_wa_contact` já gravou) e a guarda de PRIMEIRO TOQUE (`source_metadata->>'ad_platform' is null`) precisam ser UMA operação atômica — duas mensagens quase simultâneas do mesmo contato novo não podem correr a corrida de ler+mesclar+escrever em JS. **Primeiro toque, nunca sobrescreve:** clicar em outro anúncio meses depois, numa conversa já aberta, não reescreve de onde a pessoa veio ORIGINALMENTE — o UPDATE casa zero linhas, silenciosamente, quando já há `ad_platform` gravado. **Os dois revokes do item 9 do CLAUDE.md** (`from public, anon, authenticated` + `grant to service_role`): só o backend chama isto (admin client no ingest), nenhum papel de sessão precisa. Idempotente (`create or replace`) e sem backfill: não altera dado existente. | | `20260820120000` | `0166_indice_do_claim_da_fila` | **O cap global do claim varria `job_queue` inteira a cada rodada.** `claimJobs` (`lib/agent-engine/queue/queue.ts`) abre toda rodada — dentro do advisory lock que serializa os claimers — com `select count(*) from job_queue where status = 'running'`, e nenhum dos quatro índices da tabela servia esse predicado. O parcial das lanes (`uniq_job_queue_one_running_per_contact`) chega perto e **não vale**: o predicado dele é mais ESTREITO (exclui `contact_id is null`, que é todo `watchdog`/`flywheel`), então o planejador não pode responder por ele. Medido em `pgvector/pgvector:pg17` com este baseline, 50.000 linhas `done` + 4 `running`: **Seq Scan / 715 buffers → Index Only Scan / 3 buffers**, índice de **16 kB**. **O custo não depende de linha viva, depende de bloat:** apagando as 50.004 dentro de uma transação e perguntando de novo, o Seq Scan ainda lê os **mesmos 715 buffers** — ele visita PÁGINA, não tupla —, e nada no produto poda `job_queue`. **O `/healthz` do worker NÃO é consertado por isto, e foi medido, não suposto:** `workers/agent-worker/main.ts` e `lib/agent-engine/obs/metrics.ts` fazem `select status, count(*) from job_queue group by status`, que precisa de TODOS os status; com o índice novo o plano continua HashAggregate sobre Seq Scan, 715 buffers, idêntico ao de antes — e um índice CHEIO em `(status)` também não move (medido: o planejador segue escolhendo Seq Scan, e o índice custaria 360 kB). Fica fora do escopo: aquilo roda em probe do Docker a cada 30s, não no caminho quente do claim. **O bloco `do $$` não é cerimônia:** `create index if not exists` casa por NOME e não por definição, e as duas variantes foram medidas em pg17 — homônimo na própria `job_queue` com outra definição vira `NOTICE: ... already exists, skipping` (NOTICE nem chega ao filtro `ERROR\|FATAL` do `update.sh`: no-op perfeitamente silencioso), e homônimo em OUTRA tabela (nome de índice é único por SCHEMA) dá o mesmo no-op **e** faz o `comment on index` acertar o índice errado, cegando o delator. O bloco derruba e recria o homônimo NOSSO em `job_queue`; o de outro objeto ele **não apaga** — `raise exception` com a razão escrita, que é o comportamento certo num script que roda sem `ON_ERROR_STOP` (o erro aparece ao operador e o resto do baseline segue). **O `comment on index` é DELATOR:** índice ausente levanta `relation "idx_job_queue_running" does not exist`, texto conferido contra a lista benigna real do `update.sh` (`already exists\|multiple primary keys\|multiple default values\|is already a member\|already a partition`) — não casa com nenhum termo, logo chega ao cliente; um `already exists` seria engolido. Guardado por `tests/invariants/queue-cap-global-do-claim.test.ts`, que **extrai o `select` do FONTE de produção** em vez de copiá-lo: índice presente e predicado mudado (`::text` a mais, `lower(status)`, `status in (...)`) devolveria Seq Scan com o símbolo intacto. Aditiva e idempotente: sem constraint, sem backfill, sem dado tocado. | | `20260820160000` | `0165_identificador_de_canal_unico_entre_ativos` | **Os dois identificadores de canal que chegaram depois do snapshot não tinham trava nenhuma, e é por eles que o código resolve credencial de envio e o DONO de uma mensagem que acabou de entrar.** `waha_session_name` e `webhook_path_token` são UNIQUE desde o snapshot; `meta_phone_number_id` (0087) e `zernio_account_id` (0131) nasceram sem. Traz **dois índices únicos PARCIAIS** (`where archived_at is null`), pelo precedente da 0107 — canal arquivado é canal excluído e a linha só sobrevive como âncora das FKs RESTRICT, então trava total impediria reconectar o mesmo número depois de excluí-lo. **O que a ausência produzia (issue #236):** três consultas de `lib/channels/` resolviam a sessão só pelo identificador, em client de service role (bypassa RLS). Com duas linhas casando, `maybeSingle()` **não** devolve "a primeira" — medido contra `@supabase/postgrest-js` 2.112.1, devolve `data: null` + `error PGRST116` (HTTP 406). Os três **descartavam o `error`**, então: os dois resolvedores de credencial caíam no fallback do `.env` (a mensagem saía pela conta de OUTRA instalação) e `meta/ingest.ts` devolvia `no_session` com a rota respondendo 200 — a mensagem recebida era **descartada para as duas organizações**. **Classificação: alta, não crítica** — a colisão é atingível por configuração LEGÍTIMA (agência, migração de conta entre organizações), não por reivindicação hostil: `validatePartnerCredentials` (`lib/channels/connect.ts`) exige que o identificador esteja na lista de contas que a chave informada alcança. **A deduplicação vem ANTES da trava e não é destrutiva:** o `update.sh` do clone roda sem `ON_ERROR_STOP` e engoliria o 23505, deixando o clone sem trava e sem aviso. Apagar está fora (sessão de cliente, histórico por FK RESTRICT) e arquivar faria o canal sumir da tela sem ninguém pedir — a perdedora é **RENOMEADA** para `-conflito-`: continua visível, e como o identificador não existe no provider a varredura de saúde (`app/api/v1/cron/channel-health`) grava `FAILED`/`STOPPED` na passada seguinte e **abre aviso na Central** (os três estados estão em `STATUS_QUE_AVISAM`). Fica com o identificador a sessão ativa **mais recente** (criá-la exigiu provar posse da conta na tela de conexão ⇒ é a intenção mais recente); errar o palpite não destrói nada e a linha perdedora ela mesma avisa. **Idempotente por construção:** o sufixo carrega o `id` (único), então a segunda passada casa zero linhas e não há como sufixar duas vezes. Nomes de índice novos (conferidos contra `baseline.sql` e `migrations/`), então o `if not exists` — que casa por NOME — não vira no-op em cima de um homônimo com outra definição. O código foi corrigido nas três camadas junto: filtro de `organization_id` nos três sítios (com o `organizationId` atravessando o seam de canal como campo **obrigatório**, para o typecheck cobrar), `error` que deixa de ser descartado, e o invariante `tests/unit/canal-consulta-por-organizacao.test.ts`, que varre `lib/channels/` derivando as colunas de `CHANNEL_SESSION_REF_COLUMNS` — o quarto canal entra na varredura sozinho. | +| `20260823000000` | `0169_rls_de_papel_em_contacts` | **`contacts` tinha RLS de tenant e nenhuma checagem de PAPEL** — achado CRÍTICO de auditoria: única policy era `tenant_isolation_contacts_all` (FOR ALL, só `fn_user_org_ids()`), com `GRANT ALL ... TO authenticated`; qualquer membro do tenant, inclusive `viewer` (somente-leitura por doutrina, spec 13 §4), lia/gravava/apagava qualquer contato direto pelo PostgREST, sem passar por `requireRole`. Mesma classe de falha que a 0150 já corrigiu para canais/config de IA — aquela migration deferiu o resto do schema "para depois"; esta fecha `contacts`, o dado mais crítico do produto (nome, telefone, e-mail). **Mesmo par de policies da 0150**: `contacts_select` (só tenancy — todo membro continua LENDO, senão a tela quebra para o viewer) + `contacts_write` (FOR ALL, `fn_role_at_least(organization_id, 'agent')`), piso que espelha o que `app/api/v1/contacts/route.ts`/`[id]/route.ts` já exigem no POST/PATCH/DELETE. Preserva `fn_is_platform_admin()` dos dois lados, como a policy antiga garantia. Sem GRANT novo (a restrição é por RLS, não por privilégio de tabela — mesmo desenho de `channel_sessions`/`ai_agents` na 0150). Aditiva e idempotente (`drop policy if exists` + `create policy`); sem constraint nova, sem backfill. **As ~29 tabelas de infraestrutura do agente da migration 0050** (`job_queue`, `send_ledger`, `metrics` etc.) e `messages` continuam com o mesmo gap — ficam para uma migration seguinte, de propósito, pelo mesmo motivo que a 0150 deferiu: são escritas pelo motor/workers via `service_role`, e apertar tudo no mesmo fôlego trocaria risco de segurança por risco de parada de produção sem o mesmo nível de verificação por tabela. **NÃO MEDIDO nesta sessão:** `pnpm test:db` contra um Postgres efêmero (Docker não disponível) — a policy foi verificada por leitura direta do `baseline.sql` e comparação byte-a-byte com o padrão já provado da 0150, não por execução. Rode `pnpm test:db` antes do merge para confirmar que nenhuma suíte que dependia do comportamento antigo (ex.: viewer lendo contact em teste de fixture) quebra, e que `viewer` de fato apanha 403/RLS ao tentar escrever. | | `20260822180000` | `0168_fn_publish_rag_bot_version` | **Editar o prompt de um agente `rag_bot` pela tela não tinha efeito nenhum, em silêncio.** `components/ai/AgentEditor.tsx` ("caminho legado pré-EPIC-13", `app/app/ai/agents/[id]/page.tsx:73`) só grava o rascunho em `ai_agents`; o runtime (`lib/agent-engine/agent/agent-config.ts`) lê `system_prompt`/`provider`/`model` da versão apontada por `ai_agents.published_version_id`, nunca do rascunho — o agente segue respondendo com o que foi publicado no bootstrap inicial, e nenhum erro aparece em lugar nenhum. Achado numa instalação real: o prompt genérico do seed ("Você é um(a) atendente virtual amigável de uma loja online...") continuava ativo depois de o operador editar e salvar várias vezes pela tela. **`fn_publish_ai_agent_version` (0024/0025/0026) não serve pra este caminho:** exige `credential_id` não-nulo na versão e canal `channel_sessions.status = 'WORKING'` — nenhum dos dois é como `rag_bot` resolve credencial (por organização+provider em tempo de chamada, sem vínculo de versão) nem como ele publica (não trava em canal offline). `fn_publish_rag_bot_version` é o par mínimo: copia os campos de infraestrutura da versão publicada atual (canal, ferramentas, orçamento — nenhum editável pelo `AgentEditor.tsx`) e troca só `system_prompt`/`provider`/`model`, vindos do rascunho. **`provider` nunca é hardcoded** — resolve de `organizations.settings.llm.provider` (o mesmo campo que `scripts/bootstrap-owner.ts` grava a partir do `AI_PROVIDER` do instalador); um segundo bug medido na mesma triagem foi um agente seedado direto com `provider='anthropic'` apesar da organização ter escolhido OpenRouter na instalação — `LlmNotConfiguredError` em todo turno, calado, porque a chave que existia (`OPENROUTER_API_KEY`) nunca é fallback de `provider='anthropic'`. Mesmo piso de sanidade da 0024/0025/0026: recusa publicar modelo que `ai_models` não conhece (`model_not_found`) ou que já foi `deprecated_at`. Exige versão publicada existente (`no_existing_version`) — o bootstrap sempre cria a v1; sem isso não haveria de onde copiar canal/ferramentas, e inventar esses valores aqui seria pior que recusar. Os dois revokes do item 9 do CLAUDE.md (`from public, anon, authenticated` + `grant to service_role`): só o backend chama isto (`createAdminClient()` na rota `POST /api/v1/ai/agents/:id/publish-rag-bot`), nenhum papel de sessão precisa. Botão "Publicar" novo no `AgentEditor.tsx`, desabilitado enquanto o rascunho tem alteração não salva (publicar sempre o que está persistido, nunca o que está só na tela). **Verificado:** `install`+`update` do `baseline.sql` num pg17 efêmero (com o prelude de stubs do `scripts/test-db.sh`), caminho feliz e os quatro erros (`agent_kind_invalid`, `agent_not_found`, `no_existing_version`, `agent_archived`) testados manualmente contra fixtures sintéticas, e `has_function_privilege` confirmando `anon`/`authenticated` bloqueados e só `service_role` liberado. **NÃO MEDIDO:** `pnpm test:db` (a suíte de invariantes real, com Postgres + vitest orquestrados) e `pnpm test:e2e` — só a verificação manual acima; e não há teste automatizado cobrindo o botão "Publicar" na tela (só o wrapper `lib/ai/agents/publish-rag-bot.test.ts`, que cobre mapeamento de erro, não a UI). | | `20260820170000` | `0167_poda_da_fila_e_expurgo_do_audit` | **Nada no produto apagava job terminal, e a retenção de 5 anos do audit existia só no COMMENT.** `grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete` devolvia **zero linhas**: `job_queue` crescia desde a instalação e nunca encolhia; `api_audit_log` prometia 5 anos + "hot 90 dias / cold S3" em seis documentos, sem uma linha de código que executasse qualquer das duas metades. São as candidatas naturais a estourar os **500 MB** do plano free antes de qualquer tabela de negócio — e o bloat também custa CPU (715 buffers varridos no `count(*)` do claim com ZERO linhas vivas, medido na #260). Traz **duas `security definer`** (`fn_podar_fila_de_jobs`, `fn_expurgar_auditoria_vencida`), **três índices** e o cron `data-retention` (diário). **DELETE por idade e não particionamento**: particionar `job_queue` exigiria mexer no claim `FOR UPDATE SKIP LOCKED` e nos dois índices ÚNICOS parciais que garantem um turno por lead — trocar essa garantia por disco é péssimo negócio. **O QUE TEM DONO NÃO SAI, e são três cortes:** `pending`/`running` nunca saem (o primeiro ainda vai sair, o segundo está com um worker e o reaper o devolve); terminais são só `done`/`failed`/`dead` (conferidos em `lib/agent-engine/queue/queue.ts`); e **`dead` com aviso ABERTO na Central tem dono** — um humano que não olhou — com o `not exists` **antes** do `limit`, porque filtrar depois faria um lote de protegidos devolver 0, o laço do cron pararia achando que acabou e a poda morreria de fome com backlog na frente. **Cascata declarada:** o DELETE leva junto `send_ledger` e `before_send_traces` (FK `on delete cascade`, as duas também sem poda) e apenas anula o ponteiro em `llm_calls`/`lead_checkpoints`/`lead_state_transitions`; os dois consumidores de `send_ledger` sem janela (`countPriorAcceptedSends` → disclosure de IA, e o gate LGPD de 1º toque de prospecção, `before-send.ts:261`) falham **fechado** — disclosure a mais e veto a mais, nunca a menos —, daí piso de 7 dias e default de 90. **A definer do audit não é porta de adulteração, e cada razão é conferível:** (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`, então ela só sabe apagar pela ponta mais velha; (b) o **piso de 90 dias mora no corpo**, não em quem chama, então nem com a service key se remove rastro recente; (c) revogada das duas origens de EXECUTE e concedida só a `service_role`; (d) não amplia o raio de quem já tem a chave (`service_role` já tem `TRUNCATE` na mesma tabela); (e) **registra a própria erosão** — o cron grava `retention.sweep_run` com a contagem, e essa linha é nova demais para a chamada seguinte alcançar. `api_audit_log` **não tinha** GRANT de DELETE para ninguém (nem para `service_role`), e é por isso que o expurgo não podia sair pelo admin client. **Hot/cold em S3 não foi entregue e a doutrina do `CLAUDE.md` foi corrigida** em vez de fingir: o self-host não tem para onde arquivar (o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`). Índices com **nome próprio da poda** (`idx_audit_expurgo_created_at`) porque `create index if not exists` casa por NOME e um nome genérico viraria no-op silencioso num clone. Aditiva, sem constraint nova (nada a deduplicar antes), idempotente por `create or replace` + `if not exists` + `revoke`. **NÃO MEDIDO:** o comportamento sob milhões de linhas reais — os lotes foram exercitados no Postgres efêmero do `test:db`, não numa VPS com histórico de anos. | diff --git a/tests/invariants/rbac-config-ia-canais.test.ts b/tests/invariants/rbac-config-ia-canais.test.ts index 732639bce..fdf6ee54c 100644 --- a/tests/invariants/rbac-config-ia-canais.test.ts +++ b/tests/invariants/rbac-config-ia-canais.test.ts @@ -182,7 +182,7 @@ const DIVIDA_RBAC_CONHECIDA = new Set([ "agent_cases", "agent_inbox_items", "ai_agent_runs", "ai_chunks", "ai_faq_items", "ai_invocations", "ai_knowledge_sources", "ai_knowledge_versions", "ai_router_decisions", "before_send_traces", "channel_knobs", "channel_session_health", "channel_session_warmup", - "contact_field_proposals", "contacts", "crm_lead_reactivations", "crm_lead_risk_states", + "contact_field_proposals", "crm_lead_reactivations", "crm_lead_risk_states", "crm_lead_scores", "cron_jobs", "demanda_conversas", "demandas", "disclosure_template_pointers", "disclosure_template_versions", "flywheel_distiller_proposals", "flywheel_judge_verdicts", "followup_enrollment_events", diff --git a/tests/invariants/rls-de-papel-em-contacts.test.ts b/tests/invariants/rls-de-papel-em-contacts.test.ts new file mode 100644 index 000000000..df48ca153 --- /dev/null +++ b/tests/invariants/rls-de-papel-em-contacts.test.ts @@ -0,0 +1,83 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import { + GOV_ADMIN, + GOV_AGENT_A, + GOV_CONTACT_PROBE, + GOV_ORG, + GOV_VIEWER, + countAs, + seedGov, + writeCountAs, +} from "./gov-helpers"; + +/** + * Migration 0169 — `contacts` tinha RLS de tenant e nenhuma checagem de PAPEL. + * + * Mesma classe de falha que a 0150 já corrigiu para canais/config de IA + * (tests/invariants/rbac-config-ia-canais.test.ts). `contacts` é o dado mais + * crítico do produto: antes desta migration, `viewer` (somente-leitura por + * doutrina, spec 13 §4) lia/gravava/apagava qualquer contato do tenant direto + * pelo PostgREST, sem passar por `requireRole`. + * + * Cada caso vem em par: o papel de baixo é barrado E o papel de cima passa. + */ + +beforeAll(() => { + seedGov(); +}); + +describe("0169 — escrita de contacts exige agent+", () => { + it("viewer NÃO altera o nome de um contato", () => { + expect( + writeCountAs( + GOV_VIEWER, + `update public.contacts set display_name = 'SEQUESTRADO' where id = '${GOV_CONTACT_PROBE}'`, + ), + ).toBe(0); + }); + + it("viewer NÃO apaga um contato", () => { + expect( + writeCountAs(GOV_VIEWER, `delete from public.contacts where id = '${GOV_CONTACT_PROBE}'`), + ).toBe(0); + }); + + it("viewer NÃO cria contato novo", () => { + expect( + writeCountAs( + GOV_VIEWER, + `insert into public.contacts (organization_id, display_name) + values ('${GOV_ORG}', 'Criado pelo viewer')`, + ), + ).toBe(0); + }); + + it("CONTROLE POSITIVO: agent altera o nome de um contato", () => { + expect( + writeCountAs( + GOV_AGENT_A, + `update public.contacts set display_name = 'RENOMEADO PELO AGENT' where id = '${GOV_CONTACT_PROBE}'`, + ), + ).toBe(1); + }); + + it("CONTROLE POSITIVO: admin apaga e recria o contato de teste", () => { + expect( + writeCountAs(GOV_ADMIN, `delete from public.contacts where id = '${GOV_CONTACT_PROBE}'`), + ).toBe(1); + expect( + writeCountAs( + GOV_ADMIN, + `insert into public.contacts (id, organization_id, display_name) + values ('${GOV_CONTACT_PROBE}', '${GOV_ORG}', 'Gov Invariant Contact Probe')`, + ), + ).toBe(1); + }); + + it("CONTROLE POSITIVO: viewer continua LENDO contatos (senão a tela quebra)", () => { + expect( + countAs(GOV_VIEWER, `select count(*) from public.contacts where id = '${GOV_CONTACT_PROBE}';`), + ).toBe(1); + }); +}); diff --git a/tests/unit/rbac-canais-parceiro-e-onboarding.test.ts b/tests/unit/rbac-canais-parceiro-e-onboarding.test.ts new file mode 100644 index 000000000..74ae26adc --- /dev/null +++ b/tests/unit/rbac-canais-parceiro-e-onboarding.test.ts @@ -0,0 +1,86 @@ +/** + * 3 rotas de canal/onboarding sem gate de papel — achado ALTO de auditoria. + * + * `channels/partner/templates` (criar/sincronizar template aprovado pela + * Meta), `.../templates/media` (subir cabeçalho de template) e + * `onboarding/whatsapp/session` (POST reinicia a sessão oficial de WhatsApp, + * `?restart=1` derruba antes de reabrir) resolviam org sem checar papel — + * qualquer membro do tenant, inclusive `viewer`, chamava. Rotas irmãs do + * mesmo domínio (`channels/templates`, `channels/official`) sempre exigiram + * `admin`. O que se prova aqui: o gate barra ANTES de qualquer efeito + * (nenhum client admin, adapter ou transporte WAHA é acionado). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { fail } from "@/lib/api/wrappers"; +import { requireRole } from "@/lib/auth/require-role"; +import { getAdapter } from "@/lib/channels"; +import { findPartnerSession } from "@/lib/channels/connect"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { getWahaClient } from "@/lib/waha/client"; + +vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() })); +vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() })); +vi.mock("@/lib/channels/connect", () => ({ findPartnerSession: vi.fn() })); +vi.mock("@/lib/channels", async (importOriginal) => { + const real = await importOriginal>(); + return { ...real, getAdapter: vi.fn() }; +}); +vi.mock("@/lib/waha/client", () => ({ getWahaClient: vi.fn() })); + +const NEGADO = fail("forbidden_role", "Permissão insuficiente. Requer role >= admin.", 403, {}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireRole).mockResolvedValue({ ok: false, response: NEGADO }); +}); + +describe("POST /api/v1/channels/partner/templates — viewer não cria/sincroniza template", () => { + it("403 antes de resolver a conexão de parceiro ou chamar o adapter", async () => { + const { POST } = await import("@/app/api/v1/channels/partner/templates/route"); + const req = new NextRequest("http://localhost/api/v1/channels/partner/templates", { + method: "POST", + body: JSON.stringify({ acao: "criar" }), + headers: { "content-type": "application/json" }, + }); + + const res = await POST(req); + + expect(res.status).toBe(403); + expect(findPartnerSession).not.toHaveBeenCalled(); + expect(getAdapter).not.toHaveBeenCalled(); + expect(createAdminClient).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/v1/channels/partner/templates/media — viewer não sobe imagem de template", () => { + it("403 antes de ler o multipart ou tocar o storage", async () => { + const { POST } = await import("@/app/api/v1/channels/partner/templates/media/route"); + const form = new FormData(); + form.set("file", new File([new Uint8Array([1, 2, 3])], "logo.png", { type: "image/png" })); + const req = new NextRequest("http://localhost/api/v1/channels/partner/templates/media", { + method: "POST", + body: form, + }); + + const res = await POST(req); + + expect(res.status).toBe(403); + expect(createAdminClient).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/v1/onboarding/whatsapp/session — viewer não reinicia a sessão oficial", () => { + it("403 antes de tocar channel_sessions ou o transporte WAHA", async () => { + const { POST } = await import("@/app/api/v1/onboarding/whatsapp/session/route"); + const req = new Request("http://localhost/api/v1/onboarding/whatsapp/session?restart=1", { + method: "POST", + }); + + const res = await POST(req); + + expect(res.status).toBe(403); + expect(getWahaClient).not.toHaveBeenCalled(); + }); +}); From 446d8cb451e0ec1bae31d9fa80d29c71589978f3 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 04:58:21 -0300 Subject: [PATCH 09/33] docs(changelog): fecha a 1.3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Não lançado -> 1.3.1 (2026-08-23) e documenta os 3 fixes desta sessão junto do que já estava acumulado (orçamento de IA): - editor do rag_bot salvava sem publicar - reconexão do WhatsApp reprovava número já aquecido no go-live - veto do pacing (anti-banimento) derrubava o turno sem reagendar Corrige também o link de comparação [1.2.1] pra [1.3.0], que faltava, e aponta os links novos pro fork (maugarciasa) em vez do repo original. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb38ea77b..bbc42180a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es ## [Não lançado] +## [1.3.1] — 2026-08-23 + ### Adicionado - **O agente de atualização passa a fixar sozinho a versão que ficou solta**, em até 5 @@ -57,6 +59,19 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es - **No painel de administração, o alerta de orçamento parou de gritar "crítico" sobre um número que não é o do mês.** Ele continua avisando, com o rótulo dizendo que o valor é acumulado, e leva direto para a tela de saúde do cliente, que mostra o número real. +- **A tela de editar o agente de IA salvava sem publicar nada.** O botão "Salvar" gravava só + o rascunho; o atendimento continuava usando a versão publicada antiga (ou o prompt genérico + do primeiro instalador), mesmo depois de várias edições — sem erro nenhum na tela. Agora + existe um botão "Publicar": o rascunho só entra em uso depois que você aperta ele. +- **Reconectar o WhatsApp podia travar o atendimento por horas, sem nenhum aviso na + conversa.** Toda reconexão (celular caiu, precisou escanear o QR de novo) tratava o número + como se fosse novo e passava a segurar toda mensagem, esperando alguém achar e resolver um + aviso escondido na Central de avisos. Agora o sistema reconhece quando é o MESMO número já + aprovado antes e libera sozinho. +- **Quando o limite diário de envio (anti-banimento) travava uma resposta, a conversa + morria ali** — o cliente não recebia nada, nem depois de o limite abrir de novo, a menos + que mandasse outra mensagem por conta própria. Agora o sistema reagenda a resposta sozinho + para o próximo horário permitido. ## [1.3.0] — 2026-08-13 @@ -396,7 +411,9 @@ Primeira versão marcada do DeskcommCRM. O projeto vinha sendo desenvolvido publ - **Node 22 é obrigatório para desenvolvimento.** A suíte de invariantes instancia o cliente do Supabase, que exige o `WebSocket` global — nativo apenas a partir do Node 22. Isso não afeta quem apenas hospeda: a VPS roda a imagem pronta. -[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.2.1...HEAD +[Não lançado]: https://github.com/maugarciasa/DeskcommCRM/compare/v1.3.1...HEAD +[1.3.1]: https://github.com/maugarciasa/DeskcommCRM/compare/v1.3.0...v1.3.1 +[1.3.0]: https://github.com/maugarciasa/DeskcommCRM/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.0.0...v1.1.0 From c8e2055aba468c36f67f7d3c3f7baebbef300be6 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 05:12:15 -0300 Subject: [PATCH 10/33] =?UTF-8?q?test:=20confirma=20Actions=20autom=C3=A1t?= =?UTF-8?q?ico=20no=20repo=20novo=20(sem=20v=C3=ADnculo=20de=20fork)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 131edf5664fddfdfde794ff0190553795544a1e7 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 05:12:53 -0300 Subject: [PATCH 11/33] =?UTF-8?q?test:=202a=20tentativa=20de=20disparo=20a?= =?UTF-8?q?utom=C3=A1tico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 87ae640a468649b40bf169bbd17e4c01f14fde70 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 05:13:43 -0300 Subject: [PATCH 12/33] test: 3a tentativa (esperando propagacao) From f97683e3df074c43535b357c14ca5a762d127621 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 05:17:14 -0300 Subject: [PATCH 13/33] =?UTF-8?q?test:=20confere=20disparo=20autom=C3=A1ti?= =?UTF-8?q?co=20ap=C3=B3s=20habilitar=20no=20repo=20novo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 62c3f303511532e532ad50c62b2dda99cde00a07 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:01:52 -0300 Subject: [PATCH 14/33] fix(security): RLS de papel ausente em messages e nas 31 tabelas do agent-engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditoria técnica encontrou o mesmo gap da migration 0169 (contacts) em messages e em toda a infraestrutura do agent-engine (job_queue, send_ledger, lead_checkpoints, metrics etc., migration 0050): RLS só de tenant, sem checagem de papel — qualquer membro do tenant, inclusive viewer, gravava/apagava direto pelo PostgREST. 0170 fecha messages com o mesmo piso que app/api/v1/messages/route.ts já exige (agent+). 0171 faz a verificação tabela-por-tabela nas 31 tabelas da 0050: 4 têm escrita confirmada por sessão (piso = o que a rota já exige), as outras 26 ganham o mesmo piso por defesa em profundidade (service_role sempre ignorou RLS; o que muda é o acesso direto via PostgREST com a chave authenticated). Verificado com pnpm test:db (Postgres efêmero, baseline em modo install e update, 845 testes de invariantes). Co-Authored-By: Claude Sonnet 5 --- supabase/baseline.sql | 149 ++++++++++++++ ...23010000_0170_rls_de_papel_em_messages.sql | 55 +++++ ...749_0171_rls_de_papel_em_agent_harness.sql | 192 ++++++++++++++++++ supabase/migrations/MANIFEST.md | 2 + 4 files changed, 398 insertions(+) create mode 100644 supabase/migrations/20260823010000_0170_rls_de_papel_em_messages.sql create mode 100644 supabase/migrations/20260823120749_0171_rls_de_papel_em_agent_harness.sql diff --git a/supabase/baseline.sql b/supabase/baseline.sql index 2ddec70de..522e1d8bd 100644 --- a/supabase/baseline.sql +++ b/supabase/baseline.sql @@ -13466,6 +13466,155 @@ create policy "contacts_write" on public.contacts notify pgrst, 'reload schema'; +-- ---- RLS de papel em messages (migration 0170) ---- +-- +-- Mesma classe de falha que a 0169 já corrigiu para contacts: messages_insert/ +-- update/delete checavam só organization_id, sem fn_role_at_least — qualquer +-- membro do tenant, inclusive viewer, apagava/alterava mensagem de WhatsApp +-- direto pelo PostgREST. Piso 'agent' porque é o que +-- app/api/v1/messages/route.ts já exige no POST. Ver comentário completo na +-- migration 0170. messages_select fica como está (já delega ao RLS de +-- conversations via EXISTS). + +drop policy if exists "messages_insert" on public.messages; +create policy "messages_insert" on public.messages + for insert with check ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ); + +drop policy if exists "messages_update" on public.messages; +create policy "messages_update" on public.messages + for update using ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ) with check ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ); + +drop policy if exists "messages_delete" on public.messages; +create policy "messages_delete" on public.messages + for delete using ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ); + +notify pgrst, 'reload schema'; + +-- ---- RLS de papel em agent_harness — as ~29 tabelas do motor (migration 0171) ---- +-- +-- A 0169/0170 fecharam contacts/messages e deixaram de propósito as tabelas +-- da migration 0050 (job_queue, send_ledger, lead_checkpoints, metrics etc.) +-- "para uma migration seguinte, com verificação por tabela". Esta é essa +-- migration: para cada uma das 31 tabelas, grep em app/lib/workers/components +-- por escrita via client de SESSÃO (cookie/RLS), nunca admin. Comentário +-- completo (achado, decisão por tabela, piso de cada rota) na migration 0171. +-- +-- 4 tabelas TÊM escrita por sessão confirmada, com o piso da rota que já a +-- exige: agent_inbox_items (INSERT em 'viewer' — o board GET não tem +-- requireRole nenhum hoje; UPDATE/DELETE em 'agent', defesa em profundidade), +-- lead_checkpoints (reactivate-bot, 'agent'), lead_state (next-action, +-- 'agent'), cron_jobs (leads/[id]/reactivation, 'agent'). As outras 26 +-- tabelas do loop, sem escrita por sessão encontrada, ganham piso 'agent' por +-- defesa em profundidade (service_role sempre ignorou RLS; quem a policy nova +-- passa a barrar é a chave anon/authenticated direto no PostgREST). +-- watchdog_cursors (31ª) não muda: já tem RLS habilitada e ZERO policies — +-- mais restritivo que qualquer piso de papel poderia ser. + +do $$ +declare + t text; +begin + foreach t in array array[ + 'job_queue', 'send_ledger', + 'playbook_versions', 'playbook_pointers', + 'channel_session_health', 'llm_calls', + 'lead_checkpoints', 'lead_state', 'lead_state_transitions', + 'metrics', 'channel_knobs', 'pacing_ledger', 'outbound_copies', + 'cron_jobs', + 'reentry_template_versions', 'reentry_template_pointers', + 'lead_notes', 'skill_versions', 'skill_pointers', + 'promise_table_versions', 'promise_table_pointers', + 'disclosure_template_versions', 'disclosure_template_pointers', + 'before_send_traces', + 'flywheel_judge_verdicts', 'flywheel_distiller_proposals', + 'judge_alignment_pool', + 'reentry_knob_versions', 'reentry_knob_pointers' + ] + loop + execute format('drop policy if exists tenant_isolation_%s_all on public.%I', t, t); + + execute format('drop policy if exists %s_select on public.%I', t, t); + execute format( + 'create policy %s_select on public.%I for select + using (organization_id in (select public.fn_user_org_ids()) + or public.fn_is_platform_admin())', + t, t + ); + + execute format('drop policy if exists %s_write on public.%I', t, t); + execute format( + 'create policy %s_write on public.%I for all + using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, ''agent'')) + or public.fn_is_platform_admin() + ) + with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, ''agent'')) + or public.fn_is_platform_admin() + )', + t, t + ); + end loop; +end +$$; + +drop policy if exists "tenant_isolation_agent_inbox_items_all" on public.agent_inbox_items; + +drop policy if exists "agent_inbox_items_select" on public.agent_inbox_items; +create policy "agent_inbox_items_select" on public.agent_inbox_items + for select using ( + organization_id in (select public.fn_user_org_ids()) + or public.fn_is_platform_admin() + ); + +drop policy if exists "agent_inbox_items_insert" on public.agent_inbox_items; +create policy "agent_inbox_items_insert" on public.agent_inbox_items + for insert with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'viewer')) + or public.fn_is_platform_admin() + ); + +drop policy if exists "agent_inbox_items_update" on public.agent_inbox_items; +create policy "agent_inbox_items_update" on public.agent_inbox_items + for update using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ) with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ); + +drop policy if exists "agent_inbox_items_delete" on public.agent_inbox_items; +create policy "agent_inbox_items_delete" on public.agent_inbox_items + for delete using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ); + +notify pgrst, 'reload schema'; + -- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ---- -- -- ⚠️ ESTE BLOCO É, DE PROPÓSITO, O ÚLTIMO DO ARQUIVO. Apêndice novo entra ANTES diff --git a/supabase/migrations/20260823010000_0170_rls_de_papel_em_messages.sql b/supabase/migrations/20260823010000_0170_rls_de_papel_em_messages.sql new file mode 100644 index 000000000..9024edc78 --- /dev/null +++ b/supabase/migrations/20260823010000_0170_rls_de_papel_em_messages.sql @@ -0,0 +1,55 @@ +-- 0170: RLS de messages isolava por tenant mas não checava PAPEL +-- +-- Mesma classe de falha que a 0169 já corrigiu para contacts. A auditoria +-- técnica de 2026-08-23 encontrou que `messages_insert`/`messages_update`/ +-- `messages_delete` (baseline.sql, apêndice da 0169-era) checavam só +-- `organization_id in (select fn_user_org_ids())`, sem `fn_role_at_least`. +-- `messages` guarda o corpo das conversas de WhatsApp do tenant — dado tão +-- sensível quanto `contacts`. Qualquer membro do tenant, inclusive `viewer` +-- (documentado como somente-leitura em spec 13 §4), podia apagar ou alterar +-- qualquer mensagem do tenant direto pelo PostgREST, com a própria sessão, +-- sem passar por `requireRole` — que `app/api/v1/messages/route.ts:22` já +-- exige (`requireRole("agent", ...)`) no caminho da API. +-- +-- FORMA: mesmo par da 0169 — `messages_select` fica como está (a leitura já +-- delega a checagem real para o RLS de `conversations` via EXISTS, então +-- todo membro do tenant com acesso à conversa continua lendo) + escrita +-- (insert/update/delete) passa a exigir `fn_role_at_least(organization_id, +-- 'agent')`, o mesmo piso que a API já impõe. A policy passa a espelhar a +-- API, em vez de ficar um nível mais frouxa que ela. +-- +-- Preserva o acesso de platform admin (`fn_is_platform_admin()`), como a +-- policy anterior já garantia. O worker não entra nesta conta: usa +-- `service_role`, que é `bypassrls`. + +drop policy if exists "messages_insert" on public.messages; +create policy "messages_insert" on public.messages + for insert with check ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ); + +drop policy if exists "messages_update" on public.messages; +create policy "messages_update" on public.messages + for update using ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ) with check ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ); + +drop policy if exists "messages_delete" on public.messages; +create policy "messages_delete" on public.messages + for delete using ( + public.fn_is_platform_admin() + or ((organization_id in (select public.fn_user_org_ids())) + and public.fn_role_at_least(organization_id, 'agent')) + ); + +-- O PostgREST guarda o schema em cache; sem isto as policies novas só valem +-- no próximo reload dele. +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/20260823120749_0171_rls_de_papel_em_agent_harness.sql b/supabase/migrations/20260823120749_0171_rls_de_papel_em_agent_harness.sql new file mode 100644 index 000000000..19cc16160 --- /dev/null +++ b/supabase/migrations/20260823120749_0171_rls_de_papel_em_agent_harness.sql @@ -0,0 +1,192 @@ +-- 0171: RLS de papel nas ~29 tabelas de infraestrutura do agente (migration 0050) +-- +-- A 0169 (contacts) e a 0170 (messages) já fecharam o mesmo gap — RLS só de +-- TENANCY, sem checagem de PAPEL — e a própria 0169 documentou, de propósito, +-- que as tabelas nascidas na migration 0050 (`job_queue`, `send_ledger`, +-- `lead_checkpoints`, `metrics` etc.) ficavam para uma migration seguinte, +-- "pelo mesmo motivo que a 0150 deferiu: são escritas pelo motor/workers via +-- service_role, e apertar tudo no mesmo fôlego trocaria risco de segurança +-- por risco de parada de produção sem o mesmo nível de verificação por +-- tabela". Esta migration é essa verificação, tabela por tabela. +-- +-- MÉTODO: para cada uma das 31 tabelas da 0050, `grep -rn '\.from("")' +-- app lib workers components` (mais leitura de cada call site achado) para +-- decidir se algum caminho de SESSÃO (cookie/RLS — `createClient` de +-- `lib/supabase/server`, nunca `createAdminClient`) grava na tabela. O +-- worker/agent-engine usa `SUPABASE_DB_URL` via `pg.Pool` cru (doutrina: +-- service_role ou role dedicada `agent_worker`, ambos `bypassrls`) — RLS mais +-- apertada não quebra esse caminho, só fecha o acesso direto via PostgREST +-- com a chave `authenticated`. +-- +-- ACHADO — 4 tabelas TÊM escrita por sessão confirmada, com o piso da rota +-- que já a exige (a policy passa a espelhar a API, nunca fica mais frouxa): +-- +-- * `agent_inbox_items` — INSERT em +-- `app/api/v1/pipelines/[id]/board/route.ts` (`avisaAmbiguas`, dentro do +-- GET do board) roda no client de SESSÃO e o GET não tem `requireRole` +-- nenhum — só `supabase.auth.getUser()`. Ou seja: hoje QUALQUER membro +-- autenticado do tenant, inclusive `viewer`, já dispara esse INSERT como +-- efeito colateral de abrir o Kanban. Piso do INSERT: `'viewer'` (o mais +-- baixo — `fn_role_at_least(org,'viewer')` é `true` para qualquer membro +-- com papel, então não é regressão nenhuma, só nomeia o que já era +-- verdade). Já o UPDATE (`app/api/v1/ai/inbox/[id]/route.ts`, marcar +-- ack/resolved) exige `requireRole("agent", ...)` — mesmo rodando com +-- client ADMIN por dentro (a policy não entra em jogo ali, mas o piso +-- documentado é esse). Sem DELETE por sessão em lugar nenhum. Por isso +-- esta tabela ganha TRÊS policies (padrão da 0170): `_insert` em +-- `'viewer'`, `_update`/`_delete` em `'agent'` (defesa em profundidade +-- no DELETE, que nenhuma rota de sessão exercita hoje). +-- +-- * `lead_checkpoints` — INSERT em +-- `app/api/v1/conversations/[id]/reactivate-bot/route.ts` +-- (`devolverAtendimentoAoAgente` → `gravarCheckpointDeRetomada`), client +-- de sessão, `requireRole("agent", ...)`. Piso: `'agent'`. +-- +-- * `lead_state` — UPDATE em +-- `app/api/v1/leads/[id]/next-action/route.ts` (aprovar/descartar a +-- próxima ação proposta pelo agente), client de sessão, +-- `requireRole("agent", ...)`. Piso: `'agent'`. +-- +-- * `cron_jobs` — INSERT em +-- `app/api/v1/leads/[id]/reactivation/route.ts` (agendar o envio da +-- retomada aceita pelo humano), client de sessão, +-- `requireRole("agent", ...)`. Piso: `'agent'`. +-- +-- SEM escrita por sessão confirmada (só `service_role`/`agent_worker`) nas +-- outras 26 tabelas do loop: `job_queue`, `send_ledger`, `playbook_versions`, +-- `playbook_pointers`, `channel_session_health`, `llm_calls`, +-- `lead_state_transitions`, `metrics`, `channel_knobs`, `pacing_ledger`, +-- `outbound_copies`, `reentry_template_versions`, `reentry_template_pointers`, +-- `lead_notes`, `skill_versions`, `skill_pointers`, `promise_table_versions`, +-- `promise_table_pointers`, `disclosure_template_versions`, +-- `disclosure_template_pointers`, `before_send_traces`, +-- `flywheel_judge_verdicts`, `flywheel_distiller_proposals`, +-- `judge_alignment_pool`, `reentry_knob_versions`, `reentry_knob_pointers`. +-- Mesmo assim, cada uma ganha o piso `'agent'` por DEFESA EM PROFUNDIDADE +-- (item 3 do briefing desta migration): `service_role` sempre ignorou RLS, e +-- é a chave `anon`/`authenticated` batendo direto no PostgREST que a policy +-- nova passa a barrar. +-- +-- `watchdog_cursors` (31ª tabela) NÃO muda: a 0050 já a deixou com RLS +-- habilitada e ZERO policies — nenhum papel de sessão alcança de QUALQUER +-- forma, o que já é mais restritivo que qualquer piso de papel poderia ser. +-- Mexer nela criaria uma policy onde hoje não existe nenhuma, afrouxando. +-- +-- FORMA: mesmo par da 0169/0170 — `_select` fica só-tenancy (todo +-- membro continua LENDO; achado não cobre SELECT) + escrita com +-- `fn_role_at_least(organization_id, 'agent')` (ou `'viewer'` só no INSERT de +-- `agent_inbox_items`, pelo motivo acima), preservando `fn_is_platform_admin()` +-- nos dois lados. Tabelas com `organization_id` NULLABLE (`agent_inbox_items`, +-- `playbook_versions`, `playbook_pointers`, `skill_versions`, +-- `skill_pointers`, `metrics` — comentário original da 0050) mantêm o mesmo +-- comportamento: `null in (...)` nunca é `true`, então linha de plataforma +-- continua invisível e intocável por qualquer client de sessão, papel nenhum. +-- +-- Aditiva e idempotente (`drop policy if exists` + `create policy`); sem +-- constraint nova, sem backfill. +-- +-- VERIFICADO nesta sessão: `pnpm test:db` (Postgres efêmero via Docker, +-- `pgvector/pgvector:pg17`), aplicando `baseline.sql` em modo install E +-- update — 112 arquivos de teste, 845 testes passando (1 falha esperada, 1 +-- skip), saída `test:db verde`. Não há teste de invariante dedicado a estas +-- 30 tabelas ainda (a suíte cobre o schema inteiro, não caçou especificamente +-- "viewer apanha 403 ao gravar em job_queue"), então o verde prova que a +-- migration não quebra NADA do que já existe — não prova, tabela por tabela, +-- que o piso novo bloqueia quem devia ser bloqueado. Essa prova pontual (ex.: +-- `viewer` tentando INSERT em `cron_jobs` e apanhando RLS) fica para quem +-- quiser reforçar com um teste de invariante dedicado. + +-- ── As 29 tabelas com o par padrão (select só-tenancy + write em 'agent') ── +do $$ +declare + t text; +begin + foreach t in array array[ + 'job_queue', 'send_ledger', + 'playbook_versions', 'playbook_pointers', + 'channel_session_health', 'llm_calls', + 'lead_checkpoints', 'lead_state', 'lead_state_transitions', + 'metrics', 'channel_knobs', 'pacing_ledger', 'outbound_copies', + 'cron_jobs', + 'reentry_template_versions', 'reentry_template_pointers', + 'lead_notes', 'skill_versions', 'skill_pointers', + 'promise_table_versions', 'promise_table_pointers', + 'disclosure_template_versions', 'disclosure_template_pointers', + 'before_send_traces', + 'flywheel_judge_verdicts', 'flywheel_distiller_proposals', + 'judge_alignment_pool', + 'reentry_knob_versions', 'reentry_knob_pointers' + ] + loop + execute format('drop policy if exists tenant_isolation_%s_all on public.%I', t, t); + + execute format('drop policy if exists %s_select on public.%I', t, t); + execute format( + 'create policy %s_select on public.%I for select + using (organization_id in (select public.fn_user_org_ids()) + or public.fn_is_platform_admin())', + t, t + ); + + execute format('drop policy if exists %s_write on public.%I', t, t); + execute format( + 'create policy %s_write on public.%I for all + using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, ''agent'')) + or public.fn_is_platform_admin() + ) + with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, ''agent'')) + or public.fn_is_platform_admin() + )', + t, t + ); + end loop; +end +$$; + +-- ── agent_inbox_items: piso por operação (INSERT em 'viewer', UPDATE/DELETE +-- em 'agent'), pelo motivo documentado no cabeçalho ── + +drop policy if exists "tenant_isolation_agent_inbox_items_all" on public.agent_inbox_items; + +drop policy if exists "agent_inbox_items_select" on public.agent_inbox_items; +create policy "agent_inbox_items_select" on public.agent_inbox_items + for select using ( + organization_id in (select public.fn_user_org_ids()) + or public.fn_is_platform_admin() + ); + +drop policy if exists "agent_inbox_items_insert" on public.agent_inbox_items; +create policy "agent_inbox_items_insert" on public.agent_inbox_items + for insert with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'viewer')) + or public.fn_is_platform_admin() + ); + +drop policy if exists "agent_inbox_items_update" on public.agent_inbox_items; +create policy "agent_inbox_items_update" on public.agent_inbox_items + for update using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ) with check ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ); + +drop policy if exists "agent_inbox_items_delete" on public.agent_inbox_items; +create policy "agent_inbox_items_delete" on public.agent_inbox_items + for delete using ( + (organization_id in (select public.fn_user_org_ids()) + and public.fn_role_at_least(organization_id, 'agent')) + or public.fn_is_platform_admin() + ); + +-- O PostgREST guarda o schema em cache; sem isto as policies novas só valem +-- no próximo reload dele. +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/MANIFEST.md b/supabase/migrations/MANIFEST.md index 98d2ae42d..f60a1ad2c 100644 --- a/supabase/migrations/MANIFEST.md +++ b/supabase/migrations/MANIFEST.md @@ -202,6 +202,8 @@ aplica. | `20260820030000` | `0164_atribuicao_de_anuncio` | **De qual anúncio um contato do WhatsApp veio.** `contacts.source`/`source_metadata` já existiam (nasceram genéricos, `'whatsapp'`); esta migration não cria coluna, só `fn_estampar_atribuicao_de_anuncio(p_contact, p_platform, p_metadata)` — chamada pelo ingest de canal (WAHA e o canal oficial) quando a primeira mensagem de um contato novo traz dados de um clique em anúncio "Clique para o WhatsApp" da Meta. **Função, não UPDATE direto do aplicativo:** o merge de `source_metadata` (`||`, preserva `waha_lid`/`waha_chat_id`/`notify_name` que `fn_upsert_wa_contact` já gravou) e a guarda de PRIMEIRO TOQUE (`source_metadata->>'ad_platform' is null`) precisam ser UMA operação atômica — duas mensagens quase simultâneas do mesmo contato novo não podem correr a corrida de ler+mesclar+escrever em JS. **Primeiro toque, nunca sobrescreve:** clicar em outro anúncio meses depois, numa conversa já aberta, não reescreve de onde a pessoa veio ORIGINALMENTE — o UPDATE casa zero linhas, silenciosamente, quando já há `ad_platform` gravado. **Os dois revokes do item 9 do CLAUDE.md** (`from public, anon, authenticated` + `grant to service_role`): só o backend chama isto (admin client no ingest), nenhum papel de sessão precisa. Idempotente (`create or replace`) e sem backfill: não altera dado existente. | | `20260820120000` | `0166_indice_do_claim_da_fila` | **O cap global do claim varria `job_queue` inteira a cada rodada.** `claimJobs` (`lib/agent-engine/queue/queue.ts`) abre toda rodada — dentro do advisory lock que serializa os claimers — com `select count(*) from job_queue where status = 'running'`, e nenhum dos quatro índices da tabela servia esse predicado. O parcial das lanes (`uniq_job_queue_one_running_per_contact`) chega perto e **não vale**: o predicado dele é mais ESTREITO (exclui `contact_id is null`, que é todo `watchdog`/`flywheel`), então o planejador não pode responder por ele. Medido em `pgvector/pgvector:pg17` com este baseline, 50.000 linhas `done` + 4 `running`: **Seq Scan / 715 buffers → Index Only Scan / 3 buffers**, índice de **16 kB**. **O custo não depende de linha viva, depende de bloat:** apagando as 50.004 dentro de uma transação e perguntando de novo, o Seq Scan ainda lê os **mesmos 715 buffers** — ele visita PÁGINA, não tupla —, e nada no produto poda `job_queue`. **O `/healthz` do worker NÃO é consertado por isto, e foi medido, não suposto:** `workers/agent-worker/main.ts` e `lib/agent-engine/obs/metrics.ts` fazem `select status, count(*) from job_queue group by status`, que precisa de TODOS os status; com o índice novo o plano continua HashAggregate sobre Seq Scan, 715 buffers, idêntico ao de antes — e um índice CHEIO em `(status)` também não move (medido: o planejador segue escolhendo Seq Scan, e o índice custaria 360 kB). Fica fora do escopo: aquilo roda em probe do Docker a cada 30s, não no caminho quente do claim. **O bloco `do $$` não é cerimônia:** `create index if not exists` casa por NOME e não por definição, e as duas variantes foram medidas em pg17 — homônimo na própria `job_queue` com outra definição vira `NOTICE: ... already exists, skipping` (NOTICE nem chega ao filtro `ERROR\|FATAL` do `update.sh`: no-op perfeitamente silencioso), e homônimo em OUTRA tabela (nome de índice é único por SCHEMA) dá o mesmo no-op **e** faz o `comment on index` acertar o índice errado, cegando o delator. O bloco derruba e recria o homônimo NOSSO em `job_queue`; o de outro objeto ele **não apaga** — `raise exception` com a razão escrita, que é o comportamento certo num script que roda sem `ON_ERROR_STOP` (o erro aparece ao operador e o resto do baseline segue). **O `comment on index` é DELATOR:** índice ausente levanta `relation "idx_job_queue_running" does not exist`, texto conferido contra a lista benigna real do `update.sh` (`already exists\|multiple primary keys\|multiple default values\|is already a member\|already a partition`) — não casa com nenhum termo, logo chega ao cliente; um `already exists` seria engolido. Guardado por `tests/invariants/queue-cap-global-do-claim.test.ts`, que **extrai o `select` do FONTE de produção** em vez de copiá-lo: índice presente e predicado mudado (`::text` a mais, `lower(status)`, `status in (...)`) devolveria Seq Scan com o símbolo intacto. Aditiva e idempotente: sem constraint, sem backfill, sem dado tocado. | | `20260820160000` | `0165_identificador_de_canal_unico_entre_ativos` | **Os dois identificadores de canal que chegaram depois do snapshot não tinham trava nenhuma, e é por eles que o código resolve credencial de envio e o DONO de uma mensagem que acabou de entrar.** `waha_session_name` e `webhook_path_token` são UNIQUE desde o snapshot; `meta_phone_number_id` (0087) e `zernio_account_id` (0131) nasceram sem. Traz **dois índices únicos PARCIAIS** (`where archived_at is null`), pelo precedente da 0107 — canal arquivado é canal excluído e a linha só sobrevive como âncora das FKs RESTRICT, então trava total impediria reconectar o mesmo número depois de excluí-lo. **O que a ausência produzia (issue #236):** três consultas de `lib/channels/` resolviam a sessão só pelo identificador, em client de service role (bypassa RLS). Com duas linhas casando, `maybeSingle()` **não** devolve "a primeira" — medido contra `@supabase/postgrest-js` 2.112.1, devolve `data: null` + `error PGRST116` (HTTP 406). Os três **descartavam o `error`**, então: os dois resolvedores de credencial caíam no fallback do `.env` (a mensagem saía pela conta de OUTRA instalação) e `meta/ingest.ts` devolvia `no_session` com a rota respondendo 200 — a mensagem recebida era **descartada para as duas organizações**. **Classificação: alta, não crítica** — a colisão é atingível por configuração LEGÍTIMA (agência, migração de conta entre organizações), não por reivindicação hostil: `validatePartnerCredentials` (`lib/channels/connect.ts`) exige que o identificador esteja na lista de contas que a chave informada alcança. **A deduplicação vem ANTES da trava e não é destrutiva:** o `update.sh` do clone roda sem `ON_ERROR_STOP` e engoliria o 23505, deixando o clone sem trava e sem aviso. Apagar está fora (sessão de cliente, histórico por FK RESTRICT) e arquivar faria o canal sumir da tela sem ninguém pedir — a perdedora é **RENOMEADA** para `-conflito-`: continua visível, e como o identificador não existe no provider a varredura de saúde (`app/api/v1/cron/channel-health`) grava `FAILED`/`STOPPED` na passada seguinte e **abre aviso na Central** (os três estados estão em `STATUS_QUE_AVISAM`). Fica com o identificador a sessão ativa **mais recente** (criá-la exigiu provar posse da conta na tela de conexão ⇒ é a intenção mais recente); errar o palpite não destrói nada e a linha perdedora ela mesma avisa. **Idempotente por construção:** o sufixo carrega o `id` (único), então a segunda passada casa zero linhas e não há como sufixar duas vezes. Nomes de índice novos (conferidos contra `baseline.sql` e `migrations/`), então o `if not exists` — que casa por NOME — não vira no-op em cima de um homônimo com outra definição. O código foi corrigido nas três camadas junto: filtro de `organization_id` nos três sítios (com o `organizationId` atravessando o seam de canal como campo **obrigatório**, para o typecheck cobrar), `error` que deixa de ser descartado, e o invariante `tests/unit/canal-consulta-por-organizacao.test.ts`, que varre `lib/channels/` derivando as colunas de `CHANNEL_SESSION_REF_COLUMNS` — o quarto canal entra na varredura sozinho. | +| `20260823120749` | `0171_rls_de_papel_em_agent_harness` | **As ~29 tabelas de infraestrutura do agente da migration 0050 tinham RLS de tenant e nenhuma checagem de PAPEL — o gap que a 0169 deixou de propósito "para uma migration seguinte, com verificação por tabela".** Esta é essa migration: para cada uma das 31 tabelas (`job_queue`, `send_ledger`, `lead_checkpoints`, `lead_state`, `metrics`, `cron_jobs`, `agent_inbox_items` etc.), `grep -rn '\.from("")' app lib workers components` + leitura de cada call site para decidir se algum caminho de SESSÃO (cookie/RLS, `createClient` de `lib/supabase/server`) grava na tabela — o worker/agent-engine usa `SUPABASE_DB_URL` via `pg.Pool` cru (`bypassrls`), então apertar RLS não quebra esse caminho. **4 tabelas TÊM escrita por sessão confirmada, piso = o que a rota já exige:** `agent_inbox_items` (INSERT em `app/api/v1/pipelines/[id]/board/route.ts` — `avisaAmbiguas`, dentro do GET do board, que **não tem `requireRole` nenhum**; piso `'viewer'`, o mais baixo, porque hoje QUALQUER membro autenticado já dispara esse INSERT como efeito colateral de abrir o Kanban — não é regressão, só nomeia o que já era verdade; UPDATE/DELETE em `'agent'`, defesa em profundidade — o PATCH de `app/api/v1/ai/inbox/[id]/route.ts` já exige `requireRole("agent", ...)`, mesmo rodando com client admin por dentro), `lead_checkpoints` (INSERT em `app/api/v1/conversations/[id]/reactivate-bot/route.ts`, `requireRole("agent")`, piso `'agent'`), `lead_state` (UPDATE em `app/api/v1/leads/[id]/next-action/route.ts`, `requireRole("agent")`, piso `'agent'`), `cron_jobs` (INSERT em `app/api/v1/leads/[id]/reactivation/route.ts`, `requireRole("agent")`, piso `'agent'`). **As outras 26 tabelas do loop** (`send_ledger`, `playbook_versions`/`pointers`, `channel_session_health`, `llm_calls`, `lead_state_transitions`, `channel_knobs`, `pacing_ledger`, `outbound_copies`, `reentry_template_versions`/`pointers`, `lead_notes`, `skill_versions`/`pointers`, `promise_table_versions`/`pointers`, `disclosure_template_versions`/`pointers`, `before_send_traces`, `flywheel_judge_verdicts`, `flywheel_distiller_proposals`, `judge_alignment_pool`, `reentry_knob_versions`/`pointers`, `job_queue`, `metrics`) não têm escrita por sessão encontrada — todo write achado usa `createAdminClient()` (service role) ou o pool `SUPABASE_DB_URL` do agent-engine — e ganham piso `'agent'` por **defesa em profundidade**: `service_role` sempre ignorou RLS, quem a policy nova passa a barrar é a chave `anon`/`authenticated` direto no PostgREST. **`watchdog_cursors` (31ª tabela) NÃO muda:** já tinha RLS habilitada e ZERO policies desde a 0050 — mais restritivo que qualquer piso de papel poderia ser; criar uma policy ali seria AFROUXAR. Mesma FORMA da 0169/0170: `_select` só-tenancy (achado não cobre SELECT) + escrita com `fn_role_at_least`, preservando `fn_is_platform_admin()` dos dois lados. Aditiva e idempotente (`drop policy if exists` + `create policy`, via `do $$ ... foreach ... $$` para as 29 uniformes + bloco explícito para `agent_inbox_items`); sem constraint nova, sem backfill. **VERIFICADO nesta sessão:** `pnpm test:db` — Postgres efêmero via Docker (`pgvector/pgvector:pg17`), `baseline.sql` aplicado em modo install E update — verde: 112 arquivos de teste, 845 testes passando (1 falha esperada, 1 skip), `==> test:db verde`. Isso prova que a migration não quebra nada do que já existe (nenhuma suíte dependia do comportamento antigo das 30 tabelas). **NÃO MEDIDO:** não há teste de invariante dedicado provando, tabela por tabela, que `viewer` de fato apanha 403/RLS ao tentar gravar nas 26 tabelas de piso `'agent'` — a suíte atual não caça esse caso especificamente. Fica para quem quiser reforçar com um teste de invariante dedicado. | +| `20260823010000` | `0170_rls_de_papel_em_messages` | **`messages` tinha RLS de tenant e nenhuma checagem de PAPEL — mesma falha que a 0169 já corrigiu em `contacts`.** Achado CRÍTICO de auditoria técnica: `messages_insert`/`messages_update`/`messages_delete` checavam só `organization_id in (select fn_user_org_ids())`, sem `fn_role_at_least`; qualquer membro do tenant, inclusive `viewer` (somente-leitura por doutrina, spec 13 §4), apagava/alterava qualquer mensagem de WhatsApp do tenant direto pelo PostgREST, sem passar por `requireRole` — que `app/api/v1/messages/route.ts:22` já exige (`requireRole("agent", ...)`) no POST. `messages_select` fica como está (já delega ao RLS de `conversations` via EXISTS, então todo membro com acesso à conversa continua lendo). As três policies de escrita passam a exigir `fn_role_at_least(organization_id, 'agent')`, preservando `fn_is_platform_admin()` dos dois lados, como as policies antigas já garantiam. Aditiva e idempotente (`drop policy if exists` + `create policy`); sem constraint nova, sem backfill. **NÃO MEDIDO nesta sessão:** `pnpm test:db` contra Postgres efêmero (Docker não disponível) — a policy foi verificada por leitura direta e comparação byte-a-byte com o padrão já provado da 0169, não por execução. Rode `pnpm test:db` antes do merge para confirmar que `viewer` de fato apanha 403/RLS ao tentar escrever mensagem. | | `20260823000000` | `0169_rls_de_papel_em_contacts` | **`contacts` tinha RLS de tenant e nenhuma checagem de PAPEL** — achado CRÍTICO de auditoria: única policy era `tenant_isolation_contacts_all` (FOR ALL, só `fn_user_org_ids()`), com `GRANT ALL ... TO authenticated`; qualquer membro do tenant, inclusive `viewer` (somente-leitura por doutrina, spec 13 §4), lia/gravava/apagava qualquer contato direto pelo PostgREST, sem passar por `requireRole`. Mesma classe de falha que a 0150 já corrigiu para canais/config de IA — aquela migration deferiu o resto do schema "para depois"; esta fecha `contacts`, o dado mais crítico do produto (nome, telefone, e-mail). **Mesmo par de policies da 0150**: `contacts_select` (só tenancy — todo membro continua LENDO, senão a tela quebra para o viewer) + `contacts_write` (FOR ALL, `fn_role_at_least(organization_id, 'agent')`), piso que espelha o que `app/api/v1/contacts/route.ts`/`[id]/route.ts` já exigem no POST/PATCH/DELETE. Preserva `fn_is_platform_admin()` dos dois lados, como a policy antiga garantia. Sem GRANT novo (a restrição é por RLS, não por privilégio de tabela — mesmo desenho de `channel_sessions`/`ai_agents` na 0150). Aditiva e idempotente (`drop policy if exists` + `create policy`); sem constraint nova, sem backfill. **As ~29 tabelas de infraestrutura do agente da migration 0050** (`job_queue`, `send_ledger`, `metrics` etc.) e `messages` continuam com o mesmo gap — ficam para uma migration seguinte, de propósito, pelo mesmo motivo que a 0150 deferiu: são escritas pelo motor/workers via `service_role`, e apertar tudo no mesmo fôlego trocaria risco de segurança por risco de parada de produção sem o mesmo nível de verificação por tabela. **NÃO MEDIDO nesta sessão:** `pnpm test:db` contra um Postgres efêmero (Docker não disponível) — a policy foi verificada por leitura direta do `baseline.sql` e comparação byte-a-byte com o padrão já provado da 0150, não por execução. Rode `pnpm test:db` antes do merge para confirmar que nenhuma suíte que dependia do comportamento antigo (ex.: viewer lendo contact em teste de fixture) quebra, e que `viewer` de fato apanha 403/RLS ao tentar escrever. | | `20260822180000` | `0168_fn_publish_rag_bot_version` | **Editar o prompt de um agente `rag_bot` pela tela não tinha efeito nenhum, em silêncio.** `components/ai/AgentEditor.tsx` ("caminho legado pré-EPIC-13", `app/app/ai/agents/[id]/page.tsx:73`) só grava o rascunho em `ai_agents`; o runtime (`lib/agent-engine/agent/agent-config.ts`) lê `system_prompt`/`provider`/`model` da versão apontada por `ai_agents.published_version_id`, nunca do rascunho — o agente segue respondendo com o que foi publicado no bootstrap inicial, e nenhum erro aparece em lugar nenhum. Achado numa instalação real: o prompt genérico do seed ("Você é um(a) atendente virtual amigável de uma loja online...") continuava ativo depois de o operador editar e salvar várias vezes pela tela. **`fn_publish_ai_agent_version` (0024/0025/0026) não serve pra este caminho:** exige `credential_id` não-nulo na versão e canal `channel_sessions.status = 'WORKING'` — nenhum dos dois é como `rag_bot` resolve credencial (por organização+provider em tempo de chamada, sem vínculo de versão) nem como ele publica (não trava em canal offline). `fn_publish_rag_bot_version` é o par mínimo: copia os campos de infraestrutura da versão publicada atual (canal, ferramentas, orçamento — nenhum editável pelo `AgentEditor.tsx`) e troca só `system_prompt`/`provider`/`model`, vindos do rascunho. **`provider` nunca é hardcoded** — resolve de `organizations.settings.llm.provider` (o mesmo campo que `scripts/bootstrap-owner.ts` grava a partir do `AI_PROVIDER` do instalador); um segundo bug medido na mesma triagem foi um agente seedado direto com `provider='anthropic'` apesar da organização ter escolhido OpenRouter na instalação — `LlmNotConfiguredError` em todo turno, calado, porque a chave que existia (`OPENROUTER_API_KEY`) nunca é fallback de `provider='anthropic'`. Mesmo piso de sanidade da 0024/0025/0026: recusa publicar modelo que `ai_models` não conhece (`model_not_found`) ou que já foi `deprecated_at`. Exige versão publicada existente (`no_existing_version`) — o bootstrap sempre cria a v1; sem isso não haveria de onde copiar canal/ferramentas, e inventar esses valores aqui seria pior que recusar. Os dois revokes do item 9 do CLAUDE.md (`from public, anon, authenticated` + `grant to service_role`): só o backend chama isto (`createAdminClient()` na rota `POST /api/v1/ai/agents/:id/publish-rag-bot`), nenhum papel de sessão precisa. Botão "Publicar" novo no `AgentEditor.tsx`, desabilitado enquanto o rascunho tem alteração não salva (publicar sempre o que está persistido, nunca o que está só na tela). **Verificado:** `install`+`update` do `baseline.sql` num pg17 efêmero (com o prelude de stubs do `scripts/test-db.sh`), caminho feliz e os quatro erros (`agent_kind_invalid`, `agent_not_found`, `no_existing_version`, `agent_archived`) testados manualmente contra fixtures sintéticas, e `has_function_privilege` confirmando `anon`/`authenticated` bloqueados e só `service_role` liberado. **NÃO MEDIDO:** `pnpm test:db` (a suíte de invariantes real, com Postgres + vitest orquestrados) e `pnpm test:e2e` — só a verificação manual acima; e não há teste automatizado cobrindo o botão "Publicar" na tela (só o wrapper `lib/ai/agents/publish-rag-bot.test.ts`, que cobre mapeamento de erro, não a UI). | | `20260820170000` | `0167_poda_da_fila_e_expurgo_do_audit` | **Nada no produto apagava job terminal, e a retenção de 5 anos do audit existia só no COMMENT.** `grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete` devolvia **zero linhas**: `job_queue` crescia desde a instalação e nunca encolhia; `api_audit_log` prometia 5 anos + "hot 90 dias / cold S3" em seis documentos, sem uma linha de código que executasse qualquer das duas metades. São as candidatas naturais a estourar os **500 MB** do plano free antes de qualquer tabela de negócio — e o bloat também custa CPU (715 buffers varridos no `count(*)` do claim com ZERO linhas vivas, medido na #260). Traz **duas `security definer`** (`fn_podar_fila_de_jobs`, `fn_expurgar_auditoria_vencida`), **três índices** e o cron `data-retention` (diário). **DELETE por idade e não particionamento**: particionar `job_queue` exigiria mexer no claim `FOR UPDATE SKIP LOCKED` e nos dois índices ÚNICOS parciais que garantem um turno por lead — trocar essa garantia por disco é péssimo negócio. **O QUE TEM DONO NÃO SAI, e são três cortes:** `pending`/`running` nunca saem (o primeiro ainda vai sair, o segundo está com um worker e o reaper o devolve); terminais são só `done`/`failed`/`dead` (conferidos em `lib/agent-engine/queue/queue.ts`); e **`dead` com aviso ABERTO na Central tem dono** — um humano que não olhou — com o `not exists` **antes** do `limit`, porque filtrar depois faria um lote de protegidos devolver 0, o laço do cron pararia achando que acabou e a poda morreria de fome com backlog na frente. **Cascata declarada:** o DELETE leva junto `send_ledger` e `before_send_traces` (FK `on delete cascade`, as duas também sem poda) e apenas anula o ponteiro em `llm_calls`/`lead_checkpoints`/`lead_state_transitions`; os dois consumidores de `send_ledger` sem janela (`countPriorAcceptedSends` → disclosure de IA, e o gate LGPD de 1º toque de prospecção, `before-send.ts:261`) falham **fechado** — disclosure a mais e veto a mais, nunca a menos —, daí piso de 7 dias e default de 90. **A definer do audit não é porta de adulteração, e cada razão é conferível:** (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`, então ela só sabe apagar pela ponta mais velha; (b) o **piso de 90 dias mora no corpo**, não em quem chama, então nem com a service key se remove rastro recente; (c) revogada das duas origens de EXECUTE e concedida só a `service_role`; (d) não amplia o raio de quem já tem a chave (`service_role` já tem `TRUNCATE` na mesma tabela); (e) **registra a própria erosão** — o cron grava `retention.sweep_run` com a contagem, e essa linha é nova demais para a chamada seguinte alcançar. `api_audit_log` **não tinha** GRANT de DELETE para ninguém (nem para `service_role`), e é por isso que o expurgo não podia sair pelo admin client. **Hot/cold em S3 não foi entregue e a doutrina do `CLAUDE.md` foi corrigida** em vez de fingir: o self-host não tem para onde arquivar (o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`). Índices com **nome próprio da poda** (`idx_audit_expurgo_created_at`) porque `create index if not exists` casa por NOME e um nome genérico viraria no-op silencioso num clone. Aditiva, sem constraint nova (nada a deduplicar antes), idempotente por `create or replace` + `if not exists` + `revoke`. **NÃO MEDIDO:** o comportamento sob milhões de linhas reais — os lotes foram exercitados no Postgres efêmero do `test:db`, não numa VPS com histórico de anos. | From 6a63ba41267f2bfec96de0395409e63a4dc38f19 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:02:00 -0300 Subject: [PATCH 15/33] fix(security): escapa input em filtros .or() do PostgREST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/api/v1/admin/tenants/route.ts e .../lgpd/requests/[id]/route.ts interpolavam input direto em .or() sem escapar , ( ) — sujeito a injeção de filtro. O repo já tinha o fix correto em ai/followups/queue/route.ts; só não estava replicado. Co-Authored-By: Claude Sonnet 5 --- app/api/v1/admin/lgpd/requests/[id]/route.ts | 8 ++++++-- app/api/v1/admin/tenants/route.ts | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/api/v1/admin/lgpd/requests/[id]/route.ts b/app/api/v1/admin/lgpd/requests/[id]/route.ts index 89b4592fe..62bc14281 100644 --- a/app/api/v1/admin/lgpd/requests/[id]/route.ts +++ b/app/api/v1/admin/lgpd/requests/[id]/route.ts @@ -54,11 +54,15 @@ export async function GET( .eq("id", request.organization_id) .maybeSingle(); - // Fetch audit trail for this request (cross-tenant) + // Fetch audit trail for this request (cross-tenant). `id` já foi confirmado + // como UUID existente pelo .eq() acima (comparação literal, não sujeita a + // injeção de filtro), mas escapamos aqui também por segurança-em-camadas — + // esta chamada usa .or(), cujo DSL é sensível a `,`/`(`/`)`. + const safeId = id.replace(/[,()]/g, ""); const { data: auditRows, error: auditErr } = await admin .from("api_audit_log") .select("id, action, actor_user_id, resource_type, resource_id, metadata, created_at") - .or(`resource_id.eq.${id},metadata->>request_id.eq.${id}`) + .or(`resource_id.eq.${safeId},metadata->>request_id.eq.${safeId}`) .order("created_at", { ascending: true }) .limit(50); diff --git a/app/api/v1/admin/tenants/route.ts b/app/api/v1/admin/tenants/route.ts index 29b2286c2..776abf3a2 100644 --- a/app/api/v1/admin/tenants/route.ts +++ b/app/api/v1/admin/tenants/route.ts @@ -108,8 +108,9 @@ export async function GET(req: NextRequest) { } if (q) { + const safeQ = q.replace(/[%_]/g, (m) => `\\${m}`).replace(/[,()]/g, " "); query = query.or( - `display_name.ilike.%${q}%,slug::text.ilike.%${q}%,cnpj.ilike.%${q}%`, + `display_name.ilike.%${safeQ}%,slug::text.ilike.%${safeQ}%,cnpj.ilike.%${safeQ}%`, ); } From 79c293df75a310064913f7c09a53a7a95663e3e6 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:02:02 -0300 Subject: [PATCH 16/33] =?UTF-8?q?perf:=20paraleliza=20a=C3=A7=C3=A3o=20tag?= =?UTF-8?q?=20em=20leads/bulk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop for com await sequencial por lead (até 50 round-trips em série). A ação move do mesmo arquivo já usava Promise.all — tag ficou pra trás. Co-Authored-By: Claude Sonnet 5 --- app/api/v1/leads/bulk/route.ts | 64 +++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/app/api/v1/leads/bulk/route.ts b/app/api/v1/leads/bulk/route.ts index 29f7af935..cf0a2ab5a 100644 --- a/app/api/v1/leads/bulk/route.ts +++ b/app/api/v1/leads/bulk/route.ts @@ -249,34 +249,48 @@ export async function POST(req: NextRequest): Promise { const add = input.params.add ?? []; const remove = new Set(input.params.remove ?? []); // Compute next tags per row from already-fetched `scoped`. - for (const row of visible) { + const rows = visible.map((row) => { const current = (row.tags ?? []) as string[]; const next = Array.from(new Set([...current.filter((t) => !remove.has(t)), ...add])); - const { error } = await supabase - .from("crm_leads") - .update({ tags: next, updated_at: nowIso }) - .eq("id", row.id); - if (error) return fail("internal_error", error.message, 500, { requestId }); - updatedCount += 1; - - // Per-lead lead.tag_added (only-when-added), same contract as - // updateLeadHandler, so the automation engine fires for bulk tags too. const addedTags = add.filter((t) => !current.includes(t)); - if (addedTags.length) { - await supabase - .rpc("emit_event", { - p_event_type: "lead.tag_added", - p_entity_kind: "crm_lead", - p_entity_id: row.id, - p_payload: { added_tags: addedTags, tags: next }, - p_metadata: { request_id: requestId, actor_user_id: user.id }, - p_organization_id: organizationId, - }) - .then(({ error: emitError }) => { - if (emitError) console.error("[lead.bulk_tagged] emit_event failed", emitError.message); - }); - } - } + return { row, next, addedTags }; + }); + + // Wave 3 (CORE 2): parallelize like the `move` case above — a + // sequential await-per-row here turned into an N+1 that made bulk tag + // take seconds where bulk move/assign/delete are near-instant. + const results = await Promise.all( + rows.map(({ row, next }) => + supabase + .from("crm_leads") + .update({ tags: next, updated_at: nowIso }) + .eq("id", row.id), + ), + ); + const firstError = results.find((r) => r.error)?.error; + if (firstError) return fail("internal_error", firstError.message, 500, { requestId }); + updatedCount = rows.length; + + // Per-lead lead.tag_added (only-when-added), same contract as + // updateLeadHandler, so the automation engine fires for bulk tags too. + await Promise.all( + rows + .filter(({ addedTags }) => addedTags.length) + .map(({ row, next, addedTags }) => + supabase + .rpc("emit_event", { + p_event_type: "lead.tag_added", + p_entity_kind: "crm_lead", + p_entity_id: row.id, + p_payload: { added_tags: addedTags, tags: next }, + p_metadata: { request_id: requestId, actor_user_id: user.id }, + p_organization_id: organizationId, + }) + .then(({ error: emitError }) => { + if (emitError) console.error("[lead.bulk_tagged] emit_event failed", emitError.message); + }), + ), + ); break; } case "delete": { From 7e2e5848631e4a2c245ac4f259859bba0efa6830 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:02:04 -0300 Subject: [PATCH 17/33] fix(devops): Dockerfile.worker roda non-root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O worker tem acesso a SUPABASE_SERVICE_ROLE_KEY, chaves WAHA e AES de credenciais de IA — RCE numa dependência ganhava root no container. COPY --chown=node:node + USER node (node:22-alpine já traz o usuário node). --chown no COPY evita reescrever ~200MB de node_modules numa camada extra de chown -R. Verificado com docker build + docker run: uid=1000(node), node_modules legível, falha só por falta de .env (esperado sem ambiente real). Co-Authored-By: Claude Sonnet 5 --- Dockerfile.worker | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Dockerfile.worker b/Dockerfile.worker index e605fa135..07056076e 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -18,7 +18,14 @@ RUN corepack enable && corepack prepare pnpm@9.15.9 --activate COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile -COPY . . +# non-root: o worker roda com SUPABASE_SERVICE_ROLE_KEY, chaves WAHA e a AES +# de credenciais de IA (todo o .env) — sem isto um RCE numa dependência da +# cadeia tsx/npm ganha root dentro do contêiner em vez de um usuário sem +# privilégio. `node:22-alpine` já traz o usuário `node` (uid 1000) pronto. +# `--chown` no COPY (em vez de `chown -R` depois) evita reescrever os ~200MB +# de node_modules numa camada extra. +COPY --chown=node:node . . +USER node # Healthz do worker (bind 0.0.0.0 dentro do container). EXPOSE 8787 From 2662ee514d85f4a0d059ce951c9402fe2df3b255 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:02:11 -0300 Subject: [PATCH 18/33] =?UTF-8?q?fix(config):=20corrige=20conven=C3=A7?= =?UTF-8?q?=C3=A3o=20errada=20na=20skill=20do=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .agents/skills/DeskcommCRM/SKILL.md era gerado automaticamente em julho e ensinava snake_case/imports relativos — o oposto do kebab-case/alias @/ real do repo. Com allow_implicit_invocation: true em .codex/agents/openai.yaml, toda sessão Codex recebia a convenção errada automaticamente. Conteúdo reescrito espelhando a doutrina real de .claude/skills/DeskcommCRM/SKILL.md. Co-Authored-By: Claude Sonnet 5 --- .agents/skills/DeskcommCRM/SKILL.md | 98 +++++++++-------------------- 1 file changed, 31 insertions(+), 67 deletions(-) diff --git a/.agents/skills/DeskcommCRM/SKILL.md b/.agents/skills/DeskcommCRM/SKILL.md index 6670a6236..c87218e4a 100644 --- a/.agents/skills/DeskcommCRM/SKILL.md +++ b/.agents/skills/DeskcommCRM/SKILL.md @@ -1,79 +1,43 @@ -```markdown -# DeskcommCRM Development Patterns +# DeskcommCRM — doutrina de código -> Auto-generated skill from repository analysis +> A fonte da verdade é o `CLAUDE.md` da raiz, lido do `origin/main` e não de um resumo. Este +> arquivo existe para te fazer abri-lo na hora certa e para carregar as três regras que mais +> custam caro quando esquecidas. -## Overview -This skill teaches the core development patterns and conventions used in the DeskcommCRM TypeScript codebase. It covers file organization, code style, commit message standards, and testing patterns, providing practical examples and command suggestions to streamline your workflow. +## 1. Leia antes de escrever -## Coding Conventions +| arquivo | quando | +|---|---| +| `CLAUDE.md` | **sempre**, antes de qualquer código — contém a Definition of Done, que muda | +| `VISION.md` | antes de decidir escopo, ou de dizer não a uma feature | +| `docs/doctrine/` | ao mexer em canal, agente, ou peça que se conecte a outra | +| `ARCHITECTURE.md` | para a visão de uma página | -### File Naming -- Use **snake_case** for all file names. - - Example: - ``` - user_profile.ts - customer_data_manager.ts - ``` +**Não confie em resumo de doutrina — nem neste arquivo.** Abra o `CLAUDE.md`. -### Import Style -- Use **relative imports** for referencing modules. - - Example: - ```typescript - import { getUser } from './user_utils'; - import { Customer } from '../models/customer'; - ``` +## 2. As três que mais custam -### Export Style -- Use **named exports** for all modules. - - Example: - ```typescript - // In user_utils.ts - export function getUser(id: string) { ... } - export const USER_ROLE = 'admin'; - ``` +**Multi-tenancy.** Toda tabela tenant-aware leva `organization_id uuid not null` e RLS com policy +`tenant_isolation__all` via `fn_user_org_ids()`. Service role bypassa RLS — handler que o +usa filtra `organization_id` **manualmente**, resolvido de fonte confiável (cookie, JWT, segredo de +webhook, token de path), **nunca do body**. No backend é sempre `getUser()`, nunca `getSession()`. -### Commit Messages -- Follow **conventional commits** with the `fix` prefix for bug fixes. - - Example: - ``` - fix: correct customer email validation logic - ``` +**Schema sai em tripla.** Arquivo em `supabase/migrations/`, apêndice **idempotente** no +`supabase/baseline.sql`, e linha no `MANIFEST.md`. O kit self-host aplica **só o baseline** — o que +não chega lá não chega em quem instalou numa VPS, que é o cliente que paga. -## Workflows +**Nenhuma feature nomeia um provider.** Provider vive em `lib/channels/`. `pnpm lint:channels` é +catraca com lista de dívida. -### Bug Fix Workflow -**Trigger:** When you need to fix a bug in the codebase -**Command:** `/fix-bug` +## 3. Convenção de arquivo — o oposto do que a versão anterior ensinava -1. Identify the bug and create a new branch. -2. Make code changes following the coding conventions. -3. Write or update relevant tests (`*.test.*` files). -4. Commit your changes using the `fix:` prefix and a concise description. - - Example: `fix: resolve crash on empty customer list` -5. Push your branch and open a pull request. +A versão anterior deste arquivo era gerada automaticamente por análise de repositório e ensinava +`snake_case` para nome de arquivo e imports relativos. **O repo usa o oposto**: `kebab-case` para +nome de arquivo (`user-profile.ts`, não `user_profile.ts`) e o alias `@/` para import +(`import { getUser } from "@/lib/auth/get-user"`, não `./user_utils`). Nenhum comando de fluxo +(`/fix-bug`, `/add-module`) existe neste repo — não invente um. -### Adding a New Module -**Trigger:** When you need to add a new feature or module -**Command:** `/add-module` +## 4. Antes de dizer "pronto" -1. Create new files using snake_case naming. -2. Use relative imports to connect new and existing modules. -3. Export functions and constants using named exports. -4. Write corresponding tests in `*.test.*` files. -5. Commit with an appropriate message (e.g., `feat: add customer notes module`). - -## Testing Patterns - -- Test files follow the `*.test.*` naming pattern. - - Example: `user_utils.test.ts` -- The testing framework is not explicitly specified; check existing test files for structure. -- Place tests alongside or near the modules they test. -- Ensure all new features and bug fixes are covered by tests. - -## Commands -| Command | Purpose | -|--------------|-----------------------------------------| -| /fix-bug | Start the bug fix workflow | -| /add-module | Start the new module addition workflow | -``` +Verde de teste não é prova de comportamento. Sabote a linha que você corrigiu e confirme que a +suíte fica **vermelha**. Declare o que **não** mediu. From e46531619f6b9e19fe7eca5521830c3fcfd25679 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:02:14 -0300 Subject: [PATCH 19/33] fix: lint-channels quebrava no Windows por separador de path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit path.join() no Windows devolve \; KNOWN_DEBT e ALLOWED usam /. O desencontro fazia arquivo legítimo de lib/channels/ virar "violação nova" e a lista de dívida inteira aparecer "obsoleta" ao mesmo tempo — 249 linhas de falso positivo. Normaliza o separador pra / em walk(). Não afeta o CI (roda em Linux), mas quebrava pnpm gov:verify local de qualquer contribuidor no Windows. Co-Authored-By: Claude Sonnet 5 --- scripts/lint-channels.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/lint-channels.ts b/scripts/lint-channels.ts index f8ab36b02..aaa18c9f3 100644 --- a/scripts/lint-channels.ts +++ b/scripts/lint-channels.ts @@ -29,7 +29,7 @@ * silenciosa — se você precisar acrescentar uma, escreva o porquê junto. */ import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, sep } from "node:path"; // O padrão vive em módulo próprio para poder ser testado sem executar o lint — // ver a justificativa das duas fronteiras (issue #118) lá. @@ -189,7 +189,11 @@ function walk(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const p = join(dir, e.name); if (e.isDirectory()) return e.name === "node_modules" ? [] : walk(p); - return /\.tsx?$/.test(e.name) ? [p] : []; + // KNOWN_DEBT e ALLOWED usam "/" (o repo é escrito e lido por gente em + // qualquer SO); `path.join` no Windows devolve "\", o que desencontrava + // tudo — arquivo legítimo de lib/channels/ virava "violação nova", e + // toda a lista de dívida aparecia "stale" ao mesmo tempo. + return /\.tsx?$/.test(e.name) ? [p.split(sep).join("/")] : []; }); } From c2fa10e470344293af99d6c7cd1ad4f50c155fb0 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:02:18 -0300 Subject: [PATCH 20/33] =?UTF-8?q?feat(devops):=20agenda=20o=20backup=20di?= =?UTF-8?q?=C3=A1rio=20automaticamente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backup.sh existia só como script manual (cabeçalho ensinava "crontab -e" à mão) — numa instalação self-host real, isso é "nunca ter backup rodando". Agendado no crontab do HOST (não no container scheduler, que é deliberadamente sem docker.sock e o backup.sh precisa de docker run pg_dump/tar), mesmo mecanismo idempotente por marcador que setup_update_agent_cron já usa. install.sh e update.sh chamam setup_backup_cron automaticamente — nasce funcionando, sem passo manual. Horário configurável via BACKUP_CRON_HOUR (default 3h). Verificado com pnpm test:shell. Co-Authored-By: Claude Sonnet 5 --- .env.hostgator.example | 8 +++++++ hostgator-setup-kit/CLAUDE.md | 9 +++++--- hostgator-setup-kit/_common.sh | 41 +++++++++++++++++++++++++++++++++- hostgator-setup-kit/backup.sh | 5 ++++- hostgator-setup-kit/install.sh | 3 ++- hostgator-setup-kit/update.sh | 3 ++- 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/.env.hostgator.example b/.env.hostgator.example index 706d0e0d0..c4f72bdbe 100644 --- a/.env.hostgator.example +++ b/.env.hostgator.example @@ -199,6 +199,14 @@ SENTRY_DSN= OWNER_EMAIL=voce@seudominio.com.br OWNER_PASSWORD= # senha forte do primeiro admin +# ----------------------------------------------------------------------------- +# 13) Backup automático (banco + sessões do WhatsApp) +# ----------------------------------------------------------------------------- +# O install.sh (e o update.sh) já agenda sozinho um cron diário de +# `hostgator-setup-kit/backup.sh` no HOST — não precisa mexer aqui. O único +# knob é a HORA (0-23); o minuto é sempre :00. Vazio = 03h (fora do expediente). +BACKUP_CRON_HOUR= + # ----------------------------------------------------------------------------- # AGENT ENGINE (fusão Vendaval) — worker 24/7 do agente SDR (serviço `worker`) # ----------------------------------------------------------------------------- diff --git a/hostgator-setup-kit/CLAUDE.md b/hostgator-setup-kit/CLAUDE.md index 75143f3ac..3f2996633 100644 --- a/hostgator-setup-kit/CLAUDE.md +++ b/hostgator-setup-kit/CLAUDE.md @@ -184,9 +184,12 @@ Se mesmo assim aparecerem, aqui está o diagnóstico pronto: - **Numa instalação que ainda não tem o agente da tela**, rodar `bash update.sh` **duas vezes** liga o botão: a primeira execução ainda é a do script antigo (que baixa o novo, mas não conhece o agente); a segunda instala o cron do agente. -- **Backup** (importante! o Supabase grátis não faz sozinho): `bash backup.sh`, - e sugira agendar um backup diário no cron. O `update.sh` já roda um backup sozinho - antes de cada atualização. +- **Backup** (importante! o Supabase grátis não faz sozinho): já é automático — o + `install.sh` (e o `update.sh`) agenda sozinho um cron diário de `bash backup.sh` + no servidor (03h por padrão; ajustável por `BACKUP_CRON_HOUR` no `.env`). Não + precisa orientar a pessoa a agendar nada. `bash backup.sh` continua existindo + para rodar um backup avulso na hora. O `update.sh` também roda um backup sozinho + antes de cada atualização, além do cron diário. ## O que você NÃO faz diff --git a/hostgator-setup-kit/_common.sh b/hostgator-setup-kit/_common.sh index f1ed6e53d..1bb7648f6 100755 --- a/hostgator-setup-kit/_common.sh +++ b/hostgator-setup-kit/_common.sh @@ -584,7 +584,7 @@ owner_id_by_email() { # primeira (o filtro remove tudo que casa com o marcador, e as duas linhas # casavam). Medido na VPS: depois de instalar, sobrava só o agente e o CRM ficava # SEM o drain de eventos — a automação inteira parada, em silêncio. -cron_tag() { printf '# deskcomm:%s:%s' "${PROJECT_DIR:-$PWD}" "${1:?papel da linha (drain|agent)}"; } +cron_tag() { printf '# deskcomm:%s:%s' "${PROJECT_DIR:-$PWD}" "${1:?papel da linha (drain|agent|backup)}"; } # Puro (testável sem tocar no crontab real): lê o crontab atual em stdin e # imprime o novo. Tira as linhas DESTA instalação — pelo marcador, e também @@ -657,6 +657,45 @@ setup_update_agent_cron() { c_grn "✓ atualização pela tela ativa (agente a cada 5 minutos)" } +# Ativa (idempotente) o cron do backup diário (banco + sessões do WhatsApp). +# +# Por que HOST, e não o container `scheduler`: o `backup.sh` faz `docker run +# postgres:17-alpine pg_dump` e `docker run -v waha-data:/data alpine tar`, e o +# `scheduler` é DELIBERADAMENTE sem docker.sock ("Cron sem docker.sock: crond +# interno batendo curl na rede interna" — ver docker-compose.prod.yml). Dar +# socket a ele só para isto seria abrir root-no-host a um container cujo +# desenho inteiro é não ter esse alcance. O crontab do HOST é o mecanismo que +# este kit já usa para automação que também exige Docker: mesma função +# (setup_update_agent_cron, acima) roda `agent.sh`, que também chama `docker`. +# +# Sem isto o backup existia só como script MANUAL (cabeçalho do backup.sh +# ensinava "crontab -e" à mão) — numa instalação self-host real, onde quem +# instala não é a mesma pessoa que sabe editar crontab, isso é "nunca ter +# backup rodando". É achado de auditoria: Supabase free não faz backup +# sozinho, e depender do dono lembrar de agendar é depender do pior caso. +# +# Chamada por install.sh e update.sh, junto das outras automações — re-rodar +# não duplica a linha do crontab (mesmo cron_merge por marcador das outras). +# Horário configurável por BACKUP_CRON_HOUR (0-23; default 3 = 03h, fora do +# expediente); minuto fixo em :00 — não é knob, é o que o próprio cabeçalho do +# backup.sh já ensinava como receita manual. +setup_backup_cron() { + command -v crontab >/dev/null 2>&1 || { c_ylw "⚠ 'crontab' não encontrado — o backup diário não foi agendado. Rode 'bash hostgator-setup-kit/backup.sh' manualmente, ou instale o pacote 'cron' e rode de novo."; return 0; } + + local hora="${BACKUP_CRON_HOUR:-3}" + case "$hora" in ''|*[!0-9]*) hora=3;; esac + [ "$hora" -le 23 ] || hora=3 + + # Mesmo `cd` explícito de setup_update_agent_cron, e pelo mesmo motivo: o + # backup.sh chama enter_project(), que acha o projeto pelo CWD, e no cron o + # CWD é o home de quem é dono do crontab (não a pasta do projeto). + local legado="cd ${PROJECT_DIR} && bash hostgator-setup-kit/backup.sh" + local marcador; marcador="$(cron_tag backup)" + local cron_line="0 ${hora} * * * ${legado} >/dev/null 2>&1 ${marcador}" + ( crontab -l 2>/dev/null | cron_merge "$marcador" "$legado" "$cron_line" ) | crontab - + c_grn "✓ backup diário agendado (todo dia às $(printf '%02d' "$hora"):00)" +} + # Garante a chave de cifra dos segredos (webhooks/Nuvemshop) e a semeia no # banco (private.app_secrets, migration 0041). Idempotente: reusa a chave do # .env se existir (trocá-la invalidaria dados já cifrados); gera se ausente e diff --git a/hostgator-setup-kit/backup.sh b/hostgator-setup-kit/backup.sh index 532e1901b..ff7cb99d7 100755 --- a/hostgator-setup-kit/backup.sh +++ b/hostgator-setup-kit/backup.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # Backup: dump do banco (Supabase) + snapshot das sessões do WhatsApp. -# Supabase free NÃO tem backup automático — rode isto num cron diário. +# Supabase free NÃO tem backup automático — mas o install.sh/update.sh já +# agendam isto sozinhos num cron diário do HOST (setup_backup_cron, em +# _common.sh; horário em BACKUP_CRON_HOUR no .env, default 03h). Não precisa +# agendar à mão. Para rodar manual, ou com outro horário próprio: # # crontab -e → 0 3 * * * cd /caminho/deskcommcrm && bash hostgator-setup-kit/backup.sh source "$(dirname "$0")/_common.sh" diff --git a/hostgator-setup-kit/install.sh b/hostgator-setup-kit/install.sh index f8e0ea653..1a472ee67 100755 --- a/hostgator-setup-kit/install.sh +++ b/hostgator-setup-kit/install.sh @@ -1711,11 +1711,12 @@ else [ -n "$health_body" ] && c_dim " última resposta: $(printf '%s' "$health_body" | head -c 200 || true)" fi -# ── 11. Automações (cron do drain de eventos) ─────────────────────────────── +# ── 11. Automações (cron do drain de eventos + backup diário) ─────────────── step "Ativando as automações" ensure_encryption_key .env setup_event_log_drain_cron setup_update_agent_cron +setup_backup_cron # ── Final ─────────────────────────────────────────────────────────────────── # O app não confirmou que está de pé: dizer "Instalação concluída!" aqui seria diff --git a/hostgator-setup-kit/update.sh b/hostgator-setup-kit/update.sh index 412505106..58c7c7a8c 100755 --- a/hostgator-setup-kit/update.sh +++ b/hostgator-setup-kit/update.sh @@ -278,7 +278,8 @@ else exit 1 fi -# ── 7. Automações (cron do drain de eventos; o da tela já subiu no bloco 0) ── +# ── 7. Automações (cron do drain de eventos + backup; o da tela já subiu no bloco 0) ── step "Conferindo as automações" ensure_encryption_key .env setup_event_log_drain_cron +setup_backup_cron From 65d0043213aad39551f36175555a9025bc68f333 Mon Sep 17 00:00:00 2001 From: maugarciasa Date: Sun, 23 Aug 2026 10:02:26 -0300 Subject: [PATCH 21/33] =?UTF-8?q?feat(security):=20CSP=20e=20HSTS=20expl?= =?UTF-8?q?=C3=ADcitos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sem CVE conhecida hoje, mas ausência de defesa em profundidade contra XSS futura — e o produto é self-hosted por terceiros, então o header é a única rede de segurança que independe de disciplina de código. Aplicado em proxy.ts (não next.config.ts): a CSP precisa da URL real do Supabase em runtime, e self-host não queima NEXT_PUBLIC_* no build (a mesma imagem Docker serve toda instalação). HSTS só quando a request é https (via x-forwarded-proto, o app roda atrás de Traefik em http interno). unsafe-inline em script/style porque 4 blocos inline via dangerouslySetInnerHTML (branding, tema) não têm nonce hoje — sem input de usuário, mas real hardening futuro é threadar nonce. Verificado com curl real (headers presentes, HSTS ausente em http) e browser (sem erro de CSP no console em /login). Co-Authored-By: Claude Sonnet 5 --- proxy.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/proxy.ts b/proxy.ts index 9ff53072e..42defc44b 100644 --- a/proxy.ts +++ b/proxy.ts @@ -22,6 +22,83 @@ export async function proxy(request: NextRequest) { response.headers.set("x-pathname", pathname); request.headers.set("x-pathname", pathname); + // --------------------------------------------------------------------- + // HSTS + CSP. Set here (not in next.config.ts `headers()`) on purpose: CSP + // below needs the REAL runtime Supabase URL, and self-host does NOT burn + // NEXT_PUBLIC_* into the build — the same Docker image serves every + // install, and each reads its own `.env` at request time (see + // app/public-env-script.tsx). A static header in next.config.ts would bake + // in whatever placeholder was present at CI build time, not the customer's + // actual project. The other, non-runtime-dependent security headers + // (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, + // Permissions-Policy) stay in next.config.ts — no reason to duplicate that + // logic here. + // + // HSTS only over HTTPS: forcing it on http://localhost breaks local dev. + // Behind Traefik (self-host default, see CLAUDE.md "Deploy em produção") + // the app itself is reached over plain HTTP inside the docker network — + // `x-forwarded-proto` is the only reliable signal of the ORIGINAL scheme. + const forwardedProto = request.headers.get("x-forwarded-proto"); + const isHttps = forwardedProto === "https" || request.nextUrl.protocol === "https:"; + if (isHttps) { + response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + } + + const supabaseUrl = new URL(env.NEXT_PUBLIC_SUPABASE_URL); + const supabaseWsOrigin = `${supabaseUrl.protocol === "https:" ? "wss:" : "ws:"}//${supabaseUrl.host}`; + const isDev = process.env.NODE_ENV !== "production"; + + const cspValue = [ + "default-src 'self'", + // No nonce plumbing exists today: 4 inline