feat(banco-externo): chave da instalação, desligada por padrão (doc 37)

O #1372 entrou como "sem conexão cadastrada nada acontece": a tela de
cadastrar banco externo aparecia para toda empresa e não havia chave da
instalação. O doc 37 decidiu "módulo opcional da instalação, desligado
por padrão", e o doc 24 manda todo liga/desliga ter tela de admin.

- lib/instalacao/modulos.ts: a chave mora em platform_config (0341),
  linha MODULO_BANCO_EXTERNO; só `ligado` liga, falha fechada. Não é
  coluna de platform_settings: criar a linha daquele singleton faria
  signup_mode nascer 'aberto' e vencer o SIGNUP_MODE do .env.
- /admin/sistema ganha o cartão "Módulos opcionais" (platform admin,
  auditado como platform.modulo_updated).
- Desligado: a porta some do menu/hub/⌘K (NavMetadata.modulo), a tela
  e as 8 rotas de /api/v1/external-db respondem 404, abrirAcesso recusa
  (é o ponto que as ferramentas do #1457 já usam), e capacidade com
  `modulo` no catálogo não é oferecida ao agente, ao MCP externo nem à
  tela de capacidades.
- 0384: instalação que já tinha conexão nasce LIGADA; `on conflict do
  nothing` para a reaplicação do baseline nunca reescrever a escolha.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G7fFaatqXpzHA77xP9onS
This commit is contained in:
melgarafael
2026-09-22 13:02:26 -03:00
co-authored by Claude Opus 5
parent 1b66bd7ba1
commit a8ab2cf0bf
38 changed files with 601 additions and 21 deletions
@@ -0,0 +1,13 @@
---
impacto: capacidade_nova
secao: alterado
titulo: O banco de dados externo vira módulo opcional da instalação, desligado por padrão
---
A tela **Dados externos** (conectar o banco de outro sistema para o agente consultar) aparecia para todas as empresas da instalação. Agora ela é um **módulo opcional**: quem administra o servidor liga ou desliga em **Modo administrador › Comportamento › Módulos opcionais › Banco de dados externo**, sem mexer no arquivo de ambiente.
Desligado, o módulo não existe para ninguém: a porta some do menu, do hub de Configurações e da busca, a tela e as rotas dele respondem "não encontrado", e as ferramentas de consulta ao banco externo não são oferecidas ao agente. Ligado, tudo funciona como antes.
**Quem já usava não perde nada na atualização:** se a instalação tinha pelo menos uma conexão de banco externo cadastrada, o módulo já nasce **ligado**. Nas outras, ele nasce desligado — e nada muda para quem nunca cadastrou conexão. A escolha feita na tela nunca é reescrita por uma atualização seguinte.
Decisão do dono no doc 37 (18/09), completando o #1372.
@@ -0,0 +1,64 @@
"use server";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { audit } from "@/lib/audit";
import { requirePlatformAdmin } from "@/lib/auth/requirePlatformAdmin";
import {
MODULOS_OPCIONAIS,
gravarModulo,
moduloLigado,
} from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
export type UpdateModuloResult = { ok: true } | { ok: false; error: string };
const entradaSchema = z.object({
modulo: z.enum(MODULOS_OPCIONAIS),
ligado: z.boolean(),
});
/**
* Liga ou desliga um MÓDULO OPCIONAL da instalação (doc 37 para o banco
* externo; doc 24 para "todo liga/desliga tem tela").
*
* `is_platform_admin`, e não `admin` do tenant, pelo mesmo motivo de
* `updateComportamento.ts`: o módulo vale para TODAS as empresas do servidor, e
* abrir a porta de saída para o banco de outro sistema é decisão de quem
* responde pelo servidor.
*
* Auditado porque "desde quando as empresas podiam ligar um banco de fora?" não
* tem resposta em nenhuma outra tabela — a linha guarda o estado, não o
* histórico.
*/
export async function updateModuloDaInstalacao(
input: z.infer<typeof entradaSchema>,
): Promise<UpdateModuloResult> {
const { user } = await requirePlatformAdmin();
const parsed = entradaSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: "invalid_input" };
const { modulo, ligado } = parsed.data;
const db = createAdminClient();
const antes = await moduloLigado(db, modulo);
if (!(await gravarModulo(db, modulo, ligado, user.id))) {
return { ok: false, error: "write_failed" };
}
const hdrs = await headers();
await audit({
action: "platform.modulo_updated",
actorUserId: user.id,
resourceType: "platform_config",
metadata: { modulo, de: antes, para: ligado },
requestId: hdrs.get("x-request-id"),
ip: hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
userAgent: hdrs.get("user-agent"),
});
revalidatePath("/admin/sistema");
return { ok: true };
}
+66
View File
@@ -3,6 +3,7 @@
import { useState, useTransition } from "react";
import { updateComportamento } from "@/app/actions/settings/updateComportamento";
import { updateModuloDaInstalacao } from "@/app/actions/settings/updateModuloDaInstalacao";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
@@ -18,6 +19,7 @@ import type {
ChaveDeOrcamentoDaInstalacao,
ComportamentoDaInstalacao,
} from "@/lib/instalacao/comportamento";
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
/**
* Cada interruptor salva na hora, sem botão de confirmar — mesmo desenho do
@@ -173,3 +175,67 @@ export function FormularioDeComportamento({ inicial }: { inicial: ComportamentoD
</Card>
);
}
/**
* Os MÓDULOS OPCIONAIS da instalação — desligados por padrão, e é aqui, e só
* aqui, que se ligam (doc 24: liga/desliga de configuração geral tem tela, sem
* `.env`). Mesmo desenho do cartão de cima: salva no clique, volta no erro.
*/
export function FormularioDeModulos({ ligados }: { ligados: readonly ModuloOpcional[] }) {
const t = useT();
const [bancoExterno, setBancoExterno] = useState(ligados.includes("banco_externo"));
const [erro, setErro] = useState<string | null>(null);
const [pendente, startTransition] = useTransition();
function trocar(valor: boolean) {
setErro(null);
setBancoExterno(valor);
startTransition(async () => {
const r = await updateModuloDaInstalacao({ modulo: "banco_externo", ligado: valor });
if (!r.ok) {
setBancoExterno(!valor);
setErro(t("Não deu para salvar. Tente de novo em instantes."));
}
});
}
return (
<Card>
<CardHeader>
<CardTitle>{t("Módulos opcionais")}</CardTitle>
<CardDescription>
{t(
"Recursos que a maioria das instalações não usa. Desligados, eles não aparecem para nenhuma empresa daqui.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div className="space-y-1">
<Label htmlFor="modulo-banco-externo" className="text-base">
{t("Banco de dados externo")}
</Label>
<p className="text-sm text-muted-foreground">
{t(
"Ligado, cada empresa pode conectar o banco de outro sistema (um ERP, outro CRM) para o agente consultar. Isso guarda a senha daquele banco neste servidor e abre conexão com ele. Desligado, a tela, o menu e as ferramentas do agente somem.",
)}
</p>
</div>
<Switch
id="modulo-banco-externo"
checked={bancoExterno}
onCheckedChange={trocar}
disabled={pendente}
aria-label={t("Banco de dados externo")}
/>
</div>
{erro && (
<p className="text-sm text-destructive" role="alert">
{erro}
</p>
)}
</CardContent>
</Card>
);
}
+8 -2
View File
@@ -2,9 +2,11 @@ import { notFound } from "next/navigation";
import { loadAuthUser } from "@/lib/auth/server";
import { carregarComportamentoDaInstalacao } from "@/lib/instalacao/comportamento-servidor";
import { modulosLigados } from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
import { traduzir } from "@/lib/i18n/dicionario";
import { FormularioDeComportamento } from "./_form";
import { FormularioDeComportamento, FormularioDeModulos } from "./_form";
export const metadata = { title: "Comportamento da instalação" };
export const dynamic = "force-dynamic";
@@ -41,7 +43,10 @@ export default async function Page() {
// O valor EFETIVO (linha acima, `.env` como piso): a tela mostra o que está
// valendo de verdade, e não o que a linha diria se ela existisse.
const comportamento = await carregarComportamentoDaInstalacao();
const [comportamento, ligados] = await Promise.all([
carregarComportamentoDaInstalacao(),
modulosLigados(createAdminClient()),
]);
return (
<div className="space-y-6">
@@ -57,6 +62,7 @@ export default async function Page() {
</p>
</div>
<FormularioDeComportamento inicial={comportamento} />
<FormularioDeModulos ligados={ligados} />
</div>
);
}
+3 -1
View File
@@ -15,6 +15,8 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
import { createMcpServer } from "@/lib/mcp/server";
import { McpAuthError, validateBearerToken } from "@/lib/mcp/auth";
import { modulosLigados } from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -48,7 +50,7 @@ async function handle(req: NextRequest): Promise<Response> {
}
const transport = new WebStandardStreamableHTTPServerTransport({});
const server = createMcpServer(auth, requestId);
const server = createMcpServer(auth, requestId, await modulosLigados(createAdminClient()));
try {
await server.connect(transport);
+15
View File
@@ -19,6 +19,19 @@ import { fail, type ApiError } from "@/lib/api/wrappers";
import type { MotivoAcesso } from "@/lib/external-db/acesso";
import { traduzir } from "@/lib/i18n/dicionario";
import type { Idioma } from "@/lib/i18n/idiomas";
import { moduloLigado } from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
/**
* O banco externo é MÓDULO OPCIONAL da instalação, desligado por padrão (doc 37,
* `lib/instalacao/modulos.ts`). Desligado, nenhuma rota dele existe: 404, a
* mesma resposta de uma rota que nunca foi instalada — e a mesma de
* `notFound()` na tela. Toda rota de `/api/v1/external-db` chama isto primeiro.
*/
export async function seModuloDesligado(requestId: string): Promise<NextResponse<ApiError> | null> {
if (await moduloLigado(createAdminClient(), "banco_externo")) return null;
return fail("not_found", "Not found.", 404, { requestId });
}
export function respostaDeAcesso(
motivo: MotivoAcesso,
@@ -27,6 +40,8 @@ export function respostaDeAcesso(
const t = (texto: string) => (idioma ? traduzir(texto, idioma) : texto);
switch (motivo) {
case "modulo_desligado":
return fail("not_found", "Not found.", 404, { requestId });
case "nao_encontrada":
return fail("not_found", t("Conexão não encontrada."), 404, { requestId });
case "desativada":
@@ -27,6 +27,8 @@ import { traduzir } from "@/lib/i18n/dicionario";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import { seModuloDesligado } from "../../_falha";
export const dynamic = "force-dynamic";
const COLUNAS_SEGURAS =
@@ -36,6 +38,8 @@ type Ctx = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const { id } = await ctx.params;
const authz = await requireRole("viewer", { requestId, resource: "external_db_connections" });
@@ -62,6 +66,8 @@ export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const { id } = await ctx.params;
const authz = await requireRole("admin", { requestId, resource: "external_db_connections" });
@@ -168,6 +174,8 @@ export async function DELETE(_req: NextRequest, ctx: Ctx): Promise<Response> {
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const { id } = await ctx.params;
const authz = await requireRole("admin", { requestId, resource: "external_db_connections" });
@@ -20,12 +20,14 @@ import { checkRateLimit } from "@/lib/ai/dispatcher/rate-limit";
import { traduzir } from "@/lib/i18n/dicionario";
import { createAdminClient } from "@/lib/supabase/admin";
import { respostaDeAcesso } from "../../../_falha";
import { respostaDeAcesso, seModuloDesligado } from "../../../_falha";
export const dynamic = "force-dynamic";
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const { id } = await ctx.params;
const authz = await requireRole("viewer", { requestId, resource: "external_db_connections" });
@@ -24,7 +24,7 @@ import { checkRateLimit } from "@/lib/ai/dispatcher/rate-limit";
import { traduzir } from "@/lib/i18n/dicionario";
import { createAdminClient } from "@/lib/supabase/admin";
import { respostaDeAcesso } from "../../../../../_falha";
import { respostaDeAcesso, seModuloDesligado } from "../../../../../_falha";
export const dynamic = "force-dynamic";
@@ -32,6 +32,8 @@ type Ctx = { params: Promise<{ id: string; schema: string; tabela: string }> };
export async function GET(req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const { id, schema, tabela } = await ctx.params;
const authz = await requireRole("viewer", { requestId, resource: "external_db_connections" });
@@ -23,7 +23,7 @@ import { checkRateLimit } from "@/lib/ai/dispatcher/rate-limit";
import { traduzir } from "@/lib/i18n/dicionario";
import { createAdminClient } from "@/lib/supabase/admin";
import { respostaDeAcesso } from "../../../_falha";
import { respostaDeAcesso, seModuloDesligado } from "../../../_falha";
export const dynamic = "force-dynamic";
@@ -32,6 +32,8 @@ export async function POST(_req: NextRequest, ctx: { params: Promise<{ id: strin
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const { id } = await ctx.params;
const authz = await requireRole("admin", { requestId, resource: "external_db_connections" });
@@ -29,6 +29,8 @@ import { checkRateLimit } from "@/lib/ai/dispatcher/rate-limit";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import { seModuloDesligado } from "../_falha";
export const dynamic = "force-dynamic";
const COLUNAS_SEGURAS =
@@ -36,6 +38,8 @@ const COLUNAS_SEGURAS =
export async function GET(): Promise<Response> {
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const authz = await requireRole("viewer", { requestId, resource: "external_db_connections" });
if (!authz.ok) return authz.response;
const { org: activeOrg } = authz;
@@ -58,6 +62,8 @@ export async function POST(req: NextRequest): Promise<Response> {
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const desligado = await seModuloDesligado(requestId);
if (desligado) return desligado;
const authz = await requireRole("admin", { requestId, resource: "external_db_connections" });
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
+7 -2
View File
@@ -20,7 +20,9 @@ import { z } from "zod";
import { ok, fail } from "@/lib/api/wrappers";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import { allTools } from "@/lib/mcp/tools";
import { TOOL_CATALOG } from "@/lib/mcp/tools/catalog";
import { TOOL_CATALOG, deModuloDesligado } from "@/lib/mcp/tools/catalog";
import { modulosLigados } from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
import { juntarCatalogoComHandlers } from "@/lib/mcp/tools/catalogo-servido";
export const dynamic = "force-dynamic";
@@ -46,8 +48,11 @@ export async function GET(_req: NextRequest): Promise<Response> {
);
}
// Módulo opcional desligado na instalação: a capacidade não existe aqui, e a
// tela não a oferece para marcar (doc 37).
const ligados = await modulosLigados(createAdminClient());
const schemaPorNome = new Map(allTools.map((t) => [t.name, t.inputSchema]));
const tools = servidas.map((capacidade) => ({
const tools = servidas.filter((c) => !deModuloDesligado(c.id, ligados)).map((capacidade) => ({
...capacidade,
input_schema: z.toJSONSchema(z.object(schemaPorNome.get(capacidade.id) ?? {}), {
target: "openapi-3.0",
+15
View File
@@ -0,0 +1,15 @@
import { notFound } from "next/navigation";
import { moduloLigado } from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
/**
* O banco externo é módulo opcional da instalação, desligado por padrão (doc 37).
* Desligado, estas telas não existem — para ninguém, nem para o admin da
* empresa: quem liga é quem administra o servidor, em `/admin/sistema`. O
* layout cobre a lista e a tela da conexão de uma vez.
*/
export default async function Layout({ children }: { children: React.ReactNode }) {
if (!(await moduloLigado(createAdminClient(), "banco_externo"))) notFound();
return children;
}
+6 -2
View File
@@ -14,6 +14,7 @@ import { marcaDaInstalacao } from "@/lib/branding/instalacao";
import { resolverMarcaDaOrganizacao } from "@/lib/branding/organizacao";
import { env } from "@/lib/env";
import { createAdminClient } from "@/lib/supabase/admin";
import { modulosLigados } from "@/lib/instalacao/modulos";
import {
ImpersonateBanner,
} from "@/components/app/ImpersonateBanner";
@@ -64,7 +65,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
if (activeOrg) {
const admin = createAdminClient();
/**
* As quatro consultas que TODA página de `/app` paga, disparadas juntas.
* As cinco consultas que TODA página de `/app` paga, disparadas juntas.
*
* Elas eram sequenciais e independentes: cada uma esperava a anterior sem
* precisar do resultado dela, e a soma aparecia como a tela que não reage ao
@@ -87,7 +88,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
* este layout — a cerca anterior lia o texto-fonte e reprovava esta
* refatoração sem que nada tivesse quebrado.
*/
const [orgRes, conexoes, isEnrolled, mfaRequired] = await Promise.all([
const [orgRes, conexoes, isEnrolled, mfaRequired, modulos] = await Promise.all([
admin
.from("organizations")
.select("onboarded_at, status, settings")
@@ -101,6 +102,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod
user.id,
activeOrg.orgId,
),
// Da INSTALAÇÃO: decide se a porta de um módulo opcional entra no menu.
modulosLigados(admin),
]);
const orgRow = orgRes.data;
@@ -119,6 +122,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
visibility_mode: mode ?? DEFAULT_VISIBILITY_MODE,
// Mesma linha de `settings` já lida acima — nenhuma consulta a mais.
cliente_pela_agenda: clientePelaAgendaLigado(orgRow?.settings),
modulos_ligados: modulos,
};
// `marcaDaInstalacao()` é memoizada por TTL no PROCESSO (`lib/branding/
+4
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { NavHub } from "@/components/shell/NavHub";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
import { modulosLigados } from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Configurações" };
@@ -34,6 +36,8 @@ export default async function SettingsHubPage() {
isPlatformAdmin={user.is_platform_admin && !user.support}
role={activeOrg?.role ?? null}
interfaceSettings={activeOrg?.interface_settings}
// A porta do banco externo mora neste hub, e só existe com o módulo ligado.
modulosLigados={await modulosLigados(createAdminClient())}
title={traduzir("Configurações", idioma)}
subtitle={traduzir("Sua conta, os dados da empresa e quem tem acesso ao quê.", idioma)}
locale={idioma}
+8 -1
View File
@@ -60,8 +60,15 @@ function Resultados({ aoEscolher }: { aoEscolher: () => void }) {
user.is_platform_admin && !user.support,
activeOrg?.role ?? null,
activeOrg?.interface_settings,
activeOrg?.modulos_ligados ?? [],
),
[user.is_platform_admin, user.support, activeOrg?.role, activeOrg?.interface_settings],
[
user.is_platform_admin,
user.support,
activeOrg?.role,
activeOrg?.interface_settings,
activeOrg?.modulos_ligados,
],
);
const resultados = useMemo(() => {
+5 -1
View File
@@ -1,3 +1,4 @@
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
import type { InterfaceSettings } from "@/lib/navigation/interface";
import Link from "next/link";
@@ -14,6 +15,8 @@ import { BookOpen, Lightbulb, ListChecks, Warning } from "@/lib/ui/icons";
interface NavHubProps {
interfaceSettings?: InterfaceSettings;
/** Módulos opcionais ligados na instalação. Ausente = o hub não filtra por módulo. */
modulosLigados?: readonly ModuloOpcional[];
group: NavGroupId;
isPlatformAdmin: boolean;
role: Role | null;
@@ -69,11 +72,12 @@ export function NavHub({
title,
subtitle,
interfaceSettings,
modulosLigados,
locale = IDIOMA_PADRAO,
extensionGuides = [],
extensionsUnavailable = false,
}: NavHubProps) {
const secoes = hubSections(group, isPlatformAdmin, role, interfaceSettings);
const secoes = hubSections(group, isPlatformAdmin, role, interfaceSettings, modulosLigados);
return (
<div className="flex h-full flex-col gap-8 p-6">
+1
View File
@@ -45,6 +45,7 @@ export function SidebarContent({
user.is_platform_admin && !user.support,
activeOrg?.role ?? null,
activeOrg?.interface_settings,
activeOrg?.modulos_ligados ?? [],
);
// Configurações sai da área que rola e vai para o rodapé fixo: medido em
// 1280x768, ele caía fora da dobra mesmo em telas de 1080px.
+2
View File
@@ -24,6 +24,7 @@ import { mintEphemeralToken, revokeEphemeralToken } from '@/lib/ai/runtime/mcp_t
import { IDS_DO_HARNESS, motivoDoHarness } from '@/lib/mcp/tools/ferramentas-do-harness';
import type { McpAuthResult } from '@/lib/mcp/auth';
import type { McpContext } from '@/lib/mcp/types';
import { modulosLigados } from '@/lib/instalacao/modulos';
import type { Logger } from '../../obs/logger';
import type { CrmEdgeConfig } from './mcp-client';
@@ -130,6 +131,7 @@ export async function buildMcpTurnTools(
// por isso TODA escrita de lead era recusada — com a capacidade ligada na
// tela e o card parado. Quem passava era só o dispatcher antigo.
pipelineIds: agentConfig.pipelineIds,
modulosLigados: await modulosLigados(cfg.supabase),
});
return {
+2
View File
@@ -48,6 +48,7 @@ import { finalizeHandoff } from "./handoff";
import { loadHistoryWithBudget } from "./history";
import { mintEphemeralToken, revokeEphemeralToken } from "./mcp_token";
import { pickToolsFromMcp, type RuntimeHandoffSignal } from "./tools";
import { modulosLigados } from "@/lib/instalacao/modulos";
import { serializeSteps } from "./serialize";
import {
CHANNEL_SESSION_REF_COLUMNS,
@@ -484,6 +485,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
handoffToolEnabled: version.handoff_tool_enabled,
// `?? []` — o clone sem a coluna 0125 nasce FECHADO.
pipelineIds: (version as { pipeline_ids?: string[] }).pipeline_ids ?? [],
modulosLigados: await modulosLigados(admin),
handoffSignal,
});
+13 -1
View File
@@ -20,7 +20,8 @@ import { McpAuthError, ensureRole, ensureScope } from "@/lib/mcp/auth";
import type { McpAuthResult } from "@/lib/mcp/auth";
import { logger } from "@/lib/logger";
import { allTools, getToolByName } from "@/lib/mcp/tools";
import { catalogEntry } from "@/lib/mcp/tools/catalog";
import { catalogEntry, deModuloDesligado } from "@/lib/mcp/tools/catalog";
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
import { higienizarUuidsDeAterro } from "@/lib/mcp/uuid-de-aterro";
import { recusaDeCapacidadeParaOModelo } from "@/lib/mcp/recusa-para-o-modelo";
import type { McpContext, McpToolDefinition } from "@/lib/mcp/types";
@@ -47,6 +48,12 @@ export interface PickToolsInput {
* direção segura é agir de menos.
*/
pipelineIds?: readonly string[];
/**
* Módulos opcionais LIGADOS na instalação (`modulosLigados()`). Ausente vale
* como nenhum: capacidade de módulo não entra no turno sem que o chamador
* tenha perguntado — a direção segura, como a de `pipelineIds`.
*/
modulosLigados?: readonly ModuloOpcional[];
/** Mutable signal — runtime checks after each step. */
handoffSignal: RuntimeHandoffSignal;
}
@@ -267,6 +274,11 @@ export function pickToolsFromMcp(input: PickToolsInput): Record<string, Tool> {
// não a apliquei aqui. Não montar é o que faz a declaração valer.
if (catalogEntry(def.name)?.apenasHumano) continue;
// Módulo opcional desligado nesta instalação (doc 37): a capacidade não
// existe aqui, então nem chega ao modelo — mesmo que a versão publicada do
// agente a tenha marcada de quando o módulo estava ligado.
if (deModuloDesligado(def.name, input.modulosLigados ?? [])) continue;
result[def.name] = wrapMcpTool(def, input);
}
+6
View File
@@ -394,6 +394,12 @@ export const AUDIT_ACTIONS = [
// é a única tabela que guarda quem desligou o bloqueio de gasto, mudou o
// portão de divulgação ou passou a exigir assinatura nas entregas.
"platform.comportamento_updated",
// Um MÓDULO OPCIONAL da instalação ligado ou desligado em `/admin/sistema`
// (linha em `platform_config`, migration 0384 — o banco externo, doc 37).
// Auditável porque a linha guarda o estado e não o histórico: "desde quando
// as empresas deste servidor podiam ligar um banco de outro sistema?" só tem
// resposta aqui.
"platform.modulo_updated",
// A lista de endereços da rede INTERNA que a instalação pode alcançar
// (`platform_settings.internal_destinations`, migration 0324, decisão 22-d).
// Auditável pela mesma razão da linha acima e com alcance maior: cada entrada
+8
View File
@@ -1,5 +1,6 @@
import type { InterfaceSettings } from "@/lib/navigation/interface";
import type { Idioma } from "@/lib/i18n/idiomas";
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
/**
* Papéis dentro do tenant.
@@ -170,6 +171,13 @@ export interface ActiveOrg {
* `first_service_at` está congelada.
*/
cliente_pela_agenda?: boolean;
/**
* Os módulos opcionais LIGADOS na instalação (`lib/instalacao/modulos.ts`).
* É da instalação, não da organização — mora aqui porque este é o contexto
* que o layout de `/app` entrega à casca. Ausente vale como nenhum: a porta
* de módulo desligado não aparece no menu.
*/
modulos_ligados?: readonly ModuloOpcional[];
/**
* O que ESTA organização definiu para si — CAMPO A CAMPO, e só o que ela
* mesma definiu.
+9 -1
View File
@@ -14,12 +14,14 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type pg from "pg";
import { moduloLigado } from "@/lib/instalacao/modulos";
import { carregarConexao, type MotivoSemConexao } from "./credenciais";
import { obterPool } from "./conexao";
import { validarHostDeBanco } from "./guardas";
import type { ConexaoExterna } from "./types";
export type MotivoAcesso = MotivoSemConexao | "host_bloqueado" | "dns_falhou";
export type MotivoAcesso = MotivoSemConexao | "host_bloqueado" | "dns_falhou" | "modulo_desligado";
export type Acesso =
| { ok: true; conexao: ConexaoExterna; pool: pg.Pool }
@@ -30,6 +32,12 @@ export async function abrirAcesso(
organizationId: string,
connectionId: string,
): Promise<Acesso> {
// A porta de saída que o doc 37 manda fechar é ESTA: abrir conexão com o
// banco de outro sistema. Toda leitura passa por aqui — as rotas e as
// ferramentas do agente —, então o módulo desligado recusa aqui também, e
// nenhum caminho novo precisa lembrar de perguntar.
if (!(await moduloLigado(admin, "banco_externo"))) return { ok: false, motivo: "modulo_desligado" };
const leitura = await carregarConexao(admin, organizationId, connectionId);
if (!leitura.ok) return { ok: false, motivo: leitura.motivo };
+13
View File
@@ -2476,6 +2476,19 @@ export const DICIONARIO: Traducoes = {
"Ligado, cada envio passa por uma conferência de modelo para não prometer o que a empresa não cumpre. Custa uma chamada de modelo por envio.": {
es: "Si está activado, cada envío pasa por una verificación con un modelo para no prometer lo que la empresa no cumple. Cuesta una llamada al modelo por envío.",
},
// ─── app/admin/(protected)/sistema/_form.tsx (módulos opcionais, doc 37) ───
"Módulos opcionais": {
es: "Módulos opcionales",
},
"Recursos que a maioria das instalações não usa. Desligados, eles não aparecem para nenhuma empresa daqui.": {
es: "Funciones que la mayoría de las instalaciones no usa. Desactivadas, no aparecen para ninguna empresa de esta instalación.",
},
"Banco de dados externo": {
es: "Base de datos externa",
},
"Ligado, cada empresa pode conectar o banco de outro sistema (um ERP, outro CRM) para o agente consultar. Isso guarda a senha daquele banco neste servidor e abre conexão com ele. Desligado, a tela, o menu e as ferramentas do agente somem.": {
es: "Si está activado, cada empresa puede conectar la base de datos de otro sistema (un ERP, otro CRM) para que el agente la consulte. Eso guarda la contraseña de esa base en este servidor y abre una conexión con ella. Si está desactivado, la pantalla, el menú y las herramientas del agente desaparecen.",
},
"Abrimos um aviso na Central de avisos. A IA continua respondendo normalmente.": {
es: "Abrimos un aviso en la Central de avisos. La IA sigue respondiendo normalmente.",
},
+54
View File
@@ -0,0 +1,54 @@
/**
* O módulo opcional nasce DESLIGADO e só o valor `ligado` o liga (doc 37).
* Falha fechada: banco que não responde não abre a porta para o banco de outro
* sistema.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { describe, expect, it, vi } from "vitest";
import { abrirAcesso } from "@/lib/external-db/acesso";
import { modulosLigados } from "./modulos";
function banco(resposta: { data?: unknown; error?: unknown } | Error) {
const from = vi.fn(() => ({
select: () => ({
in: async () => {
if (resposta instanceof Error) throw resposta;
return { data: resposta.data ?? null, error: resposta.error ?? null };
},
}),
}));
return { db: { from } as unknown as SupabaseClient, from };
}
describe("modulosLigados", () => {
it("sem linha, o banco externo está desligado", async () => {
expect(await modulosLigados(banco({ data: [] }).db)).toEqual([]);
});
it("só `ligado` liga — `desligado` e lixo não", async () => {
const ligado = [{ chave: "MODULO_BANCO_EXTERNO", valor: "ligado" }];
expect(await modulosLigados(banco({ data: ligado }).db)).toEqual(["banco_externo"]);
const desligado = [{ chave: "MODULO_BANCO_EXTERNO", valor: "desligado" }];
expect(await modulosLigados(banco({ data: desligado }).db)).toEqual([]);
const lixo = [{ chave: "MODULO_BANCO_EXTERNO", valor: "true" }];
expect(await modulosLigados(banco({ data: lixo }).db)).toEqual([]);
});
it("banco que recusa ou lança = desligado, sem lançar", async () => {
expect(await modulosLigados(banco({ error: { code: "42P01", message: "x" } }).db)).toEqual([]);
expect(await modulosLigados(banco(new Error("rede")).db)).toEqual([]);
});
});
describe("abrirAcesso com o módulo desligado", () => {
it("recusa antes de carregar a conexão — nenhuma credencial é lida", async () => {
const { db, from } = banco({ data: [] });
const acesso = await abrirAcesso(db, "org", "conexao");
expect(acesso).toEqual({ ok: false, motivo: "modulo_desligado" });
// Uma consulta só: a da chave. A tabela de conexões nem foi tocada.
expect(from).toHaveBeenCalledTimes(1);
expect(from).toHaveBeenCalledWith("platform_config");
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Os MÓDULOS OPCIONAIS da instalação: desligados por padrão, ligados pelo dono
* do servidor em `/admin/sistema`.
*
* ─── De onde vem ────────────────────────────────────────────────────────────
*
* Doc 37 (18/09): o banco de dados externo é "módulo opcional da instalação,
* desligado por padrão — o mesmo caminho da telefonia". Ele abre uma porta de
* saída de rede e de credencial que a maioria dos clientes não usa; desligado,
* quem não liga não carrega o risco. E o doc 24: todo liga/desliga de
* configuração geral tem lugar na tela de admin, sem `.env`. O #1372 entrou sem
* a chave — a tela de cadastrar banco aparecia para toda empresa.
*
* ─── Por que `platform_config` (0341), e não uma coluna em `platform_settings` ─
*
* `platform_settings` é singleton, e CRIAR a linha dele tem efeito colateral:
* `signup_mode` nasce `'aberto'` pelo default da coluna, e linha presente vence o
* `SIGNUP_MODE` do `.env` (ver `lib/auth/politica-de-cadastro.ts`). Ligar um
* módulo numa instalação que nunca abriu a tela de cadastro reabriria o cadastro
* dela. Em `platform_config` cada chave é uma linha própria: ligar isto não toca
* em mais nada, e a migração de dados (0384) pode escrevê-la sem risco.
*
* ─── A regra de leitura ─────────────────────────────────────────────────────
*
* Só o valor `ligado` liga. Linha ausente, outro valor, ou banco que não
* respondeu = DESLIGADO — falha fechada, porque o que o módulo guarda atrás da
* porta é credencial de outro sistema. Não há piso no `.env`: a decisão do dono
* é que a chave mora na tela.
*
* O cliente entra por PARÂMETRO: o motor do agente é outro processo e chega aqui
* com o próprio cliente de serviço; o Next passa `createAdminClient()`. A tabela
* não tem policy (0341): só o service role a lê.
*
* ponytail: sem memo — é uma leitura por chave primária, no mesmo `Promise.all`
* das consultas que o layout já faz. Memo de processo (como `comportamento.ts`)
* só se isto aparecer medido num perfil.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { logger } from "@/lib/logger";
export const MODULOS_OPCIONAIS = ["banco_externo"] as const;
export type ModuloOpcional = (typeof MODULOS_OPCIONAIS)[number];
/** A linha de cada módulo em `platform_config`. O formato é o da CHECK da 0341. */
export const CHAVE_DO_MODULO: Record<ModuloOpcional, string> = {
banco_externo: "MODULO_BANCO_EXTERNO",
};
const LIGADO = "ligado";
const DESLIGADO = "desligado";
/** Os módulos ligados nesta instalação. Nunca lança: erro de banco = nenhum. */
export async function modulosLigados(db: SupabaseClient): Promise<ModuloOpcional[]> {
try {
const { data, error } = await db
.from("platform_config")
.select("chave, valor")
.in("chave", Object.values(CHAVE_DO_MODULO));
if (error) {
logger.warn("módulos da instalação: leitura recusada — tratando todos como desligados", {
codigo: error.code,
detalhe: error.message,
});
return [];
}
const linhas = (data ?? []) as Array<{ chave: string; valor: string | null }>;
return MODULOS_OPCIONAIS.filter((m) =>
linhas.some((l) => l.chave === CHAVE_DO_MODULO[m] && l.valor === LIGADO),
);
} catch (erro) {
logger.warn("módulos da instalação: leitura falhou — tratando todos como desligados", {
detalhe: erro instanceof Error ? erro.message : String(erro),
});
return [];
}
}
export async function moduloLigado(db: SupabaseClient, modulo: ModuloOpcional): Promise<boolean> {
return (await modulosLigados(db)).includes(modulo);
}
/**
* Grava a escolha de quem administra a instalação. `semeado_do_env = false`
* pela regra da 0341: foi uma pessoa, e nada sobrescreve.
*/
export async function gravarModulo(
db: SupabaseClient,
modulo: ModuloOpcional,
ligado: boolean,
ator: string,
): Promise<boolean> {
const { error } = await db.from("platform_config").upsert(
{
chave: CHAVE_DO_MODULO[modulo],
valor: ligado ? LIGADO : DESLIGADO,
eh_segredo: false,
semeado_do_env: false,
updated_by: ator,
},
{ onConflict: "chave" },
);
if (error) {
logger.error("módulos da instalação: não deu para gravar", {
modulo,
codigo: error.code,
detalhe: error.message,
});
return false;
}
return true;
}
+13 -1
View File
@@ -12,10 +12,12 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { z } from "zod";
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
import { createAdminClient } from "@/lib/supabase/admin";
import { auditMcpToolCall } from "./audit";
import { ensureRole, ensureScope, type McpAuthResult } from "./auth";
import { allTools } from "./tools";
import { deModuloDesligado } from "./tools/catalog";
import { higienizarUuidsDeAterro } from "./uuid-de-aterro";
import type { McpContext } from "./types";
@@ -32,7 +34,16 @@ function summarizeResult(result: unknown): string | undefined {
return undefined;
}
export function createMcpServer(auth: McpAuthResult, requestId: string): McpServer {
/**
* `modulosLigados`: os módulos opcionais ligados na instalação. Capacidade de
* módulo desligado nem é registrada — o cliente externo não a vê na lista.
* Ausente vale como nenhum, pela mesma razão de `pickToolsFromMcp`.
*/
export function createMcpServer(
auth: McpAuthResult,
requestId: string,
modulosLigados: readonly ModuloOpcional[] = [],
): McpServer {
const server = new McpServer({
name: SERVER_NAME,
version: SERVER_VERSION,
@@ -41,6 +52,7 @@ export function createMcpServer(auth: McpAuthResult, requestId: string): McpServ
const supabase = createAdminClient();
for (const tool of allTools) {
if (deModuloDesligado(tool.name, modulosLigados)) continue;
server.registerTool(
tool.name,
{
+1
View File
@@ -27,4 +27,5 @@ export {
VALID_TOOL_IDS,
catalogEntry,
declararTools,
deModuloDesligado,
} from "./catalogo";
+12
View File
@@ -13,6 +13,7 @@
*
* Client-safe: zero import de zod, supabase ou next/headers.
*/
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
import { TOOLS_AGENDAMENTO } from "./agendamento";
import { TOOLS_ATENDIMENTO } from "./atendimento";
import { TOOLS_COMERCIO } from "./comercio";
@@ -63,3 +64,14 @@ export const VALID_TOOL_IDS: ReadonlyArray<string> = TOOL_CATALOG.map((t) => t.n
export function catalogEntry(name: string): McpToolCatalogEntry | undefined {
return TOOL_CATALOG.find((t) => t.name === name);
}
/**
* A capacidade e de um modulo opcional DESLIGADO nesta instalacao? Entao, aqui,
* ela nao existe. `ligados` vem de `modulosLigados()`. Os tres lugares que
* oferecem capacidade passam por aqui: o turno do agente (`pickToolsFromMcp`),
* o MCP externo (`createMcpServer`) e o catalogo servido a tela.
*/
export function deModuloDesligado(name: string, ligados: readonly ModuloOpcional[]): boolean {
const modulo = catalogEntry(name)?.modulo;
return modulo !== undefined && !ligados.includes(modulo);
}
+9
View File
@@ -5,6 +5,8 @@
* declare suas capacidades no proprio arquivo sem que times paralelos colidam
* no mesmo hunk. Client-safe: zero import de zod, supabase ou next/headers.
*/
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
import type { McpToolCategory } from "../../types";
import type { ToolBundle, ToolRisk } from "../pacotes";
@@ -40,6 +42,13 @@ export interface McpToolCatalogEntry {
* usar, e ela nunca dispara.
*/
apenasHumano?: boolean;
/**
* Capacidade de um MODULO OPCIONAL da instalacao (`lib/instalacao/modulos.ts`).
* Com o modulo desligado ela nao e oferecida a ninguem — nem ao agente, nem
* ao cliente MCP externo, nem a tela que escolhe capacidades. Ver
* `deModuloDesligado` em `./index.ts`.
*/
modulo?: ModuloOpcional;
}
/**
+11
View File
@@ -1,4 +1,5 @@
import type { Role } from "@/lib/auth/types";
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
/**
* Registro de navegação — a ÚNICA lista de destinos do app do tenant.
@@ -43,6 +44,12 @@ export interface NavMetadata {
/** Ausente = só no hub. `true` = uso diário, sobe para o sidebar. */
sidebar?: boolean;
healthDot?: boolean;
/**
* A porta de um MÓDULO OPCIONAL da instalação (`lib/instalacao/modulos.ts`).
* Com o módulo desligado ela some do menu, do hub e do ⌘K — para todo papel.
* É apresentação, como o resto deste arquivo: quem recusa é a tela e a rota.
*/
modulo?: ModuloOpcional;
}
/**
@@ -868,6 +875,10 @@ export const NAV_CATALOG = [
section: "Dados e acesso",
// SEM `sidebar`: o menu de Organização já estourou a dobra uma vez e hub é
// onde se agrupa por uso. Configurar fonte de dados é tarefa de uma vez.
//
// Módulo opcional da instalação, desligado por padrão (doc 37): a porta só
// existe onde quem administra o servidor o ligou, em `/admin/sistema`.
modulo: "banco_externo",
},
] as const satisfies readonly NavMetadata[];
+16 -3
View File
@@ -1,6 +1,7 @@
/** Apresentação por vínculo. Nunca é autorização de página, API ou ação. */
import { z } from "zod";
import { ROLE_RANK, type Role } from "@/lib/auth/types";
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
import { NAV_CATALOG, type NavMetadata, type NavDestinationId } from "./catalogo";
const ids = NAV_CATALOG.map((d) => d.href);
@@ -63,8 +64,19 @@ export function canSee(
): boolean {
return platform || (!!role && ROLE_RANK[role] >= ROLE_RANK[d.minRole ?? "viewer"]);
}
export function permitidos(platform: boolean, role: Role | null): NavMetadata[] {
return NAV_CATALOG.filter((d) => canSee(d, platform, role));
/**
* `modulos` são os módulos opcionais LIGADOS na instalação. Ausente = não filtra
* por módulo: quem desenha menu (sidebar, hub, ⌘K) passa a lista; quem só
* pergunta "sobra alguma porta?" não precisa.
*/
export function permitidos(
platform: boolean,
role: Role | null,
modulos?: readonly ModuloOpcional[],
): NavMetadata[] {
return NAV_CATALOG.filter(
(d) => canSee(d, platform, role) && (!modulos || !d.modulo || modulos.includes(d.modulo)),
);
}
/** Leitura tolera versões antigas/removidas sem lançar no layout. */
export function lerInterface(raw: unknown): {
@@ -91,9 +103,10 @@ export function destinosDaInterface(
raw: unknown,
platform: boolean,
role: Role | null,
modulos?: readonly ModuloOpcional[],
): NavMetadata[] {
const { settings } = lerInterface(raw);
const allowed = permitidos(platform, role);
const allowed = permitidos(platform, role, modulos);
const chosen =
settings.destinos ?? (settings.preset === "simplificada" ? SIMPLIFICADA : undefined);
return allowed.filter(
+8 -3
View File
@@ -50,6 +50,8 @@ import {
type NavGroup,
type NavGroupId,
} from "./catalogo";
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
import { destinosDaInterface, type InterfaceSettings } from "./interface";
export { NAV_GROUPS, GRUPO_NO_RODAPE } from "./catalogo";
export type { NavGroup, NavGroupId } from "./catalogo";
@@ -115,9 +117,10 @@ export function sidebarGroups(
isPlatformAdmin: boolean,
role: Role | null,
settings?: InterfaceSettings,
modulos?: readonly ModuloOpcional[],
): Array<{ group: NavGroup; items: NavDestination[] }> {
const visible = new Set<string>(
destinosDaInterface(settings, isPlatformAdmin, role).map((d) => d.href),
destinosDaInterface(settings, isPlatformAdmin, role, modulos).map((d) => d.href),
);
return NAV_GROUPS.map((group) => ({
group,
@@ -143,10 +146,11 @@ export function hubSections(
isPlatformAdmin: boolean,
role: Role | null,
settings?: InterfaceSettings,
modulos?: readonly ModuloOpcional[],
): Array<{ section: string; items: NavDestination[] }> {
const porSecao = new Map<string, NavDestination[]>();
const visible = new Set<string>(
destinosDaInterface(settings, isPlatformAdmin, role).map((d) => d.href),
destinosDaInterface(settings, isPlatformAdmin, role, modulos).map((d) => d.href),
);
for (const d of NAV_DESTINATIONS) {
if (d.group !== group || !visible.has(d.href)) continue;
@@ -163,9 +167,10 @@ export function searchable(
isPlatformAdmin: boolean,
role: Role | null,
settings?: InterfaceSettings,
modulos?: readonly ModuloOpcional[],
): NavDestination[] {
const visible = new Set<string>(
destinosDaInterface(settings, isPlatformAdmin, role).map((d) => d.href),
destinosDaInterface(settings, isPlatformAdmin, role, modulos).map((d) => d.href),
);
return NAV_DESTINATIONS.filter((d) => visible.has(d.href));
}
+11
View File
@@ -36091,6 +36091,17 @@ create trigger trg_external_db_connections_audit
after insert or update or delete on public.external_db_connections
for each row execute function public.fn_audit_log_row();
-- ---- o banco externo vira módulo opcional da instalação (migration 0384) ----
-- Doc 37: desligado por padrão, ligado em /admin/sistema. A chave é a linha
-- `MODULO_BANCO_EXTERNO` de `platform_config` (0341, bloco acima); sem linha =
-- desligado. Quem já tinha conexão cadastrada nasce LIGADO, para a atualização
-- não tirar a função calada. `do nothing`: a reaplicação a cada `update.sh`
-- nunca reescreve a escolha de uma pessoa. Vem DEPOIS das duas tabelas que lê.
insert into public.platform_config (chave, valor, eh_segredo, semeado_do_env)
select 'MODULO_BANCO_EXTERNO', 'ligado', false, false
where exists (select 1 from public.external_db_connections)
on conflict (chave) do nothing;
-- ---- módulos instalados são reaplicados, depois de toda tabela do núcleo (migration 0340) ----
--
-- A provisionadora de cada módulo instalado roda de novo, sobre o núcleo já
@@ -0,0 +1,28 @@
-- 0384 — o banco de dados externo vira MÓDULO OPCIONAL da instalação, desligado
-- por padrão (decisão do dono, doc 37, 18/09).
--
-- ─── O que faltava ──────────────────────────────────────────────────────────
-- O #1372 (recorte do #1130, de @vgamkt) entrou como "sem conexão cadastrada,
-- nada acontece": a tela de cadastrar banco externo aparecia para TODA empresa,
-- e não havia chave da instalação. O doc 37 pede o contrário — desligado, e quem
-- administra o servidor liga na tela de admin (doc 24: sem `.env`).
--
-- ─── Onde a chave mora ──────────────────────────────────────────────────────
-- Uma linha em `platform_config` (0341): chave `MODULO_BANCO_EXTERNO`, valor
-- `ligado` ou `desligado`. Linha ausente = DESLIGADO (lib/instalacao/modulos.ts).
-- Não é coluna de `platform_settings` de propósito: criar a linha daquele
-- singleton faria `signup_mode` nascer `'aberto'` pelo default e vencer o
-- `SIGNUP_MODE` do `.env` — ligar o módulo reabriria o cadastro.
--
-- ─── A ATUALIZAÇÃO não desliga ninguém calado ───────────────────────────────
-- Instalação que JÁ cadastrou conexão estava usando o módulo. Para ela, a chave
-- nasce LIGADA aqui; para todas as outras, não há linha, e o módulo some.
--
-- `on conflict do nothing`, e não `do update`: o `update.sh` reaplica o baseline
-- a cada atualização, e a linha que uma pessoa gravou (inclusive `desligado`)
-- nunca é reescrita. Idempotente e sem mudar escolha humana.
insert into public.platform_config (chave, valor, eh_segredo, semeado_do_env)
select 'MODULO_BANCO_EXTERNO', 'ligado', false, false
where exists (select 1 from public.external_db_connections)
on conflict (chave) do nothing;
+1
View File
@@ -393,3 +393,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260921040000` | `0372_banco_externo_do_agente` | **Recorte do PR #1130, de @vgamkt: a organização passa a cadastrar um PostgreSQL de OUTRO sistema (segundo CRM, ERP) para o CRM consultar em tempo real.** Tabela `external_db_connections` (tenant-aware, `organization_id not null` com cascade), a senha em três colunas `bytea` (`password_encrypted`/`password_iv`/`password_tag`) cifradas pelo APP com AES-256-GCM e `AI_CRED_AES_KEY` — o mesmo padrão de `ai_provider_credentials` (0023), e não `fn_encrypt_oauth` (0201), porque quem abre a conexão é o Node via `pg`: a chave tem de estar no processo, e cifrar no banco exigiria mandar a senha em claro só para decifrá-la depois. Nunca há coluna em claro. **Por organização e não por instalação**, pelo mesmo motivo de `ad_platform_connections` (0213): duas empresas na mesma VPS têm bancos externos diferentes, e um singleton faria o agente de uma responder com dado da outra. `ssl_mode` default `require` (remoto sem TLS manda credencial e dado em claro pela rede), CHECK do vocabulário `disable|prefer|require|verify-ca|verify-full`; `enabled` para pausar sem reapontar host/porta/credencial; `unique (organization_id, label)` porque dois cadastros de mesmo nome deixariam tela e agente ambíguos. **RLS em duas policies, não uma:** `select` para qualquer membro da organização (decisão do dono — saber que a fonte existe não é segredo) e `for all` exigindo `fn_role_at_least(organization_id,'admin')` para escrita, de modo que um `viewer` falando direto com o PostgREST não reconfigura a credencial. O segredo não sai do servidor: a view `external_db_connections_safe` (`security_invoker`, logo herda a RLS da base) omite as três colunas cifradas e é a única com `grant select to authenticated`; `revoke all ... from anon` na tabela e na view, porque o `alter default privileges ... on tables to anon` do baseline alcança toda tabela criada depois dele. Triggers `fn_set_updated_at` e `fn_audit_log_row`. **Nenhuma função nova em `public`** ⇒ o item 9 da doutrina de migrations não é acionado. O schema do banco externo NÃO é espelhado de propósito: ele muda com frequência e cópia de schema envelhece — a introspecção é ao vivo, em `lib/external-db/introspeccao.ts`. Numerada `0233` na branch de origem; realocada no recorte porque 0233/0234 já foram para outra coisa na `main`. Apêndice idempotente no `baseline.sql` ANTES da reaplicação de módulos e das varreduras finais (tabela de organização nova tem de passar pelas travas de suporte na PRIMEIRA aplicação). |
| `20260921040100` | `0373_limites_configuraveis_do_banco_externo` | **Os tetos de leitura do banco externo deixam de ser cravados no código e passam a ser propriedade da CONEXÃO.** `max_rows` (1..5000, default 200 — o antigo `LIMITE_MAX`), `max_filters` (0..100, default 20) e `max_response_bytes` (4096..1048576, default 30000), as três `integer not null` com CHECK. Colunas tipadas e não um `jsonb` de configuração porque o limite pertence à conexão (cada fonte tem um processo), não à organização nem à instalação — e CHECK diz a mesma coisa que um JSON sem validar nada, com RLS e PostgREST herdando a proteção de graça. Defaults IGUAIS aos valores antigos: ninguém muda de comportamento sem pedir. `max_rows` governa a grade da tela e o teto da consulta; `max_filters` e `max_response_bytes` governam o que entra no contexto do modelo. A view `_safe` é recriada expondo os três (são configuração, não segredo) com os grants refeitos logo abaixo. **Doutrina item 8 no apêndice do baseline:** os três `update` de clamp rodam ANTES do `add constraint`, para que o `update.sh` de um clone onde alguém escreveu um teto fora da faixa direto no SQL conserte em vez de quebrar. Recorte do PR #1130, de @vgamkt; `0234` na branch dele, renumerada aqui. |
| `20260921070000` | `0380_hierarquia_do_anuncio` | **O anúncio tem nome, e o CRM só tinha o número dele.** `ad_hierarchy_cache` guarda nome do anúncio, do conjunto e da campanha por `(organization_id, platform, ad_id)`. O contato que chega por clique-para-WhatsApp traz só o id do anúncio — a plataforma não manda nome nenhum no `referral` —, e os nomes existem apenas na API, se alguém perguntar. **O cache não é otimização prematura, é a condição para a feature durar um dia:** a conta sondada responde `ads_api_access_tier: "development_access"` (medido, registrado no cabeçalho de `lib/plataformas-de-anuncio/meta/insights.ts`), e um anúncio que presta gera centenas de contatos — sem cache, cada ficha aberta repete a MESMA pergunta sobre o MESMO anúncio e a cota acaba no primeiro dia, com o operador vendo a ficha sem nome de campanha, igual a antes da feature existir. `fetched_at` guarda quando se perguntou; **a idade aceitável é decisão do código que lê, não do schema** — ela muda com o degrau de cota da conta, não com a forma do dado. `organization_id` entra no índice único porque o id do anúncio é da plataforma e duas organizações podem alcançar a MESMA conta (uma agência e o cliente dela): sem ele, a segunda a resolver leria a linha da primeira e mostraria, na ficha do contato dela, o nome que o vizinho deu ao anúncio — a mesma lição da #236. Sem FK nos identificadores: o anúncio é da plataforma e pode ser apagado lá sem aviso. Quarta tabela do eixo com o desenho server-side-only de `ad_platform_connections` (0213), `ad_insights_connections` (0214) e as duas de captura do Google (0306): RLS ligada, ZERO policies, grants de anon/authenticated revogados — nome de campanha e de criativo é a ESTRATÉGIA de mídia de quem anuncia, que um concorrente pagaria para ler, e a anon key vai para o browser. Deny-all é mais restritivo que policy de tenant, não menos; quem lê é o servidor com o admin client filtrando `organization_id` à mão. Nenhuma função nova em `public`. Gate: `tests/invariants/credencial-de-anuncios-e-server-side.test.ts`, que mede privilégio E comportamento (`permission denied` sob `set role`).
| `20260922190000` | `0384_banco_externo_vira_modulo_opcional` | **O banco de dados externo vira MÓDULO OPCIONAL da instalação, desligado por padrão (doc 37; completa o #1372).** A chave é a linha `MODULO_BANCO_EXTERNO` de `platform_config` (0341), ligada e desligada por quem administra o servidor em `/admin/sistema`; sem linha = desligado (`lib/instalacao/modulos.ts`). Desligado: nenhuma porta no menu, `/app/integracao-dados` e `/api/v1/external-db/*` respondem 404, `abrirAcesso` recusa, e as ferramentas de IA do módulo não são oferecidas ao agente. **Dado tocado:** instalação que já tinha conexão cadastrada nasce LIGADA (`insert ... where exists ... on conflict do nothing`) — a atualização não tira a função calada, e a reaplicação do baseline nunca reescreve a escolha de uma pessoa. Não é coluna de `platform_settings` porque criar a linha daquele singleton faria `signup_mode` nascer `'aberto'` e vencer o `SIGNUP_MODE` do `.env`. |
+34
View File
@@ -110,6 +110,40 @@ describe("NavHub", () => {
expect(screen.queryByRole("link", { name: /LGPD/ })).toBeNull();
});
it("a porta do banco externo só existe com o módulo ligado na instalação (doc 37)", () => {
// Desligado: some para TODO papel, inclusive o admin da empresa — quem liga
// é quem administra o servidor. Para o viewer, a seção inteira some junto,
// porque "Dados externos" era a única porta dela ao alcance dele.
render(
<NavHub group="organizacao" isPlatformAdmin={false} role="admin" title="Org" subtitle="" modulosLigados={[]} />,
);
expect(screen.queryByRole("link", { name: /Dados externos/ })).toBeNull();
expect(screen.getByRole("link", { name: /API Tokens/ })).toBeTruthy();
cleanup();
render(
<NavHub group="organizacao" isPlatformAdmin={false} role="viewer" title="Org" subtitle="" modulosLigados={[]} />,
);
const secoes = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent?.trim());
expect(secoes).not.toContain("Dados e acesso");
cleanup();
render(
<NavHub
group="organizacao"
isPlatformAdmin={false}
role="viewer"
title="Org"
subtitle=""
modulosLigados={["banco_externo"]}
/>,
);
expect(screen.getByRole("link", { name: /Dados externos/ })).toHaveAttribute(
"href",
"/app/integracao-dados",
);
});
it("agrupa os cards sob a própria seção, não numa lista solta", () => {
render(<NavHub group="ia" isPlatformAdmin role={null} title="Agente de IA" subtitle="" />);
const ensinar = screen.getByRole("region", { name: "Ensinar o agente" });