mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 09:16:46 +00:00
Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
parent
d18e1ad27c
commit
dd9f97cf79
6 changed files with 1177 additions and 0 deletions
|
|
@ -26,3 +26,15 @@ verify_jwt = true
|
|||
|
||||
[functions.telegram-webhook]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.oauth-start]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.oauth-callback]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.refresh-integration-token]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.disconnect-integration]
|
||||
verify_jwt = true
|
||||
|
|
|
|||
470
supabase/functions/_shared/oauth-providers.ts
Normal file
470
supabase/functions/_shared/oauth-providers.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
// Shared OAuth provider helpers para Fase 4 (Integrações).
|
||||
// IMPORTANTE: NUNCA logar response body de trocas de token. Apenas status HTTP e error codes.
|
||||
|
||||
export type ProviderSlug =
|
||||
| "google_workspace"
|
||||
| "notion"
|
||||
| "todoist"
|
||||
| "calcom"
|
||||
| "microsoft_365";
|
||||
|
||||
export interface TokenExchangeResult {
|
||||
access_token: string;
|
||||
refresh_token?: string | null;
|
||||
expires_in?: number | null; // segundos
|
||||
account_email?: string | null;
|
||||
account_name?: string | null;
|
||||
granted_scopes?: string[];
|
||||
}
|
||||
|
||||
export interface ProviderEnv {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export function getProviderEnv(slug: ProviderSlug, redirectUri: string): ProviderEnv {
|
||||
const map: Record<ProviderSlug, [string, string]> = {
|
||||
google_workspace: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
|
||||
notion: ["NOTION_CLIENT_ID", "NOTION_CLIENT_SECRET"],
|
||||
todoist: ["TODOIST_CLIENT_ID", "TODOIST_CLIENT_SECRET"],
|
||||
calcom: ["CALCOM_CLIENT_ID", "CALCOM_CLIENT_SECRET"],
|
||||
microsoft_365: ["MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET"],
|
||||
};
|
||||
const [idKey, secretKey] = map[slug];
|
||||
const clientId = Deno.env.get(idKey) ?? "";
|
||||
const clientSecret = Deno.env.get(secretKey) ?? "";
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error(`Credenciais OAuth ausentes para ${slug} (${idKey}/${secretKey})`);
|
||||
}
|
||||
return { clientId, clientSecret, redirectUri };
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta a URL de autorização para iniciar o fluxo OAuth.
|
||||
*/
|
||||
export function buildAuthorizeUrl(
|
||||
slug: ProviderSlug,
|
||||
authorizeUrl: string,
|
||||
scopes: string[],
|
||||
state: string,
|
||||
env: ProviderEnv,
|
||||
): string {
|
||||
const u = new URL(authorizeUrl);
|
||||
u.searchParams.set("client_id", env.clientId);
|
||||
u.searchParams.set("redirect_uri", env.redirectUri);
|
||||
u.searchParams.set("response_type", "code");
|
||||
u.searchParams.set("state", state);
|
||||
|
||||
switch (slug) {
|
||||
case "google_workspace": {
|
||||
u.searchParams.set("scope", scopes.join(" "));
|
||||
u.searchParams.set("access_type", "offline");
|
||||
u.searchParams.set("prompt", "consent");
|
||||
break;
|
||||
}
|
||||
case "microsoft_365": {
|
||||
// Garante offline_access para receber refresh_token
|
||||
const withOffline = scopes.includes("offline_access")
|
||||
? scopes
|
||||
: ["offline_access", ...scopes];
|
||||
u.searchParams.set("scope", withOffline.join(" "));
|
||||
u.searchParams.set("response_mode", "query");
|
||||
break;
|
||||
}
|
||||
case "notion": {
|
||||
u.searchParams.set("owner", "user");
|
||||
break;
|
||||
}
|
||||
case "todoist": {
|
||||
u.searchParams.set("scope", scopes.join(","));
|
||||
break;
|
||||
}
|
||||
case "calcom": {
|
||||
u.searchParams.set("scope", scopes.join(" "));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Troca o `code` por tokens. NUNCA loga response body.
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
slug: ProviderSlug,
|
||||
code: string,
|
||||
tokenUrl: string,
|
||||
env: ProviderEnv,
|
||||
): Promise<TokenExchangeResult> {
|
||||
switch (slug) {
|
||||
case "google_workspace": {
|
||||
const body = new URLSearchParams({
|
||||
code,
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
redirect_uri: env.redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
});
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`google token exchange status=${res.status}`);
|
||||
throw new Error("provider_error");
|
||||
}
|
||||
const tokens = await res.json() as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
scope?: string;
|
||||
};
|
||||
// userinfo
|
||||
let email: string | null = null;
|
||||
let name: string | null = null;
|
||||
try {
|
||||
const ui = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
if (ui.ok) {
|
||||
const u = await ui.json() as { email?: string; name?: string };
|
||||
email = u.email ?? null;
|
||||
name = u.name ?? null;
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
return {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token ?? null,
|
||||
expires_in: tokens.expires_in ?? null,
|
||||
account_email: email,
|
||||
account_name: name,
|
||||
granted_scopes: tokens.scope ? tokens.scope.split(" ") : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "notion": {
|
||||
const basic = btoa(`${env.clientId}:${env.clientSecret}`);
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: env.redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`notion token exchange status=${res.status}`);
|
||||
throw new Error("provider_error");
|
||||
}
|
||||
const t = await res.json() as {
|
||||
access_token: string;
|
||||
workspace_name?: string;
|
||||
workspace_id?: string;
|
||||
owner?: { user?: { person?: { email?: string }; name?: string } };
|
||||
};
|
||||
return {
|
||||
access_token: t.access_token,
|
||||
refresh_token: null,
|
||||
expires_in: null,
|
||||
account_email: t.owner?.user?.person?.email ?? null,
|
||||
account_name: t.workspace_name ?? t.owner?.user?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
case "microsoft_365": {
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
code,
|
||||
redirect_uri: env.redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
});
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`microsoft token exchange status=${res.status}`);
|
||||
throw new Error("provider_error");
|
||||
}
|
||||
const t = await res.json() as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
};
|
||||
let email: string | null = null;
|
||||
let name: string | null = null;
|
||||
if (t.id_token) {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
atob(t.id_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
|
||||
) as { preferred_username?: string; email?: string; name?: string };
|
||||
email = payload.preferred_username ?? payload.email ?? null;
|
||||
name = payload.name ?? null;
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
return {
|
||||
access_token: t.access_token,
|
||||
refresh_token: t.refresh_token ?? null,
|
||||
expires_in: t.expires_in ?? null,
|
||||
account_email: email,
|
||||
account_name: name,
|
||||
granted_scopes: t.scope ? t.scope.split(" ") : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "calcom": {
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
code,
|
||||
redirect_uri: env.redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
});
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`calcom token exchange status=${res.status}`);
|
||||
throw new Error("provider_error");
|
||||
}
|
||||
const t = await res.json() as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
let email: string | null = null;
|
||||
let name: string | null = null;
|
||||
try {
|
||||
const me = await fetch("https://api.cal.com/v2/me", {
|
||||
headers: { Authorization: `Bearer ${t.access_token}` },
|
||||
});
|
||||
if (me.ok) {
|
||||
const u = await me.json() as { data?: { email?: string; name?: string } };
|
||||
email = u.data?.email ?? null;
|
||||
name = u.data?.name ?? null;
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
return {
|
||||
access_token: t.access_token,
|
||||
refresh_token: t.refresh_token ?? null,
|
||||
expires_in: t.expires_in ?? null,
|
||||
account_email: email,
|
||||
account_name: name,
|
||||
};
|
||||
}
|
||||
|
||||
case "todoist": {
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
code,
|
||||
});
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`todoist token exchange status=${res.status}`);
|
||||
throw new Error("provider_error");
|
||||
}
|
||||
const t = await res.json() as { access_token: string };
|
||||
let email: string | null = null;
|
||||
let name: string | null = null;
|
||||
try {
|
||||
const me = await fetch("https://api.todoist.com/rest/v2/user", {
|
||||
headers: { Authorization: `Bearer ${t.access_token}` },
|
||||
});
|
||||
if (me.ok) {
|
||||
const u = await me.json() as { email?: string; full_name?: string };
|
||||
email = u.email ?? null;
|
||||
name = u.full_name ?? null;
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
return {
|
||||
access_token: t.access_token,
|
||||
refresh_token: null,
|
||||
expires_in: null,
|
||||
account_email: email,
|
||||
account_name: name,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access_token usando refresh_token. NUNCA loga body.
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
slug: ProviderSlug,
|
||||
refreshToken: string,
|
||||
tokenUrl: string,
|
||||
env: ProviderEnv,
|
||||
): Promise<{ access_token: string; refresh_token?: string | null; expires_in?: number | null }> {
|
||||
if (slug === "notion" || slug === "todoist") {
|
||||
throw new Error("not_supported");
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
});
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (res.status === 400 || res.status === 401) {
|
||||
console.error(`refresh ${slug} invalid_grant status=${res.status}`);
|
||||
throw new Error("invalid_grant");
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.error(`refresh ${slug} status=${res.status}`);
|
||||
throw new Error("provider_error");
|
||||
}
|
||||
const t = await res.json() as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
return {
|
||||
access_token: t.access_token,
|
||||
refresh_token: t.refresh_token ?? null,
|
||||
expires_in: t.expires_in ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz uma chamada de validação leve (não consome refresh).
|
||||
*/
|
||||
export async function testProviderConnection(
|
||||
slug: ProviderSlug,
|
||||
accessToken: string,
|
||||
): Promise<{ ok: boolean; status: number; account?: { email?: string; name?: string } }> {
|
||||
const calls: Record<ProviderSlug, { url: string; headers?: Record<string, string> }> = {
|
||||
google_workspace: {
|
||||
url: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
},
|
||||
notion: {
|
||||
url: "https://api.notion.com/v1/users/me",
|
||||
headers: { "Notion-Version": "2022-06-28" },
|
||||
},
|
||||
microsoft_365: {
|
||||
url: "https://graph.microsoft.com/v1.0/me",
|
||||
},
|
||||
calcom: {
|
||||
url: "https://api.cal.com/v2/me",
|
||||
},
|
||||
todoist: {
|
||||
url: "https://api.todoist.com/rest/v2/user",
|
||||
},
|
||||
};
|
||||
const cfg = calls[slug];
|
||||
const res = await fetch(cfg.url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(cfg.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) return { ok: false, status: res.status };
|
||||
let account: { email?: string; name?: string } | undefined;
|
||||
try {
|
||||
const data = await res.json() as Record<string, unknown>;
|
||||
const email = (data.email ?? data.mail ?? data.userPrincipalName ?? data.full_name) as string | undefined;
|
||||
const name = (data.name ?? data.displayName ?? data.full_name) as string | undefined;
|
||||
account = { email, name };
|
||||
} catch (_) { /* ignore */ }
|
||||
return { ok: true, status: res.status, account };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoga token no provider. Best-effort com 1 retry em timeout.
|
||||
*/
|
||||
export async function revokeToken(
|
||||
slug: ProviderSlug,
|
||||
accessToken: string,
|
||||
revokeUrl: string | null,
|
||||
): Promise<{ ok: boolean; status: number; serverError: boolean }> {
|
||||
if (!revokeUrl || slug === "notion") {
|
||||
return { ok: true, status: 200, serverError: false };
|
||||
}
|
||||
|
||||
const doCall = async (): Promise<Response> => {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 8000);
|
||||
try {
|
||||
switch (slug) {
|
||||
case "google_workspace": {
|
||||
const u = new URL(revokeUrl);
|
||||
u.searchParams.set("token", accessToken);
|
||||
return await fetch(u.toString(), { method: "POST", signal: ctrl.signal });
|
||||
}
|
||||
case "todoist": {
|
||||
// Todoist precisa client_id/secret + access_token no body
|
||||
const clientId = Deno.env.get("TODOIST_CLIENT_ID") ?? "";
|
||||
const clientSecret = Deno.env.get("TODOIST_CLIENT_SECRET") ?? "";
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
access_token: accessToken,
|
||||
});
|
||||
return await fetch(revokeUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
}
|
||||
case "microsoft_365":
|
||||
case "calcom":
|
||||
default: {
|
||||
return await fetch(revokeUrl, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await doCall();
|
||||
const serverError = res.status >= 500;
|
||||
if (serverError) {
|
||||
// 1 retry
|
||||
try {
|
||||
const res2 = await doCall();
|
||||
return { ok: res2.ok, status: res2.status, serverError: res2.status >= 500 };
|
||||
} catch (_) {
|
||||
return { ok: false, status: res.status, serverError: true };
|
||||
}
|
||||
}
|
||||
return { ok: res.ok || res.status >= 400 && res.status < 500, status: res.status, serverError: false };
|
||||
} catch (e) {
|
||||
console.error(`revoke ${slug} error`, e instanceof Error ? e.message : "unknown");
|
||||
// timeout — 1 retry
|
||||
try {
|
||||
const res2 = await doCall();
|
||||
return { ok: res2.ok, status: res2.status, serverError: res2.status >= 500 };
|
||||
} catch (_) {
|
||||
return { ok: false, status: 0, serverError: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
175
supabase/functions/disconnect-integration/index.ts
Normal file
175
supabase/functions/disconnect-integration/index.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
184
supabase/functions/oauth-callback/index.ts
Normal file
184
supabase/functions/oauth-callback/index.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// oauth-callback (PÚBLICA — verify_jwt = false)
|
||||
// Recebe code & state via GET query params, troca por tokens, salva no Vault, redireciona para o painel.
|
||||
//
|
||||
// CRÍTICO: NUNCA logar response body de troca de tokens. Apenas status HTTP.
|
||||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
getProviderEnv,
|
||||
type ProviderSlug,
|
||||
} from "../_shared/oauth-providers.ts";
|
||||
|
||||
function siteUrl(): string {
|
||||
return Deno.env.get("SITE_URL") ?? "https://798b89e5-0dc6-412a-81be-a4b6dfea7b6c.lovable.app";
|
||||
}
|
||||
|
||||
function redirect(path: string): Response {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `${siteUrl()}${path}` },
|
||||
});
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const url = new URL(req.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const oauthError = url.searchParams.get("error");
|
||||
|
||||
if (oauthError) {
|
||||
console.error(`provider returned error=${oauthError}`);
|
||||
return redirect(`/painel/integracoes?error=${encodeURIComponent(oauthError)}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return redirect("/painel/integracoes?error=missing_params");
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const admin = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
// 1. Consome state atomicamente (previne replay)
|
||||
const { data: stateRow, error: stErr } = await admin
|
||||
.from("oauth_state_tokens")
|
||||
.update({ consumed: true })
|
||||
.eq("state_token", state)
|
||||
.eq("consumed", false)
|
||||
.gt("expires_at", new Date().toISOString())
|
||||
.select("user_id, mcp_id")
|
||||
.maybeSingle();
|
||||
|
||||
if (stErr || !stateRow) {
|
||||
console.error("invalid_state", stErr?.message);
|
||||
return redirect("/painel/integracoes?error=invalid_state");
|
||||
}
|
||||
|
||||
// 2. Busca MCP
|
||||
const { data: mcp } = await admin
|
||||
.from("available_mcps")
|
||||
.select("id, slug, oauth_token_url, supports_refresh_token")
|
||||
.eq("id", stateRow.mcp_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mcp) {
|
||||
return redirect("/painel/integracoes?error=mcp_not_found");
|
||||
}
|
||||
|
||||
const slug = mcp.slug as ProviderSlug;
|
||||
const redirectUri = `${supabaseUrl}/functions/v1/oauth-callback`;
|
||||
|
||||
// 3. Troca code por tokens
|
||||
let tokens;
|
||||
try {
|
||||
const env = getProviderEnv(slug, redirectUri);
|
||||
tokens = await exchangeCodeForTokens(slug, code, mcp.oauth_token_url, env);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "provider_error";
|
||||
return redirect(`/painel/integracoes?error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
|
||||
// 4. Busca integração existente para preservar refresh_token antigo se necessário
|
||||
const { data: existing } = await admin
|
||||
.from("user_integrations")
|
||||
.select("id, access_token_vault_id, refresh_token_vault_id")
|
||||
.eq("user_id", stateRow.user_id)
|
||||
.eq("mcp_id", mcp.id)
|
||||
.maybeSingle();
|
||||
|
||||
// 5. Salva access_token no Vault
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const { data: accessVault, error: accessErr } = await admin
|
||||
.rpc("vault_create_secret", {
|
||||
secret_value: tokens.access_token,
|
||||
secret_name: `oauth_access_${stateRow.user_id}_${slug}_${ts}`,
|
||||
secret_description: `OAuth access token (${slug})`,
|
||||
})
|
||||
.single();
|
||||
if (accessErr || !accessVault) {
|
||||
console.error("vault access error", accessErr?.message);
|
||||
return redirect("/painel/integracoes?error=vault_error");
|
||||
}
|
||||
const accessVaultId = (accessVault as { secret_id: string }).secret_id;
|
||||
|
||||
// 6. Refresh token: salva novo OU preserva antigo no caso Google sem refresh retornado
|
||||
let refreshVaultId: string | null = null;
|
||||
if (tokens.refresh_token) {
|
||||
const { data: rv, error: rErr } = await admin
|
||||
.rpc("vault_create_secret", {
|
||||
secret_value: tokens.refresh_token,
|
||||
secret_name: `oauth_refresh_${stateRow.user_id}_${slug}_${ts}`,
|
||||
secret_description: `OAuth refresh token (${slug})`,
|
||||
})
|
||||
.single();
|
||||
if (rErr || !rv) {
|
||||
console.error("vault refresh error", rErr?.message);
|
||||
} else {
|
||||
refreshVaultId = (rv as { secret_id: string }).secret_id;
|
||||
}
|
||||
// Se reconexão e tinha refresh antigo, deletar
|
||||
if (existing?.refresh_token_vault_id) {
|
||||
try {
|
||||
await admin.rpc("vault_delete_secret", {
|
||||
secret_id: existing.refresh_token_vault_id,
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
} else if (existing?.refresh_token_vault_id) {
|
||||
// Preserva refresh antigo (caso típico Google sem prompt=consent re-emitir)
|
||||
refreshVaultId = existing.refresh_token_vault_id;
|
||||
}
|
||||
|
||||
// 7. Deleta access_token antigo
|
||||
if (existing?.access_token_vault_id) {
|
||||
try {
|
||||
await admin.rpc("vault_delete_secret", {
|
||||
secret_id: existing.access_token_vault_id,
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
const expiresAt = tokens.expires_in
|
||||
? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
// 8. Upsert user_integrations
|
||||
const { error: upErr } = await admin
|
||||
.from("user_integrations")
|
||||
.upsert(
|
||||
{
|
||||
user_id: stateRow.user_id,
|
||||
mcp_id: mcp.id,
|
||||
status: "active",
|
||||
access_token_vault_id: accessVaultId,
|
||||
refresh_token_vault_id: refreshVaultId,
|
||||
token_expires_at: expiresAt,
|
||||
connected_account_email: tokens.account_email,
|
||||
connected_account_name: tokens.account_name,
|
||||
granted_scopes: tokens.granted_scopes ?? [],
|
||||
error_message: null,
|
||||
last_refreshed_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "user_id,mcp_id" },
|
||||
);
|
||||
|
||||
if (upErr) {
|
||||
console.error("user_integrations upsert error", upErr.message, upErr.code);
|
||||
// Mapeia códigos de erro do trigger
|
||||
const code = upErr.code;
|
||||
if (code === "P0001") return redirect("/painel/integracoes?error=no_subscription");
|
||||
if (code === "P0002") return redirect("/painel/integracoes?error=limit_reached");
|
||||
if (code === "P0003") return redirect("/painel/integracoes?error=plan_not_allowed");
|
||||
if (code === "P0005") return redirect("/painel/integracoes?error=agent_inactive");
|
||||
return redirect("/painel/integracoes?error=db_error");
|
||||
}
|
||||
|
||||
// TODO Fase 5: notify Hermes container that MCP is now available
|
||||
return redirect(`/painel/integracoes?status=success&mcp=${encodeURIComponent(slug)}`);
|
||||
} catch (err) {
|
||||
console.error("oauth-callback fatal", err instanceof Error ? err.message : "unknown");
|
||||
return redirect("/painel/integracoes?error=internal_error");
|
||||
}
|
||||
});
|
||||
152
supabase/functions/oauth-start/index.ts
Normal file
152
supabase/functions/oauth-start/index.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// oauth-start (autenticada via JWT)
|
||||
// Recebe { mcp_slug }, valida plano e retorna { auth_url } para o frontend redirecionar.
|
||||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
getProviderEnv,
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
function generateStateToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
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 { mcp_slug } = await req.json() as { mcp_slug?: string };
|
||||
if (!mcp_slug) {
|
||||
return jsonResponse({ error: "mcp_slug é obrigatório" }, 400);
|
||||
}
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
// Busca MCP
|
||||
const { data: mcp, error: mcpErr } = await admin
|
||||
.from("available_mcps")
|
||||
.select("id, slug, oauth_authorize_url, required_scopes, available_in_plans, is_active")
|
||||
.eq("slug", mcp_slug)
|
||||
.maybeSingle();
|
||||
|
||||
if (mcpErr || !mcp || !mcp.is_active) {
|
||||
return jsonResponse({ error: "Integração não encontrada" }, 404);
|
||||
}
|
||||
|
||||
// Valida agente ativo
|
||||
const { data: agent } = await admin
|
||||
.from("agent_instances")
|
||||
.select("status")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!agent || agent.status !== "active") {
|
||||
return jsonResponse(
|
||||
{ error: "agent_inactive", message: "Seu agente ainda não está ativo. Complete o onboarding." },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// Valida plano
|
||||
const { data: limits } = await admin
|
||||
.from("user_integration_limits")
|
||||
.select("plan_slug, max_integrations, current_integrations_count")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!limits || !limits.plan_slug || !limits.max_integrations) {
|
||||
return jsonResponse(
|
||||
{ error: "no_subscription", message: "Você precisa de uma assinatura ativa." },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const availableIn = (mcp.available_in_plans as string[]) ?? [];
|
||||
if (!availableIn.includes(limits.plan_slug)) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: "plan_not_allowed",
|
||||
message: `A integração ${mcp.slug} não está disponível no plano ${limits.plan_slug}.`,
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// Verifica se já não atingiu limite (evita iniciar fluxo que vai falhar)
|
||||
if (
|
||||
(limits.current_integrations_count ?? 0) >= limits.max_integrations
|
||||
) {
|
||||
// Permite reconectar uma existente; mas se for nova vai falhar.
|
||||
const { data: existing } = await admin
|
||||
.from("user_integrations")
|
||||
.select("id")
|
||||
.eq("user_id", userId)
|
||||
.eq("mcp_id", mcp.id)
|
||||
.maybeSingle();
|
||||
if (!existing) {
|
||||
return jsonResponse(
|
||||
{ error: "limit_reached", message: "Você atingiu o limite de integrações do seu plano." },
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gera state token
|
||||
const stateToken = generateStateToken();
|
||||
const { error: stErr } = await admin.from("oauth_state_tokens").insert({
|
||||
state_token: stateToken,
|
||||
user_id: userId,
|
||||
mcp_id: mcp.id,
|
||||
});
|
||||
if (stErr) {
|
||||
console.error("oauth_state insert error", stErr);
|
||||
return jsonResponse({ error: "Falha ao iniciar OAuth" }, 500);
|
||||
}
|
||||
|
||||
// Monta URL de autorização
|
||||
const redirectUri = `${supabaseUrl}/functions/v1/oauth-callback`;
|
||||
const env = getProviderEnv(mcp.slug as ProviderSlug, redirectUri);
|
||||
const authUrl = buildAuthorizeUrl(
|
||||
mcp.slug as ProviderSlug,
|
||||
mcp.oauth_authorize_url,
|
||||
(mcp.required_scopes as string[]) ?? [],
|
||||
stateToken,
|
||||
env,
|
||||
);
|
||||
|
||||
return jsonResponse({ auth_url: authUrl });
|
||||
} catch (err) {
|
||||
console.error("oauth-start fatal", err instanceof Error ? err.message : "unknown");
|
||||
return jsonResponse(
|
||||
{ error: err instanceof Error ? err.message : "Erro inesperado" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
184
supabase/functions/refresh-integration-token/index.ts
Normal file
184
supabase/functions/refresh-integration-token/index.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// refresh-integration-token (autenticada JWT)
|
||||
// Renova access_token usando refresh_token. Marca como 'revoked' se invalid_grant.
|
||||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import {
|
||||
getProviderEnv,
|
||||
type ProviderSlug,
|
||||
refreshAccessToken,
|
||||
} 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 } = await req.json() as { integration_id?: string };
|
||||
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, mcp_id, access_token_vault_id, refresh_token_vault_id, mcp:available_mcps(slug, oauth_token_url, supports_refresh_token)",
|
||||
)
|
||||
.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_token_url: string; supports_refresh_token: boolean };
|
||||
|
||||
if (!mcp.supports_refresh_token) {
|
||||
return jsonResponse(
|
||||
{ error: "Provider não suporta refresh token" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (!integ.refresh_token_vault_id) {
|
||||
return jsonResponse(
|
||||
{ error: "Sem refresh token salvo. Reconecte a integração." },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const refreshToken = await getDecryptedSecret(admin, integ.refresh_token_vault_id);
|
||||
if (!refreshToken) {
|
||||
return jsonResponse({ error: "Falha ao ler refresh token do Vault" }, 500);
|
||||
}
|
||||
|
||||
const slug = mcp.slug as ProviderSlug;
|
||||
const redirectUri = `${supabaseUrl}/functions/v1/oauth-callback`;
|
||||
const env = getProviderEnv(slug, redirectUri);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await refreshAccessToken(slug, refreshToken, mcp.oauth_token_url, env);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "unknown";
|
||||
if (msg === "invalid_grant") {
|
||||
await admin
|
||||
.from("user_integrations")
|
||||
.update({
|
||||
status: "revoked",
|
||||
error_message: "Refresh token revogado pelo provider. Reconecte a integração.",
|
||||
})
|
||||
.eq("id", integration_id);
|
||||
return jsonResponse({ error: "Refresh token revogado. Reconecte." }, 401);
|
||||
}
|
||||
return jsonResponse({ error: "Falha ao renovar token" }, 502);
|
||||
}
|
||||
|
||||
// Salva novo access_token no Vault, deleta antigo
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const { data: newAccess, error: vErr } = await admin
|
||||
.rpc("vault_create_secret", {
|
||||
secret_value: result.access_token,
|
||||
secret_name: `oauth_access_${userId}_${slug}_${ts}`,
|
||||
secret_description: `OAuth access token (${slug}) refreshed`,
|
||||
})
|
||||
.single();
|
||||
if (vErr || !newAccess) {
|
||||
return jsonResponse({ error: "Falha ao salvar novo token" }, 500);
|
||||
}
|
||||
const newAccessId = (newAccess as { secret_id: string }).secret_id;
|
||||
|
||||
if (integ.access_token_vault_id) {
|
||||
try {
|
||||
await admin.rpc("vault_delete_secret", {
|
||||
secret_id: integ.access_token_vault_id,
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Se provider rotou refresh_token, salva novo
|
||||
let newRefreshId = integ.refresh_token_vault_id;
|
||||
if (result.refresh_token && result.refresh_token !== refreshToken) {
|
||||
const { data: nrv } = await admin
|
||||
.rpc("vault_create_secret", {
|
||||
secret_value: result.refresh_token,
|
||||
secret_name: `oauth_refresh_${userId}_${slug}_${ts}`,
|
||||
secret_description: `OAuth refresh token (${slug}) rotated`,
|
||||
})
|
||||
.single();
|
||||
if (nrv) {
|
||||
const id = (nrv as { secret_id: string }).secret_id;
|
||||
try {
|
||||
await admin.rpc("vault_delete_secret", {
|
||||
secret_id: integ.refresh_token_vault_id,
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
newRefreshId = id;
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = result.expires_in
|
||||
? new Date(Date.now() + result.expires_in * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
await admin
|
||||
.from("user_integrations")
|
||||
.update({
|
||||
status: "active",
|
||||
access_token_vault_id: newAccessId,
|
||||
refresh_token_vault_id: newRefreshId,
|
||||
token_expires_at: expiresAt,
|
||||
last_refreshed_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
})
|
||||
.eq("id", integration_id);
|
||||
|
||||
return jsonResponse({ success: true, expires_at: expiresAt });
|
||||
} catch (err) {
|
||||
console.error("refresh-integration-token fatal", err instanceof Error ? err.message : "unknown");
|
||||
return jsonResponse(
|
||||
{ error: err instanceof Error ? err.message : "Erro inesperado" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue