Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
188 changes: 188 additions & 0 deletions docs/WEBHOOKS_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Webhooks & Edge Functions — Contrato de Validação

Documento de referência para o formato unificado de respostas de erro, o
versionamento de contratos (v1/v2) e a infraestrutura de testes que garante
compatibilidade retroativa entre versões.

## Endpoints sob contrato

**Webhooks principais** (schema canônico + testes de contrato Vitest):

| Endpoint | Schema | Auth |
|-----------------------|-------------------------------------|--------------------------------------------|
| `product-webhook` | `ProductWebhookPayloadSchema` | `x-webhook-secret` |
| `webhook-dispatcher` | `DispatcherBodySchema` | `x-dispatcher-secret` ou JWT supervisor |
| `webhook-inbound` | `InboundWebhookEnvelopeSchema` | HMAC SHA-256 (`x-signature-256`) |

**Demais Edge Functions** (35) — todas usam o helper unificado
`buildValidationErrorResponse`. CI guard
(`npm run check:unified-validation`) bloqueia novos endpoints que voltem ao
padrão inline:

```
ai-recommendations, analyze-logo-colors, bitrix-sync, categories-api,
cnpj-lookup, comparison-ai-advisor, commemorative-dates, crm-db-bridge,
detect-new-device, dropbox-list, elevenlabs-tts, expert-chat,
external-db-bridge, external-db-inspect, full-op-diagnostics,
generate-ad-image, generate-ad-prompt, generate-mockup,
generate-product-seo, kit-identity-suggest, log-login-attempt,
magic-up-score, manage-users, materials-api, mcp-keys-issue,
mcp-keys-revoke, mcp-keys-rotate, mcp-keys-update, quote-sync,
rate-limit-check, secrets-manager, semantic-search, send-notification,
sync-quote-bitrix, verify-email, visual-search, voice-agent
```

**Exempções intencionais:**
- `validate-access` — schema com `passthrough()` que nunca rejeita; é um
fallback silencioso (security check defensive, não API endpoint).

Os schemas dos 3 webhooks principais vivem em
`supabase/functions/_shared/webhook-schemas.ts` (Deno) e têm um mirror em
`src/lib/webhook-schemas.ts` (Node) — usado pelos testes Vitest. A paridade
é garantida por `tests/edge-functions/webhook-schemas-parity.test.ts`.

## Formato unificado de erro 422

Toda falha de validação retorna **HTTP 422 Unprocessable Entity**.

### v1 (default, retrocompatível)

```json
{
"error": "Validation failed",
"details": {
"sku": ["String must contain at least 1 character(s)"],
"price": ["Expected number, received string"]
}
}
```

### v2 (canônico, recomendado)

```json
{
"code": "validation_failed",
"message": "Validation failed",
"version": "v2",
"fields": [
{ "path": "product.sku", "code": "too_small", "message": "String must contain at least 1 character(s)" },
{ "path": "product.price", "code": "invalid_type", "message": "Expected number, received string" }
]
}
```

Diferenças chave:

- v2 carrega `code` machine-readable estável (`validation_failed`).
- v2 expressa **paths aninhados** com dot-notation (`product.images.0`).
- v2 preserva o `code` original do Zod (`too_small`, `invalid_type`,
`invalid_enum_value`, `invalid_string`, `custom`, ...).
- v2 nunca perde informação que estaria em v1: cada chave de `details` em v1
corresponde ao prefixo de pelo menos um `fields[].path` em v2 (verificado
em `webhook-schemas.contract.test.ts > contract versioning`).

## Negociação de versão

Ordem de prioridade (primeiro match vence):

1. Query string: `?api_version=v2` ou `?version=v2`
2. Header: `X-API-Version: v2` (ou `2`)
3. Accept: `application/vnd.promogifts.v2+json`
4. Default: **v1**

A versão efetiva é refletida no response header `X-API-Version`.

## Outros erros canônicos

Todos seguem o mesmo envelope (v1: `{error, details}`; v2: `{code, message, version, fields}`):

