mika-agent-assist/supabase/functions/disconnect-integration/index.ts
gpt-engineer-app[bot] dd9f97cf79 Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
2026-04-18 01:53:13 +00:00

175 lines
5.7 KiB
TypeScript

// disconnect-integration (autenticada JWT)
// Lógica:
// 1. Checa cronjobs dependentes — se houver e force_pause_jobs=false, retorna 409.
// 2. Se force_pause_jobs=true, pausa todos os jobs dependentes.
// 3. Revoga token no provider (1 retry em timeout).
// 4. Se revoke 5xx/timeout: NÃO deleta nada, marca status='error', retorna 503.
// 5. Sucesso: deleta secrets do Vault e a row de user_integrations.
//
// TODO Fase 5: notify Hermes container to invalidate MCP config after disconnect.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { revokeToken, type ProviderSlug } from "../_shared/oauth-providers.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
async function getDecryptedSecret(
admin: ReturnType<typeof createClient>,
secretId: string,
): Promise<string | null> {
const { data, error } = await admin
.rpc("vault_decrypt_secret", { secret_id: secretId })
.single();
if (!error && data) {
// deno-lint-ignore no-explicit-any
return (data as any).decrypted_secret ?? (data as unknown as string);
}
return null;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) {
return jsonResponse({ error: "Não autenticado" }, 401);
}
const userId = userData.user.id;
const { integration_id, force_pause_jobs } = await req.json() as {
integration_id?: string;
force_pause_jobs?: boolean;
};
if (!integration_id) {
return jsonResponse({ error: "integration_id é obrigatório" }, 400);
}
const admin = createClient(supabaseUrl, serviceKey);
const { data: integ, error: iErr } = await admin
.from("user_integrations")
.select(
"id, user_id, access_token_vault_id, refresh_token_vault_id, mcp:available_mcps(slug, oauth_revoke_url)",
)
.eq("id", integration_id)
.eq("user_id", userId)
.maybeSingle();
if (iErr || !integ) {
return jsonResponse({ error: "Integração não encontrada" }, 404);
}
// deno-lint-ignore no-explicit-any
const mcp = (integ as any).mcp as { slug: string; oauth_revoke_url: string | null };
const slug = mcp.slug;
// 1. Checa dependências
const { data: dependentJobs } = await admin
.from("scheduled_jobs")
.select("id, name")
.eq("user_id", userId)
.neq("status", "archived")
.contains("required_mcp_slugs", [slug]);
if (dependentJobs && dependentJobs.length > 0 && !force_pause_jobs) {
return jsonResponse(
{
error: "has_dependencies",
dependent_jobs: dependentJobs,
},
409,
);
}
let pausedCount = 0;
if (dependentJobs && dependentJobs.length > 0 && force_pause_jobs) {
const { error: pErr } = await admin
.from("scheduled_jobs")
.update({
status: "paused",
auto_paused_reason: `Integração ${slug} foi desconectada`,
})
.eq("user_id", userId)
.neq("status", "archived")
.contains("required_mcp_slugs", [slug]);
if (!pErr) pausedCount = dependentJobs.length;
}
// 2. Busca tokens ANTES de qualquer delete
const accessToken = integ.access_token_vault_id
? await getDecryptedSecret(admin, integ.access_token_vault_id)
: null;
// 3. Revoga no provider
if (accessToken) {
const revokeResult = await revokeToken(
slug as ProviderSlug,
accessToken,
mcp.oauth_revoke_url,
);
if (revokeResult.serverError) {
// NÃO deleta nada. Marca como erro.
await admin
.from("user_integrations")
.update({
status: "error",
error_message: "Revoke falhou no provider. Tente novamente em alguns segundos.",
})
.eq("id", integration_id);
return jsonResponse(
{ error: "revoke_failed", message: "O provider está indisponível. Tente novamente em instantes." },
503,
);
}
}
// 4. Sucesso: deleta secrets e row
const vaultIds = [integ.access_token_vault_id, integ.refresh_token_vault_id]
.filter((id): id is string => Boolean(id));
for (const vid of vaultIds) {
try {
await admin.rpc("vault_delete_secret", { secret_id: vid });
} catch (e) {
console.warn("vault_delete_secret ignored", e instanceof Error ? e.message : "unknown");
}
}
const { error: dErr } = await admin
.from("user_integrations")
.delete()
.eq("id", integration_id);
if (dErr) {
console.error("delete user_integrations error", dErr.message);
return jsonResponse({ error: "Falha ao remover integração" }, 500);
}
// TODO Fase 5: notify Hermes container that MCP was disconnected
return jsonResponse({ success: true, paused_jobs_count: pausedCount });
} catch (err) {
console.error("disconnect-integration fatal", err instanceof Error ? err.message : "unknown");
return jsonResponse(
{ error: err instanceof Error ? err.message : "Erro inesperado" },
500,
);
}
});