diff --git a/supabase/functions/_shared/contracts/_zod.ts b/supabase/functions/_shared/contracts/_zod.ts new file mode 100644 index 000000000..50da9e527 --- /dev/null +++ b/supabase/functions/_shared/contracts/_zod.ts @@ -0,0 +1,12 @@ +/** + * _zod.ts — Pinning ÚNICO de Zod para todo o projeto. + * + * Esta é a ÚNICA URL de Zod que pode existir em qualquer arquivo do projeto. + * Todos os demais módulos (incluindo `index.ts` deste pacote) devem importar + * `z` daqui via path relativo. + * + * Para subir/descer versão de Zod, edite somente este arquivo. + * + * Regra reforçada por ESLint (no-restricted-imports) em `eslint.config.js`. + */ +export { z } from "https://esm.sh/zod@3.23.8"; diff --git a/supabase/functions/_shared/zod-validate.ts b/supabase/functions/_shared/zod-validate.ts index aa5d40f6e..c5882181b 100644 --- a/supabase/functions/_shared/zod-validate.ts +++ b/supabase/functions/_shared/zod-validate.ts @@ -4,8 +4,8 @@ */ // Using Zod from esm.sh for Deno compatibility -export { z } from "https://esm.sh/zod@3.23.8"; -import { z } from "https://esm.sh/zod@3.23.8"; +export { z } from "./contracts/_zod.ts"; +import { z } from "./contracts/_zod.ts"; /** * Parse and validate a request body against a Zod schema. diff --git a/supabase/functions/webhook-inbound/index.ts b/supabase/functions/webhook-inbound/index.ts index 416dbc406..6574e7156 100644 --- a/supabase/functions/webhook-inbound/index.ts +++ b/supabase/functions/webhook-inbound/index.ts @@ -15,29 +15,29 @@ // Cliente seleciona via header `accept-version: 2` ou `?v=2`. // v1 será descontinuada em 2026-06-30; resposta inclui headers Deprecation/Sunset + warning explícito. -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; -import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; -import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; -import { buildPublicCorsHeaders } from "../_shared/cors.ts"; -import { parseContract } from "../_shared/contracts/index.ts"; -import { WebhookInboundSchemas } from "../_shared/contracts/schemas/webhook-inbound.ts"; -import { runBotProtection } from "../_shared/bot-protection.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.49.4'; +import { crypto } from 'https://deno.land/std@0.224.0/crypto/mod.ts'; +import { encodeHex } from 'https://deno.land/std@0.224.0/encoding/hex.ts'; +import { buildPublicCorsHeaders } from '../_shared/cors.ts'; +import { parseContract } from '../_shared/contracts/index.ts'; +import { WebhookInboundSchemas } from '../_shared/contracts/schemas/webhook-inbound.ts'; +import { runBotProtection } from '../_shared/bot-protection.ts'; const corsHeaders = buildPublicCorsHeaders({ - extraAllowHeaders: ["x-signature-256", "x-event", "accept-version"], - allowMethods: "POST, OPTIONS", + extraAllowHeaders: ['x-signature-256', 'x-event', 'accept-version'], + allowMethods: 'POST, OPTIONS', }); async function hmacSign(payload: string, secret: string): Promise { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( - "raw", + 'raw', enc.encode(secret), - { name: "HMAC", hash: "SHA-256" }, + { name: 'HMAC', hash: 'SHA-256' }, false, - ["sign"], + ['sign'], ); - const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload)); + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload)); return encodeHex(new Uint8Array(sig)); } @@ -49,11 +49,11 @@ function timingSafeEqual(a: string, b: string): boolean { } function readRequestedVersion(req: Request): string | null { - const headerVal = req.headers.get("accept-version"); - if (headerVal) return headerVal.replace(/^v/i, "").split(".")[0].trim(); + const headerVal = req.headers.get('accept-version'); + if (headerVal) return headerVal.replace(/^v/i, '').split('.')[0].trim(); try { - const qv = new URL(req.url).searchParams.get("v"); - if (qv) return qv.replace(/^v/i, "").split(".")[0].trim(); + const qv = new URL(req.url).searchParams.get('v'); + if (qv) return qv.replace(/^v/i, '').split('.')[0].trim(); } catch { // no-op } @@ -61,14 +61,17 @@ function readRequestedVersion(req: Request): string | null { } function parseAllowlist(): Set { - const raw = Deno.env.get("WEBHOOK_INBOUND_V1_ALLOWLIST") ?? ""; + const raw = Deno.env.get('WEBHOOK_INBOUND_V1_ALLOWLIST') ?? ''; return new Set( - raw.split(",").map((x) => x.trim()).filter(Boolean), + raw + .split(',') + .map((x) => x.trim()) + .filter(Boolean), ); } Deno.serve(async (req) => { - if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }); // OPS-002: rate-limit anti-DoS por IP antes de qualquer trabalho de DB. // Webhooks legítimos têm baixa cadência (≪60/min por IP); caller espurioso @@ -76,7 +79,7 @@ Deno.serve(async (req) => { const protection = await runBotProtection( req, { - endpoint: "webhook-inbound", + endpoint: 'webhook-inbound', maxRequests: 60, windowSeconds: 60, blockSeconds: 1800, @@ -87,32 +90,35 @@ Deno.serve(async (req) => { if (!protection.allowed) return protection.blockResponse!; const supabase = createClient( - Deno.env.get("SUPABASE_URL")!, - Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, ); try { const url = new URL(req.url); - const slug = url.searchParams.get("slug") - || url.pathname.split("/").filter(Boolean).pop() - || ""; + const slug = + url.searchParams.get('slug') || url.pathname.split('/').filter(Boolean).pop() || ''; if (!slug) { return new Response( - JSON.stringify({ code: "missing_slug", message: "slug ausente", fields: [] }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + JSON.stringify({ code: 'missing_slug', message: 'slug ausente', fields: [] }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }, ); } const { data: endpoint } = await supabase - .from("inbound_webhook_endpoints") - .select("*") - .eq("slug", slug) - .eq("active", true) + .from('inbound_webhook_endpoints') + .select('*') + .eq('slug', slug) + .eq('active', true) .maybeSingle(); if (!endpoint) { return new Response( - JSON.stringify({ code: "endpoint_not_found", message: "endpoint não encontrado", fields: [] }), - { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + JSON.stringify({ + code: 'endpoint_not_found', + message: 'endpoint não encontrado', + fields: [], + }), + { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }, ); } @@ -130,96 +136,101 @@ Deno.serve(async (req) => { const { version, data: payloadParsed, responseHeaders } = contractResult; const requestedVersion = readRequestedVersion(req); const isDefaultVersion = !requestedVersion; - const issuer = req.headers.get("x-webhook-issuer")?.trim() || slug; + const issuer = req.headers.get('x-webhook-issuer')?.trim() || slug; - const v1CompatEnabled = (Deno.env.get("WEBHOOK_INBOUND_V1_COMPAT_ENABLED") ?? "false") - .toLowerCase() === "true"; + const v1CompatEnabled = + (Deno.env.get('WEBHOOK_INBOUND_V1_COMPAT_ENABLED') ?? 'false').toLowerCase() === 'true'; const v1Allowlist = parseAllowlist(); const v1AllowedIssuer = v1Allowlist.has(issuer) || v1Allowlist.has(slug); - console.info(JSON.stringify({ - metric: "webhook_inbound_contract_version_adoption", - endpoint: slug, - issuer, - contract_version: version, - is_default_version: isDefaultVersion, - requested_version: requestedVersion ?? "default", - })); + console.info( + JSON.stringify({ + metric: 'webhook_inbound_contract_version_adoption', + endpoint: slug, + issuer, + contract_version: version, + is_default_version: isDefaultVersion, + requested_version: requestedVersion ?? 'default', + }), + ); - if (version === "1" && (!v1CompatEnabled || !v1AllowedIssuer)) { + if (version === '1' && (!v1CompatEnabled || !v1AllowedIssuer)) { return new Response( JSON.stringify({ - code: "legacy_version_blocked", + code: 'legacy_version_blocked', message: - "v1 temporariamente restrita: solicite migração para envelope v2 ou peça allowlist de emissor legado.", - fields: ["accept-version"], + 'v1 temporariamente restrita: solicite migração para envelope v2 ou peça allowlist de emissor legado.', + fields: ['accept-version'], }), { status: 426, headers: { ...corsHeaders, ...responseHeaders, - "Content-Type": "application/json", - "Warning": - '299 - "Webhook v1 está em sunset (2026-06-30). Use v2 envelope."', + 'Content-Type': 'application/json', + Warning: '299 - "Webhook v1 está em sunset (2026-06-30). Use v2 envelope."', }, }, ); } - const signatureHeader = req.headers.get("x-signature-256") - || req.headers.get("x-webhook-signature") - || ""; - const eventType = req.headers.get("x-event") - || (typeof payloadParsed === "object" && payloadParsed !== null && "event" in payloadParsed + const signatureHeader = + req.headers.get('x-signature-256') || req.headers.get('x-webhook-signature') || ''; + const eventType = + req.headers.get('x-event') || + (typeof payloadParsed === 'object' && payloadParsed !== null && 'event' in payloadParsed ? String((payloadParsed as { event: unknown }).event) - : "unknown"); - const sourceIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || null; + : 'unknown'); + const sourceIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || null; const secretRes = await supabase - .from("integration_credentials") - .select("secret_value") - .eq("secret_name", endpoint.hmac_secret_ref) + .from('integration_credentials') + .select('secret_value') + .eq('secret_name', endpoint.hmac_secret_ref) .maybeSingle(); const secret = secretRes.data?.secret_value || Deno.env.get(endpoint.hmac_secret_ref); let signatureValid = false; if (secret) { - const expected = "sha256=" + await hmacSign(rawBody, secret); - const provided = signatureHeader.startsWith("sha256=") + const expected = 'sha256=' + (await hmacSign(rawBody, secret)); + const provided = signatureHeader.startsWith('sha256=') ? signatureHeader - : "sha256=" + signatureHeader; + : 'sha256=' + signatureHeader; signatureValid = timingSafeEqual(expected, provided); } - await supabase.from("inbound_webhook_events").insert({ + await supabase.from('inbound_webhook_events').insert({ endpoint_id: endpoint.id, event_type: eventType, payload: payloadParsed, signature_valid: signatureValid, processed: signatureValid, source_ip: sourceIp, - error: signatureValid ? null : "HMAC inválido ou ausente", + error: signatureValid ? null : 'HMAC inválido ou ausente', contract_version: version, }); await supabase - .from("inbound_webhook_endpoints") + .from('inbound_webhook_endpoints') .update({ last_received_at: new Date().toISOString(), total_received: (endpoint.total_received ?? 0) + 1, total_invalid: (endpoint.total_invalid ?? 0) + (signatureValid ? 0 : 1), }) - .eq("id", endpoint.id); + .eq('id', endpoint.id); - const okHeaders = { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }; - if (version === "1") { - okHeaders["Warning"] = '299 - "Webhook v1 deprecado; sunset em 2026-06-30. Migre para v2."'; + const okHeaders: Record = { + ...corsHeaders, + ...responseHeaders, + 'Content-Type': 'application/json', + }; + if (version === '1') { + okHeaders['Warning'] = '299 - "Webhook v1 deprecado; sunset em 2026-06-30. Migre para v2."'; } if (!signatureValid) { return new Response( - JSON.stringify({ code: "invalid_signature", message: "Assinatura inválida", fields: [] }), + JSON.stringify({ code: 'invalid_signature', message: 'Assinatura inválida', fields: [] }), { status: 401, headers: okHeaders }, ); } @@ -228,17 +239,18 @@ Deno.serve(async (req) => { JSON.stringify({ ok: true, received: true, - warning: version === "1" - ? "Webhook v1 deprecado; sunset em 2026-06-30. Migre para envelope v2." - : undefined, + warning: + version === '1' + ? 'Webhook v1 deprecado; sunset em 2026-06-30. Migre para envelope v2.' + : undefined, }), { headers: okHeaders }, ); } catch (err) { - const msg = err instanceof Error ? err.message : "Erro"; - return new Response( - JSON.stringify({ code: "internal_error", message: msg, fields: [] }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, - ); + const msg = err instanceof Error ? err.message : 'Erro'; + return new Response(JSON.stringify({ code: 'internal_error', message: msg, fields: [] }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); } });