| Status | code (v2) | Cenário |
|--------|-------------------------|------------------------------------------|
| 400 | `empty_body` | Body vazio em endpoint que exige body |
| 400 | `invalid_json` | Body não é JSON válido |
| 401 | `unauthorized` | Auth ausente/inválida |
| 401 | `invalid_signature` | HMAC inválido (webhook-inbound) |
| 404 | `not_found` | Recurso (delivery, webhook, endpoint) |
| 422 | `validation_failed` | Schema Zod falhou |
| 500 | `internal_error` | Erro não capturado |

## Testes de contrato

Há duas camadas de cobertura:

### 1) Schema isolado (offline, rápido)

Executado em CI via `npm run test`. Arquivos em `tests/edge-functions/`:

- `validation-errors.test.ts` — 19 testes da infra de respostas (negociação,
builders v1/v2, invariantes).
- `webhook-schemas.contract.test.ts` — 47 testes dos schemas (happy path,
campos ausentes, tipos incorretos, valores vazios, regras cross-field,
limites de tamanho, propagação de erros aninhados, e a invariante
v1 ⊂ v2 que sustenta a deprecação segura de v1).
- `webhook-schemas-parity.test.ts` — 3 testes que garantem que o mirror Node
é byte-idêntico ao canônico Deno (exceto pelo import path).

```bash
npm run test -- tests/edge-functions/
# → 101 testes, todos passam em ~3s
```

### 2) End-to-end HTTP (online, contra deploy)

`scripts/contract-testing.mjs` (`npm run test:contract`) faz chamadas reais
contra a Edge Function deployada. Cobre o ciclo completo: cabeçalhos de
auth, parsing do body, schema, e shape da resposta — em ambas as versões.

```bash
SUPABASE_SERVICE_ROLE_KEY=... npm run test:contract
```

## Como adicionar contrato a um endpoint novo

1. Defina o schema em `supabase/functions/_shared/webhook-schemas.ts` e
espelhe em `src/lib/webhook-schemas.ts` (a paridade roda em CI).

2. Na Edge Function, troque o boilerplate manual por:

```ts
import { buildErrorResponse, buildValidationErrorResponse }
from "../_shared/validation-errors.ts";
import { MeuSchema } from "../_shared/webhook-schemas.ts";

// ... dentro do handler:
const parsed = MeuSchema.safeParse(rawBody);
if (!parsed.success) {
return buildValidationErrorResponse(parsed.error, req, corsHeaders);
}
```

Ou, mais conciso, use o helper existente:

```ts
import { parseBodyWithSchema } from "../_shared/zod-validate.ts";

const result = await parseBodyWithSchema(req, MeuSchema, corsHeaders);
if ("error" in result) return result.error;
const payload = result.data;
```

3. Adicione cenários ao `webhook-schemas.contract.test.ts` cobrindo no
mínimo: happy path, cada campo obrigatório ausente, cada tipo errado,
cada string obrigatória vazia, cada regra cross-field.

4. Adicione cenários ao `scripts/contract-testing.mjs` validando o
round-trip HTTP em v1 e v2.

## Deprecação de v1

Quando v1 for descontinuado:

1. Anuncie via `Deprecation: true` e `Sunset: <date>` headers nas respostas
v1 (a infra atual já suporta — basta estender `buildValidationError`).
2. Mantenha as duas versões em paralelo por **≥90 dias** após o anúncio.
3. O teste `contract versioning: v1 ↔ v2 backwards compatibility` garante
que nenhuma informação semântica é perdida durante a transição.
4. Após o sunset, remova `buildValidationErrorV1` e o branch de detecção
v1 em `detectContractVersion` — os testes de paridade falharão
automaticamente até a remoção ser propagada para ambos os mirrors.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@
"check:toast-leaks": "node scripts/check-toast-leaks.mjs",
"test:stress": "node scripts/massive-load-test.mjs",
"test:fuzz:full": "node scripts/fuzz-testing.mjs",
"test:contract": "node scripts/contract-testing.mjs"
"test:contract": "node scripts/contract-testing.mjs",
"check:unified-validation": "node scripts/check-unified-validation-errors.mjs"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
Expand Down
72 changes: 72 additions & 0 deletions scripts/check-unified-validation-errors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* CI guard: forbid inline validation-error responses in Edge Functions.
*
* Forces every new endpoint to use the unified helpers in
* `_shared/validation-errors.ts` so the v1/v2 contract stays consistent.
*
* Run from CI: node scripts/check-unified-validation-errors.mjs
*
* Exit 0 = clean, exit 1 = regressions found.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve, join } from 'node:path';

