Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e189dc8
feat(contracts): scaffold barrel export (test push)
adm01-debug May 21, 2026
3bb221b
feat(contracts): add errors/versioning/parse helpers + vitest alias f…
adm01-debug May 21, 2026
40f36db
feat(contracts): add v1/v2 schemas for product-webhook, webhook-inbou…
adm01-debug May 21, 2026
271af38
refactor(webhooks): migrate product-webhook and webhook-inbound to pa…
adm01-debug May 21, 2026
eb32ced
refactor(webhook-dispatcher): migrate to parseContract; v2 uses discr…
adm01-debug May 21, 2026
f602270
test(contracts): add unit tests for errors + versioning (14 tests)
adm01-debug May 21, 2026
8a65b75
test(contracts): add contract tests for product-webhook (13) + inboun…
adm01-debug May 21, 2026
e15a092
refactor(scripts): rewrite contract-testing.mjs — consume central sch…
adm01-debug May 21, 2026
0c1a069
docs(contracts): add README + MIGRATION_GUIDE (priorized P0/P1/P2 lis…
adm01-debug May 21, 2026
3f0d7df
feat(contracts): P0 schemas (1/4) — send-transactional-email, kit-ai-…
adm01-debug May 22, 2026
0d2b75b
feat(contracts): P0 schemas (2/4) — market-intelligence-insights, ste…
adm01-debug May 22, 2026
d49ef3e
feat(contracts): P1 schemas (3/4) — ownership-audit, ownership-repair…
adm01-debug May 22, 2026
c4108de
feat(contracts): P1+P2 schemas (4/4) — trends-insights, force-global-…
adm01-debug May 22, 2026
efc1490
feat(contracts): handler P0 — send-transactional-email migrado para p…
adm01-debug May 22, 2026
4e9d531
feat(contracts): handlers P0 — kit-ai-builder, bi-copilot migrados pa…
adm01-debug May 22, 2026
a64b631
feat(contracts): handler P0 — market-intelligence-insights migrado pa…
adm01-debug May 22, 2026
d35b8f9
feat(contracts): handler P0 — step-up-verify migrado para parseContract
adm01-debug May 22, 2026
dd9b5ea
feat(contracts): handlers P1 (1/2) — ownership-audit, ownership-repai…
adm01-debug May 22, 2026
9f3c139
feat(contracts): handler P1 (2/2) — simulation-orchestrator migrado p…
adm01-debug May 22, 2026
623cb41
feat(contracts): handlers P2 (1/2) — force-global-logout, block-ip-te…
adm01-debug May 22, 2026
e19a02a
feat(contracts): handler P2 (2/2) — e2e-cleanup migrado para parseCon…
adm01-debug May 22, 2026
3aa25f1
test(contracts): adiciona 49 testes de contrato para os 13 endpoints …
adm01-debug May 22, 2026
56ea2f8
refactor(contracts): pin Zod via _zod.ts barrel (core do pacote)
adm01-debug May 22, 2026
bea6aae
refactor(contracts): schemas A-O importam Zod via ../_zod.ts
adm01-debug May 22, 2026
7d2cfe5
Merge main into PR 155
May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions supabase/functions/_shared/contracts/_zod.ts
Original file line number Diff line number Diff line change
@@ -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`.
Comment on lines +2 to +10
*/
export { z } from "https://esm.sh/zod@3.23.8";
4 changes: 2 additions & 2 deletions supabase/functions/_shared/zod-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
174 changes: 93 additions & 81 deletions supabase/functions/webhook-inbound/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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));
}

Expand All @@ -49,34 +49,37 @@ 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
}
return null;
}

function parseAllowlist(): Set<string> {
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
// que ultrapassa é blocked por 30min e nunca chega no INSERT.
const protection = await runBotProtection(
req,
{
endpoint: "webhook-inbound",
endpoint: 'webhook-inbound',
maxRequests: 60,
windowSeconds: 60,
blockSeconds: 1800,
Expand All @@ -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' } },
);
}

Expand All @@ -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<string, string> = {
...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 },
);
}
Expand All @@ -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' },
});
}
});
Loading