Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
485f032
merge: integrate OPS-002 bot-protection (resolves PR #87 conflict pre…
adm01-debug May 22, 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
191 changes: 191 additions & 0 deletions docs/contracts/MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Contract migration guide

Como migrar uma Edge Function legada para o pacote `_shared/contracts`.

---

## Quando migrar

Se sua função:

- Recebe payload externo via `req.json()` ou `req.text()`, **e**
- Não usa `parseContract`,

então ela é candidata. Auditoria 2026-05-21 listou 14 candidatas; abaixo a
lista priorizada:

### P0 (próximas a migrar)

1. `send-transactional-email` — sem nenhuma validação runtime
2. `kit-ai-builder` — payload livre vai direto pro modelo
3. `market-intelligence-insights` — idem
4. `bi-copilot` — query SQL aceita string livre
5. `step-up-verify` — fluxo de autenticação sensível

### P1

6. `ownership-audit`
7. `ownership-repair`
8. `simulation-orchestrator`
9. `sync-external-db`
10. `trends-insights`

### P2 (já têm guarda mas merecem schema explícito)

11. `force-global-logout`
12. `e2e-cleanup`
13. `block-ip-temporarily`

---

## Receita em 5 passos

### 1. Criar o schema

`supabase/functions/_shared/contracts/schemas/<endpoint>.ts`:

```ts
import { z } from "https://esm.sh/zod@3.23.8";

export const SendEmailV1 = z.object({
event_type: z.enum(["quote_sent", "quote_approved", "quote_rejected", "order_created"]),
recipient_email: z.string().email().max(255),
recipient_name: z.string().max(150).optional(),
data: z.record(z.unknown()),
});

export const SendEmailV2 = SendEmailV1.extend({
idempotency_key: z.string().uuid(),
}).strict();

export const SendTransactionalEmailSchemas = {
name: "send-transactional-email",
versions: { "1": SendEmailV1, "2": SendEmailV2 },
defaultVersion: "1" as const,
deprecated: [{ version: "1", sunset: "2026-10-31", migrationUrl: "..." }],
};
```

### 2. Importar no index.ts

```ts
import { parseContract } from "../_shared/contracts/index.ts";
import { SendTransactionalEmailSchemas } from "../_shared/contracts/schemas/send-transactional-email.ts";
```

### 3. Substituir o parsing manual

**Antes:**

```ts
const body = await req.json();
if (!body.event_type) {
return new Response(JSON.stringify({ error: "event_type required" }), { status: 400 });
}
```

**Depois:**

```ts
const result = await parseContract(req, SendTransactionalEmailSchemas, { corsHeaders });
if (!result.ok) return result.response;
const { version, data, responseHeaders } = result;
```

### 4. Anexar headers de versionamento nas respostas de sucesso

```ts
const okHeaders = { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" };
return new Response(JSON.stringify({ ok: true }), { headers: okHeaders });
```

### 5. Adicionar testes em `tests/contracts/`

Veja `tests/contracts/product-webhook.contract.test.ts` como template.
Mínimo de 5 cenários: válido v1, válido v2, missing field, wrong type, version negotiation.

---

## Casos especiais

### Função que precisa do raw body (HMAC, signing)

Use `prereadBody` para evitar ler o stream duas vezes:

```ts
const rawBody = await req.text(); // lê 1x para HMAC
// ...calcula assinatura usando rawBody...

const result = await parseContract(req, MySchemas, {
corsHeaders,
prereadBody: rawBody, // helper reusa o que já foi lido
});
```

Padrão usado em `webhook-inbound` migrado neste PR.

### Função com múltiplos verbos (action / mode)

Use **discriminated union** em v2:

```ts
export const MyV2 = z.discriminatedUnion("mode", [
z.object({ mode: z.literal("create"), data: z.object({ /* ... */ }) }).strict(),
z.object({ mode: z.literal("delete"), id: z.string().uuid() }).strict(),
]);
```

Padrão usado em `webhook-dispatcher` migrado neste PR.

### Backward compatibility

**Regra de ouro**: v1 do schema **deve ser idêntica** ao shape aceito hoje
em produção. Nada de "aproveitar a migração pra apertar uma validação que
sempre quis apertar". Aperte na v2.

Mudanças de v1 → v2:

| Mudança permitida em v2 | Exemplo |
| --- | --- |
| `.strict()` (rejeita campos extras) | `MyV1.strict()` |
| Tornar opcional → obrigatório | `external_id: z.string()` |
| Estreitar enum (remover valor) | `action: z.enum(["upsert", "delete"])` (sem `sync`) |
| Forçar formato ISO em datas | `z.string().datetime()` |
| Adicionar campos obrigatórios | `idempotency_key: z.string().uuid()` |

Tudo isso seria **breaking change** se feito na v1; em v2, o cliente opt-in
explícito via `accept-version: 2`.

---

## Específicos do projeto

### product-webhook v1 → v2

| Campo | v1 | v2 |
| --- | --- | --- |
| `action` | `sync \| upsert \| delete \| batch_upsert` | `upsert \| delete \| batch_upsert` (sem `sync`) |
| `external_id` | opcional | **obrigatório** |
| `idempotency_key` | — | **obrigatório** (UUID) |
| Strict mode | passthrough | `.strict()` |
| Validação cruzada | — | `action=delete` requer `external_ids[]`; `batch_upsert` requer `products[]` não-vazio |

Sunset v1: **2026-08-31**.

### webhook-inbound v1 → v2

| | v1 (atual) | v2 |
| --- | --- | --- |
| Body | qualquer JSON | envelope `{event, occurred_at, data, idempotency_key?}` |
| Validação | nenhuma (lixo no DB) | `event` slug-like, `occurred_at` ISO, `data` objeto |

Sunset v1: **2026-09-30**.

### webhook-dispatcher v1 → v2

| | v1 | v2 |
| --- | --- | --- |
| Shape | flat com flags (`test_mode`, `replay_delivery_id`) | discriminated union por `mode: "dispatch" \| "replay" \| "test"` |
| Combinações inválidas | passavam pelo parsing | rejeitadas no schema |

Sunset v1: **2026-09-30**.
156 changes: 156 additions & 0 deletions docs/contracts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Contract validation package

Pacote canônico de validação de payload e versionamento de Edge Functions
do Promo Gifts V4. Substitui o padrão antigo (cada função inventando seu
próprio formato de erro e usando HTTP 400 para tudo).

## Por que existe

Antes deste pacote (auditoria 2026-05-21):

- 82 Edge Functions, **49** recebem payload externo, apenas **13** validavam com Zod.
- 3 formatos de erro diferentes conviviam: `{error}`, `{error, details}`, `{error: "internal_error"}`.
- HTTP 400 era retornado tanto para JSON quebrado quanto para falha semântica do schema.
- Nenhum endpoint tinha versionamento — qualquer mudança quebrava clientes em produção.

Este pacote resolve isso com **uma porta de entrada única**: `parseContract`.

## Estrutura

```
supabase/functions/_shared/contracts/
├── index.ts # barrel export
├── errors.ts # builders 400 / 422 / 406 com formato {code, message, fields[]}
├── versioning.ts # negociação via accept-version | ?v= | default
├── parse.ts # parseContract(req, schemas) — orquestrador
└── schemas/ # schemas por endpoint, sempre múltiplas versões
├── product-webhook.ts
├── webhook-inbound.ts
└── webhook-dispatcher.ts
```

## Uso rápido

```ts
import { parseContract } from "../_shared/contracts/index.ts";
import { ProductWebhookSchemas } from "../_shared/contracts/schemas/product-webhook.ts";

const result = await parseContract(req, ProductWebhookSchemas, { corsHeaders });
if (!result.ok) return result.response; // 400 / 422 / 406 já formatado

const { version, data, responseHeaders } = result;
// data tem o tipo correspondente à versão resolvida
// responseHeaders inclui x-contract-version + (Deprecation, Sunset) se aplicável
```

## Formato de erro canônico

Toda resposta de falha de validação tem este shape:

```json
{
"code": "validation_failed",
"message": "One or more fields are invalid.",
"fields": [
{ "path": "product.price", "message": "Expected number, got string", "code": "invalid_type" },
{ "path": "product.images[0]", "message": "Invalid url", "code": "invalid_string" }
],
"version": "1",
"request_id": "..." // opcional
}
```

Códigos possíveis:

| code | HTTP | quando |
| ----------------------------- | ---- | ----------------------------------- |
| `missing_body` | 400 | body vazio |
| `invalid_json` | 400 | body não é JSON válido |
| `validation_failed` | 422 | JSON OK mas campos inválidos |
| `unsupported_version` | 406 | `accept-version` fora de `supported`|

Path notation em `fields[].path`:

- Objetos aninhados: `product.sku`, `endpoint.config.timeout`
- Arrays: `images[0]`, `products[3].sku`
- Raiz: `$`

## Versionamento

Três jeitos do cliente pedir uma versão (em ordem de prioridade):

1. Header **`accept-version: 2`** (preferido — RFC-style)
2. Query **`?v=2`**
3. Default da função (declarado no schema)

Versões em depreciação continuam funcionando, mas a resposta carrega
(por RFC 8594):

```
Deprecation: true
Sunset: Mon, 31 Aug 2026 00:00:00 GMT
Link: <https://docs/...>; rel="deprecation"
```

Versões inexistentes retornam **406** com `code=unsupported_version` e a
lista de versões aceitas no `message`.

## Adicionando um novo schema

```ts
// supabase/functions/_shared/contracts/schemas/<endpoint>.ts
import { z } from "https://esm.sh/zod@3.23.8";

export const MyEndpointV1 = z.object({ /* ... */ });
export const MyEndpointV2 = z.object({ /* ... */ }).strict();

export const MyEndpointSchemas = {
name: "my-endpoint",
versions: {
"1": MyEndpointV1,
"2": MyEndpointV2,
},
defaultVersion: "1" as const,
deprecated: [
{ version: "1", sunset: "2026-12-31", migrationUrl: "https://..." },
],
};
```

Boas práticas para v2:

- Use `.strict()` — rejeita campos desconhecidos.
- Adicione `idempotency_key` quando o endpoint causa side-effects.
- Datas como `z.string().datetime()` (ISO 8601 obrigatório).
- Discriminated unions (`z.discriminatedUnion`) para verbos múltiplos no mesmo endpoint.

## Testando

Testes vivem em `tests/contracts/` (vitest). Resolvedor de URL → npm está
configurado no `vitest.config.ts`:

```ts
{ find: /^https:\/\/esm\.sh\/zod@.*$/, replacement: 'zod' }
```

Cada novo schema **deve** vir com testes para:

- Payload válido em cada versão suportada
- Body vazio → `400 missing_body`
- JSON quebrado → `400 invalid_json`
- Campo obrigatório ausente → `422 validation_failed`
- Campo com tipo errado → `422 validation_failed`
- Versão inválida → `406 unsupported_version`
- Versão deprecated → headers `Deprecation` + `Sunset`

## Smoke contract test (HTTP real)

```bash
# Roda contra Edge Functions reais (default localhost)
SUPABASE_ANON_KEY=... npm run test:contract

# Contra produção em modo seguro (apenas leituras)
SUPABASE_URL=https://<proj>.supabase.co \
SUPABASE_ANON_KEY=... \
npm run test:contract
```
Loading
Loading