const FUNCTIONS_DIR = resolve('supabase/functions');
const SHARED = '_shared';
// Endpoints intentionally exempt from this rule.
const EXEMPT = new Set([
// Silent-fallback intake; never returns a validation error.
'validate-access',
]);

// Patterns that signal an inline validation-error response.
const FORBIDDEN_PATTERNS = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: CI guard can be bypassed by constructing the error object in a variable before passing to JSON.stringify/jsonResponse. The three regex patterns only match inline object literals, so variable-assigned error objects bypass detection entirely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/check-unified-validation-errors.mjs, line 24:

<comment>CI guard can be bypassed by constructing the error object in a variable before passing to JSON.stringify/jsonResponse. The three regex patterns only match inline object literals, so variable-assigned error objects bypass detection entirely.</comment>

<file context>
@@ -0,0 +1,72 @@
+]);
+
+// Patterns that signal an inline validation-error response.
+const FORBIDDEN_PATTERNS = [
+  // new Response(JSON.stringify({ error: "Validation failed" | "Invalid input" | ... + ZodErr.flatten() }))
+  /JSON\.stringify\(\s*\{[^{}]*error:[^{}]*["'](?:Validation failed|Invalid input|Dados inválidos|Payload inválido|invalid_input|validation_failed)["'][^{}]*\.error\.flatten\(\)[^{}]*\}\s*\)/,
</file context>

// new Response(JSON.stringify({ error: "Validation failed" | "Invalid input" | ... + ZodErr.flatten() }))
/JSON\.stringify\(\s*\{[^{}]*error:[^{}]*["'](?:Validation failed|Invalid input|Dados inválidos|Payload inválido|invalid_input|validation_failed)["'][^{}]*\.error\.flatten\(\)[^{}]*\}\s*\)/,
// jsonResponse({error: ..., fields: ZodErr.flatten...}, 422 or 400, requestId)
/jsonResponse\(\s*\{[^{}]*error:[^{}]*["']validation_failed["'][^{}]*fields[^{}]*\}\s*,\s*4\d\d/,
// Direct dump of ZodErr.flatten() as the error message (no canonical envelope).
/JSON\.stringify\(\s*\{\s*error:\s*\w+\.error\.flatten\(\)/,
];

function listDirs(p) {
return readdirSync(p).filter((n) => {
const full = join(p, n);
return statSync(full).isDirectory();
});
}

const violations = [];

for (const fn of listDirs(FUNCTIONS_DIR)) {
if (fn === SHARED || EXEMPT.has(fn)) continue;
const file = join(FUNCTIONS_DIR, fn, 'index.ts');
let src;
try {
src = readFileSync(file, 'utf8');
} catch {
continue; // no index.ts
}
for (const pat of FORBIDDEN_PATTERNS) {
if (pat.test(src)) {
violations.push({ fn, pattern: pat.source.slice(0, 80) });
}
}
}

if (violations.length === 0) {
console.log('✅ All Edge Functions use the unified validation error envelope.');
process.exit(0);
}

console.error('❌ Inline validation-error responses detected.');
console.error(' Migrate to buildValidationErrorResponse / buildValidationErrorV2');
console.error(' from supabase/functions/_shared/validation-errors.ts');
console.error('');
for (const v of violations) {
console.error(` • ${v.fn} — matched: /${v.pattern}.../`);
}
console.error('');
console.error('See docs/WEBHOOKS_CONTRACT.md for migration examples.');
process.exit(1);
Loading