From e189dc8c80a6963a0afbf878f1734e8eaff60e69 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:45:41 -0300 Subject: [PATCH 01/24] feat(contracts): scaffold barrel export (test push) --- supabase/functions/_shared/contracts/index.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 supabase/functions/_shared/contracts/index.ts diff --git a/supabase/functions/_shared/contracts/index.ts b/supabase/functions/_shared/contracts/index.ts new file mode 100644 index 000000000..3e70cd7f8 --- /dev/null +++ b/supabase/functions/_shared/contracts/index.ts @@ -0,0 +1,34 @@ +/** + * supabase/functions/_shared/contracts/index.ts + * + * Barrel export do pacote de contratos. + */ + +export { + type ContractError, + type ContractErrorCode, + type FieldIssue, + invalidJsonResponse, + missingBodyResponse, + unsupportedVersionResponse, + validationErrorResponse, + zodErrorToFieldIssues, + zodValidationErrorResponse, +} from "./errors.ts"; + +export { + resolveContractVersion, + type ResolvedVersion, + type VersionConfig, + type VersionResolution, +} from "./versioning.ts"; + +export { + type ContractSchemas, + parseContract, + type ParseOptions, + type ParseResult, +} from "./parse.ts"; + +// Re-export do Zod para padronizar o pinning em todo o repo +export { z } from "https://esm.sh/zod@3.23.8"; From 3bb221b6ccfaf1d496eef383ba25ddf042f640aa Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:47:11 -0300 Subject: [PATCH 02/24] =?UTF-8?q?feat(contracts):=20add=20errors/versionin?= =?UTF-8?q?g/parse=20helpers=20+=20vitest=20alias=20for=20esm.sh=E2=86=92z?= =?UTF-8?q?od?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../functions/_shared/contracts/errors.ts | 171 ++++++++++++++++++ supabase/functions/_shared/contracts/parse.ts | 159 ++++++++++++++++ .../functions/_shared/contracts/versioning.ts | 128 +++++++++++++ vitest.config.ts | 11 +- 4 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 supabase/functions/_shared/contracts/errors.ts create mode 100644 supabase/functions/_shared/contracts/parse.ts create mode 100644 supabase/functions/_shared/contracts/versioning.ts diff --git a/supabase/functions/_shared/contracts/errors.ts b/supabase/functions/_shared/contracts/errors.ts new file mode 100644 index 000000000..76874bdb4 --- /dev/null +++ b/supabase/functions/_shared/contracts/errors.ts @@ -0,0 +1,171 @@ +/** + * supabase/functions/_shared/contracts/errors.ts + * + * Formato único de erro para falhas de validação (HTTP 422) e demais erros + * estruturados retornados pelas Edge Functions. + * + * Inspirado em RFC 7807 (Problem Details), simplificado para uso interno. + * + * Forma canônica: + * { + * code: "validation_failed" | "invalid_json" | "missing_body" | "unsupported_version" | ... + * message: string // mensagem legível por humano + * fields: FieldIssue[] // sempre presente; [] quando não aplicável + * // opcionais (não-quebrantes): + * version?: string // versão de contrato resolvida + * request_id?: string + * } + * + * Status HTTP de validação semântica = 422. + * Status HTTP de payload sintaticamente inválido (JSON quebrado) = 400. + * Status HTTP de versão de contrato não suportada = 406. + * + * Toda Edge Function que aceita body externo DEVE responder erros nesse formato. + */ + +import { z } from "https://esm.sh/zod@3.23.8"; + +// --------------------------------------------------------------------------- +// Tipos +// --------------------------------------------------------------------------- + +export type ContractErrorCode = + | "missing_body" + | "invalid_json" + | "validation_failed" + | "unsupported_version" + | "version_negotiation_failed"; + +export interface FieldIssue { + /** Caminho do campo no JSON. Ex.: "product.sku", "items[0].price". */ + path: string; + /** Mensagem específica do campo. */ + message: string; + /** Código machine-readable do Zod (ex: "invalid_type", "too_small"). */ + code?: string; + /** Valor recebido (útil em debug; nunca contém dados sensíveis pois schemas restringem o que entra). */ + received?: unknown; +} + +export interface ContractError { + code: ContractErrorCode; + message: string; + fields: FieldIssue[]; + version?: string; + request_id?: string; +} + +// --------------------------------------------------------------------------- +// Conversores +// --------------------------------------------------------------------------- + +/** + * Converte um `ZodError` num array `FieldIssue[]` plano. + * Preserva paths aninhados (ex.: `product.images[2]`). + */ +export function zodErrorToFieldIssues(err: z.ZodError): FieldIssue[] { + return err.issues.map((issue) => ({ + path: pathToDotNotation(issue.path), + message: issue.message, + code: issue.code, + })); +} + +function pathToDotNotation(path: (string | number)[]): string { + if (path.length === 0) return "$"; + let out = ""; + for (const seg of path) { + if (typeof seg === "number") { + out += `[${seg}]`; + } else { + out += out === "" ? seg : `.${seg}`; + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Builders de Response +// --------------------------------------------------------------------------- + +interface ResponseOptions { + corsHeaders?: Record; + version?: string; + requestId?: string; + extraHeaders?: Record; +} + +function buildResponse( + status: number, + body: ContractError, + opts: ResponseOptions = {}, +): Response { + const headers: Record = { + ...(opts.corsHeaders ?? {}), + ...(opts.extraHeaders ?? {}), + "Content-Type": "application/json", + }; + if (opts.version) headers["x-contract-version"] = opts.version; + if (opts.requestId) headers["x-request-id"] = opts.requestId; + return new Response(JSON.stringify(body), { status, headers }); +} + +/** HTTP 400 — body ausente. */ +export function missingBodyResponse(opts: ResponseOptions = {}): Response { + return buildResponse(400, { + code: "missing_body", + message: "Request body is required.", + fields: [], + version: opts.version, + request_id: opts.requestId, + }, opts); +} + +/** HTTP 400 — JSON sintaticamente inválido. */ +export function invalidJsonResponse(opts: ResponseOptions = {}): Response { + return buildResponse(400, { + code: "invalid_json", + message: "Request body is not valid JSON.", + fields: [], + version: opts.version, + request_id: opts.requestId, + }, opts); +} + +/** HTTP 422 — payload bem-formado mas inválido segundo o schema. */ +export function validationErrorResponse( + fields: FieldIssue[], + opts: ResponseOptions = {}, +): Response { + return buildResponse(422, { + code: "validation_failed", + message: "One or more fields are invalid.", + fields, + version: opts.version, + request_id: opts.requestId, + }, opts); +} + +/** HTTP 422 — atalho direto a partir de `ZodError`. */ +export function zodValidationErrorResponse( + err: z.ZodError, + opts: ResponseOptions = {}, +): Response { + return validationErrorResponse(zodErrorToFieldIssues(err), opts); +} + +/** HTTP 406 — versão de contrato solicitada não é suportada. */ +export function unsupportedVersionResponse( + requested: string, + supported: string[], + opts: ResponseOptions = {}, +): Response { + return buildResponse(406, { + code: "unsupported_version", + message: + `Contract version "${requested}" is not supported. Supported versions: ${supported.join(", ")}.`, + fields: [], + version: opts.version, + request_id: opts.requestId, + }, opts); +} diff --git a/supabase/functions/_shared/contracts/parse.ts b/supabase/functions/_shared/contracts/parse.ts new file mode 100644 index 000000000..52bb40e1d --- /dev/null +++ b/supabase/functions/_shared/contracts/parse.ts @@ -0,0 +1,159 @@ +/** + * supabase/functions/_shared/contracts/parse.ts + * + * Helper canônico para parsear + validar + versionar payloads de Edge Functions. + * + * Uso típico: + * + * import { parseContract } from "../_shared/contracts/index.ts"; + * import { ProductWebhookSchemas } from "../_shared/contracts/schemas/product-webhook.ts"; + * + * const result = await parseContract(req, ProductWebhookSchemas, { + * corsHeaders, + * requestId, + * }); + * if (!result.ok) return result.response; + * + * const { version, data } = result; + * // data tem o tipo da versão resolvida; `version` indica qual. + * + * Convenção: o objeto `schemas` é um `Record`. As versões + * disponíveis viram automaticamente `supported`. `default` e `deprecated` são + * configurados no `VersionConfig`. + */ + +import { z } from "https://esm.sh/zod@3.23.8"; +import { + invalidJsonResponse, + missingBodyResponse, + zodValidationErrorResponse, +} from "./errors.ts"; +import { + resolveContractVersion, + type VersionConfig, +} from "./versioning.ts"; + +export interface ContractSchemas { + /** Map versão → schema Zod. As chaves viram a lista de versões suportadas. */ + versions: Record; + /** Versão default quando o client não pedir nenhuma. */ + defaultVersion: V; + /** Lista de versões em depreciação com data de sunset. */ + deprecated?: VersionConfig["deprecated"]; + /** Identificador legível do contrato (usado em logs). */ + name?: string; +} + +export interface ParseOptions { + corsHeaders?: Record; + requestId?: string; + /** Permite passar um body já lido (útil quando a função precisa do raw para HMAC). */ + prereadBody?: string; +} + +export type ParseResult> = + | { + ok: true; + version: V; + /** Dados parseados; o tipo casa com o schema da versão resolvida. */ + data: { [K in V]: z.infer }[V]; + /** Headers que a resposta de sucesso deve incluir (versão, deprecation). */ + responseHeaders: Record; + } + | { ok: false; response: Response }; + +/** + * Parseia, valida e versiona o body de uma requisição. + */ +export async function parseContract< + V extends string, + S extends Record, +>( + req: Request, + schemas: ContractSchemas & { versions: S }, + opts: ParseOptions = {}, +): Promise> { + const corsHeaders = opts.corsHeaders ?? {}; + + // 1. Resolver versão + const supportedVersions = Object.keys(schemas.versions) as V[]; + const versionConfig: VersionConfig = { + supported: supportedVersions, + default: schemas.defaultVersion, + deprecated: schemas.deprecated, + }; + const vRes = resolveContractVersion(req, versionConfig, corsHeaders); + if (!vRes.ok) return { ok: false, response: vRes.response }; + + const { version, responseHeaders } = vRes.resolved; + const schema = schemas.versions[version as V]; + + // 2. Ler body (uma única vez) + let rawText: string; + if (opts.prereadBody !== undefined) { + rawText = opts.prereadBody; + } else { + try { + rawText = await req.text(); + } catch { + return { + ok: false, + response: invalidJsonResponse({ + corsHeaders, + version, + requestId: opts.requestId, + extraHeaders: responseHeaders, + }), + }; + } + } + + if (!rawText || rawText.trim() === "") { + return { + ok: false, + response: missingBodyResponse({ + corsHeaders, + version, + requestId: opts.requestId, + extraHeaders: responseHeaders, + }), + }; + } + + // 3. Parsear JSON + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch { + return { + ok: false, + response: invalidJsonResponse({ + corsHeaders, + version, + requestId: opts.requestId, + extraHeaders: responseHeaders, + }), + }; + } + + // 4. Validar contra o schema + const result = schema.safeParse(parsed); + if (!result.success) { + return { + ok: false, + response: zodValidationErrorResponse(result.error, { + corsHeaders, + version, + requestId: opts.requestId, + extraHeaders: responseHeaders, + }), + }; + } + + return { + ok: true, + version: version as V, + data: result.data, + responseHeaders, + }; +} diff --git a/supabase/functions/_shared/contracts/versioning.ts b/supabase/functions/_shared/contracts/versioning.ts new file mode 100644 index 000000000..54eca380b --- /dev/null +++ b/supabase/functions/_shared/contracts/versioning.ts @@ -0,0 +1,128 @@ +/** + * supabase/functions/_shared/contracts/versioning.ts + * + * Negociação de versão de contrato para Edge Functions / Webhooks. + * + * Estratégia de resolução (em ordem de prioridade): + * 1. Header `accept-version: 2` (RFC-style; preferido) + * 2. Query param `?v=2` + * 3. Default da função + * + * Versões marcadas como `deprecated` continuam atendendo mas a resposta + * inclui: + * - `Deprecation: true` (RFC 8594) + * - `Sunset: ` (RFC 8594) + * - `Link: ; rel="deprecation"` + * + * Versões fora de `supported` retornam 406 (unsupported_version). + */ + +import { unsupportedVersionResponse } from "./errors.ts"; + +export interface VersionConfig { + /** Lista de versões aceitas (ex.: ["1", "2"]). */ + supported: string[]; + /** Versão usada quando nenhuma é solicitada. */ + default: string; + /** + * Versões em depreciação. Continuam funcionando, mas a resposta carrega + * headers `Deprecation` / `Sunset`. + */ + deprecated?: Array<{ + version: string; + /** Data ISO (yyyy-mm-dd) em que a versão deixa de ser servida. */ + sunset: string; + /** URL opcional do guia de migração. */ + migrationUrl?: string; + }>; +} + +export interface ResolvedVersion { + /** Versão escolhida. */ + version: string; + /** Se vier de `deprecated`, traz info do sunset. */ + deprecation?: { + sunset: string; + migrationUrl?: string; + }; + /** Headers que devem ser anexados a TODA resposta (sucesso ou erro). */ + responseHeaders: Record; +} + +export type VersionResolution = + | { ok: true; resolved: ResolvedVersion } + | { ok: false; response: Response }; + +/** + * Resolve a versão pedida pelo client. Retorna `Response` 406 se não suportada. + */ +export function resolveContractVersion( + req: Request, + config: VersionConfig, + corsHeaders: Record = {}, +): VersionResolution { + const requested = readRequestedVersion(req); + const version = requested ?? config.default; + + if (!config.supported.includes(version)) { + return { + ok: false, + response: unsupportedVersionResponse(version, config.supported, { + corsHeaders, + }), + }; + } + + const dep = config.deprecated?.find((d) => d.version === version); + const responseHeaders: Record = { + "x-contract-version": version, + }; + if (dep) { + responseHeaders["Deprecation"] = "true"; + responseHeaders["Sunset"] = toRfc1123(dep.sunset); + if (dep.migrationUrl) { + responseHeaders["Link"] = `<${dep.migrationUrl}>; rel="deprecation"`; + } + } + + return { + ok: true, + resolved: { + version, + deprecation: dep + ? { sunset: dep.sunset, migrationUrl: dep.migrationUrl } + : undefined, + responseHeaders, + }, + }; +} + +function readRequestedVersion(req: Request): string | null { + // 1. Header `accept-version` + const headerVal = req.headers.get("accept-version"); + if (headerVal) { + // aceita "2", "v2", "2.0" + return headerVal.replace(/^v/i, "").split(".")[0].trim(); + } + + // 2. Query param `?v=` + try { + const url = new URL(req.url); + const qv = url.searchParams.get("v"); + if (qv) return qv.replace(/^v/i, "").split(".")[0].trim(); + } catch { + /* ignore */ + } + + return null; +} + +function toRfc1123(isoDate: string): string { + // Aceita "2026-12-31" ou ISO completa; força UTC midnight. + const d = isoDate.length === 10 ? new Date(`${isoDate}T00:00:00Z`) : new Date(isoDate); + if (Number.isNaN(d.getTime())) { + // Fallback: ecoa o valor (não trava o response, apenas perde validade RFC). + return isoDate; + } + return d.toUTCString(); +} diff --git a/vitest.config.ts b/vitest.config.ts index cd0d89104..fd667f059 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -56,8 +56,13 @@ export default defineConfig({ }, }, resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, + alias: [ + { find: '@', replacement: path.resolve(__dirname, './src') }, + // Edge Functions (Deno) importam Zod via URL esm.sh. Vitest (Node) usa o pacote npm. + // Aliases permitem que os mesmos arquivos rodem nos dois runtimes sem duplicação. + // Pattern abrange qualquer pin de versão (3.22.x, 3.23.x, 4.x). + { find: /^https:\/\/esm\.sh\/zod@.*$/, replacement: 'zod' }, + { find: /^https:\/\/deno\.land\/x\/zod@.*\/mod\.ts$/, replacement: 'zod' }, + ], }, }); From 40f36dbf52b07cd6e491b1ad6da109e3c146fe5c Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:48:07 -0300 Subject: [PATCH 03/24] feat(contracts): add v1/v2 schemas for product-webhook, webhook-inbound, webhook-dispatcher --- .../contracts/schemas/product-webhook.ts | 141 ++++++++++++++++++ .../contracts/schemas/webhook-dispatcher.ts | 75 ++++++++++ .../contracts/schemas/webhook-inbound.ts | 64 ++++++++ 3 files changed, 280 insertions(+) create mode 100644 supabase/functions/_shared/contracts/schemas/product-webhook.ts create mode 100644 supabase/functions/_shared/contracts/schemas/webhook-dispatcher.ts create mode 100644 supabase/functions/_shared/contracts/schemas/webhook-inbound.ts diff --git a/supabase/functions/_shared/contracts/schemas/product-webhook.ts b/supabase/functions/_shared/contracts/schemas/product-webhook.ts new file mode 100644 index 000000000..78b4ae10b --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/product-webhook.ts @@ -0,0 +1,141 @@ +/** + * supabase/functions/_shared/contracts/schemas/product-webhook.ts + * + * Contratos do endpoint `product-webhook`. + * + * v1: preserva 100% do schema atual em produção. Aceito por padrão. + * Será descontinuado em 2026-08-31. + * + * v2: strict — sem campos extras, datas como ISO, IDs externos obrigatórios + * em modo upsert/delete, e enum de action enxuto (`upsert | delete | batch_upsert`). + * `sync` foi removido: era ambíguo e nunca foi usado em produção. + */ + +import { z } from "https://esm.sh/zod@3.23.8"; + +// --------------------------------------------------------------------------- +// v1 (compatível com produção) +// --------------------------------------------------------------------------- + +const ProductV1 = z.object({ + external_id: z.string().max(255).optional(), + sku: z.string().min(1).max(100), + name: z.string().min(1).max(500), + description: z.string().max(5000).optional(), + price: z.number().nonnegative(), + min_quantity: z.number().int().positive().optional(), + category_id: z.number().int().optional(), + category_name: z.string().max(255).optional(), + subcategory: z.string().max(255).optional(), + supplier_id: z.string().max(255).optional(), + supplier_name: z.string().max(255).optional(), + stock: z.number().int().nonnegative().optional(), + stock_status: z.string().max(50).optional(), + is_kit: z.boolean().optional(), + is_active: z.boolean().optional(), + featured: z.boolean().optional(), + new_arrival: z.boolean().optional(), + on_sale: z.boolean().optional(), + images: z.array(z.string().url().max(2000)).max(50).optional(), + video_url: z.string().url().max(2000).optional().nullable(), + colors: z + .array(z.object({ name: z.string(), hex: z.string(), group: z.string().optional() })) + .max(100) + .optional(), + materials: z.array(z.string().max(100)).max(50).optional(), + tags: z.record(z.array(z.string())).optional(), + kit_items: z + .array( + z.object({ + productId: z.string(), + productName: z.string(), + quantity: z.number(), + sku: z.string(), + }), + ) + .max(50) + .optional(), + variations: z.array(z.any()).max(200).optional(), + metadata: z.record(z.any()).optional(), +}); + +export const ProductWebhookV1 = z.object({ + action: z.enum(["sync", "upsert", "delete", "batch_upsert"]), + products: z.array(ProductV1).max(500).optional(), + product: ProductV1.optional(), + external_ids: z.array(z.string().max(255)).max(500).optional(), +}); + +// --------------------------------------------------------------------------- +// v2 (strict) +// --------------------------------------------------------------------------- + +const ProductV2 = ProductV1 + .extend({ + external_id: z.string().min(1).max(255), // agora OBRIGATÓRIO + updated_at: z.string().datetime().optional(), + currency: z.enum(["BRL", "USD", "EUR"]).default("BRL"), + }) + .strict(); // recusa campos desconhecidos + +export const ProductWebhookV2 = z + .object({ + action: z.enum(["upsert", "delete", "batch_upsert"]), + products: z.array(ProductV2).max(500).optional(), + product: ProductV2.optional(), + external_ids: z.array(z.string().min(1).max(255)).max(500).optional(), + /** Idempotency key obrigatória em v2. */ + idempotency_key: z.string().uuid(), + }) + .strict() + .superRefine((val, ctx) => { + if (val.action === "delete") { + if (!val.external_ids || val.external_ids.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["external_ids"], + message: "external_ids is required when action='delete'", + }); + } + } else if (val.action === "upsert") { + if (!val.product) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["product"], + message: "product is required when action='upsert'", + }); + } + } else if (val.action === "batch_upsert") { + if (!val.products || val.products.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["products"], + message: "products[] (non-empty) is required when action='batch_upsert'", + }); + } + } + }); + +// --------------------------------------------------------------------------- +// Schemas exportados +// --------------------------------------------------------------------------- + +export const ProductWebhookSchemas = { + name: "product-webhook", + versions: { + "1": ProductWebhookV1, + "2": ProductWebhookV2, + }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-08-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#product-webhook-v1-v2", + }, + ], +}; + +export type ProductWebhookV1Payload = z.infer; +export type ProductWebhookV2Payload = z.infer; diff --git a/supabase/functions/_shared/contracts/schemas/webhook-dispatcher.ts b/supabase/functions/_shared/contracts/schemas/webhook-dispatcher.ts new file mode 100644 index 000000000..6c1cc363e --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/webhook-dispatcher.ts @@ -0,0 +1,75 @@ +/** + * supabase/functions/_shared/contracts/schemas/webhook-dispatcher.ts + * + * Contrato do disparador outbound (`webhook-dispatcher`). + * + * v1: schema atual (preserva compat com triggers DB + RPCs). + * v2: strict; força distinção entre `dispatch | replay | test`, e exige + * campos coerentes para cada modo via discriminated union. + */ + +import { z } from "https://esm.sh/zod@3.23.8"; + +// --------------------------------------------------------------------------- +// v1 — compat com produção +// --------------------------------------------------------------------------- + +export const WebhookDispatcherV1 = z.object({ + event: z.string().min(1), + payload: z.unknown().optional(), + replay_delivery_id: z.string().uuid().optional(), + test_mode: z.boolean().optional(), + test_webhook_id: z.string().uuid().optional(), +}); + +// --------------------------------------------------------------------------- +// v2 — discriminated union por `mode` +// --------------------------------------------------------------------------- + +const Dispatch = z.object({ + mode: z.literal("dispatch"), + event: z.string().min(1).max(150), + payload: z.record(z.unknown()), +}).strict(); + +const Replay = z.object({ + mode: z.literal("replay"), + replay_delivery_id: z.string().uuid(), +}).strict(); + +const TestRun = z.object({ + mode: z.literal("test"), + event: z.string().min(1).max(150), + payload: z.record(z.unknown()), + test_webhook_id: z.string().uuid(), +}).strict(); + +export const WebhookDispatcherV2 = z.discriminatedUnion("mode", [ + Dispatch, + Replay, + TestRun, +]); + +// --------------------------------------------------------------------------- +// Schemas exportados +// --------------------------------------------------------------------------- + +export const WebhookDispatcherSchemas = { + name: "webhook-dispatcher", + versions: { + "1": WebhookDispatcherV1, + "2": WebhookDispatcherV2, + }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-09-30", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#webhook-dispatcher-v1-v2", + }, + ], +}; + +export type WebhookDispatcherV1Payload = z.infer; +export type WebhookDispatcherV2Payload = z.infer; diff --git a/supabase/functions/_shared/contracts/schemas/webhook-inbound.ts b/supabase/functions/_shared/contracts/schemas/webhook-inbound.ts new file mode 100644 index 000000000..438614014 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/webhook-inbound.ts @@ -0,0 +1,64 @@ +/** + * supabase/functions/_shared/contracts/schemas/webhook-inbound.ts + * + * Contrato para o receptor genérico de webhooks externos (`webhook-inbound`). + * + * Hoje o endpoint aceita QUALQUER JSON (zero validação) e só verifica HMAC. + * Isso permite inserir lixo em `inbound_webhook_events.payload`. Este contrato + * passa a exigir um envelope mínimo, mantendo `data` livre para o emissor. + * + * v1 = aceita qualquer payload (forma adotada hoje em produção). Default. + * v2 = strict envelope: `event`, `occurred_at`, `data` exigidos. + */ + +import { z } from "https://esm.sh/zod@3.23.8"; + +// --------------------------------------------------------------------------- +// v1 — passthrough (zero estrutura imposta) preservando compat +// --------------------------------------------------------------------------- + +export const WebhookInboundV1 = z.any(); + +// --------------------------------------------------------------------------- +// v2 — envelope estruturado +// --------------------------------------------------------------------------- + +export const WebhookInboundV2 = z + .object({ + /** Tipo do evento (ex.: "order.created"). */ + event: z.string().min(1).max(150).regex( + /^[a-z][a-z0-9_.-]*$/i, + "event must be slug-like (letters, digits, '.', '_', '-')", + ), + /** Timestamp ISO em que o evento ocorreu no sistema de origem. */ + occurred_at: z.string().datetime(), + /** Payload livre do emissor; objeto exigido para evitar bytes soltos. */ + data: z.record(z.unknown()), + /** Idempotency-key opcional do emissor — quando presente, deve ser UUID. */ + idempotency_key: z.string().uuid().optional(), + }) + .strict(); + +// --------------------------------------------------------------------------- +// Schemas exportados +// --------------------------------------------------------------------------- + +export const WebhookInboundSchemas = { + name: "webhook-inbound", + versions: { + "1": WebhookInboundV1, + "2": WebhookInboundV2, + }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-09-30", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#webhook-inbound-v1-v2", + }, + ], +}; + +export type WebhookInboundV1Payload = z.infer; +export type WebhookInboundV2Payload = z.infer; From 271af386b6c90d8df22a363b31928b3670d9a6c6 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:49:29 -0300 Subject: [PATCH 04/24] refactor(webhooks): migrate product-webhook and webhook-inbound to parseContract (backward-compat v1; opt-in v2 via accept-version) --- supabase/functions/product-webhook/index.ts | 180 +++++++------------- supabase/functions/webhook-inbound/index.ts | 103 +++++++---- 2 files changed, 135 insertions(+), 148 deletions(-) diff --git a/supabase/functions/product-webhook/index.ts b/supabase/functions/product-webhook/index.ts index a2d37538d..707a8debf 100644 --- a/supabase/functions/product-webhook/index.ts +++ b/supabase/functions/product-webhook/index.ts @@ -1,62 +1,26 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; -import { z } from "../_shared/zod-validate.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; +import { + parseContract, +} from "../_shared/contracts/index.ts"; +import { + ProductWebhookSchemas, + type ProductWebhookV1Payload, + type ProductWebhookV2Payload, +} from "../_shared/contracts/schemas/product-webhook.ts"; -const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-webhook-secret"] }); +const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-webhook-secret", "accept-version"] }); const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const webhookSecret = Deno.env.get("N8N_PRODUCT_WEBHOOK_SECRET"); -const ProductPayloadSchema = z.object({ - external_id: z.string().max(255).optional(), - sku: z.string().min(1).max(100), - name: z.string().min(1).max(500), - description: z.string().max(5000).optional(), - price: z.number().nonnegative(), - min_quantity: z.number().int().positive().optional(), - category_id: z.number().int().optional(), - category_name: z.string().max(255).optional(), - subcategory: z.string().max(255).optional(), - supplier_id: z.string().max(255).optional(), - supplier_name: z.string().max(255).optional(), - stock: z.number().int().nonnegative().optional(), - stock_status: z.string().max(50).optional(), - is_kit: z.boolean().optional(), - is_active: z.boolean().optional(), - featured: z.boolean().optional(), - new_arrival: z.boolean().optional(), - on_sale: z.boolean().optional(), - images: z.array(z.string().url().max(2000)).max(50).optional(), - video_url: z.string().url().max(2000).optional().nullable(), - colors: z.array(z.object({ name: z.string(), hex: z.string(), group: z.string().optional() })).max(100).optional(), - materials: z.array(z.string().max(100)).max(50).optional(), - tags: z.record(z.array(z.string())).optional(), - kit_items: z.array(z.object({ - productId: z.string(), productName: z.string(), quantity: z.number(), sku: z.string() - })).max(50).optional(), - variations: z.array(z.any()).max(200).optional(), - metadata: z.record(z.any()).optional(), -}); - -const WebhookPayloadSchema = z.object({ - action: z.enum(["sync", "upsert", "delete", "batch_upsert"]), - products: z.array(ProductPayloadSchema).max(500).optional(), - product: ProductPayloadSchema.optional(), - external_ids: z.array(z.string().max(255)).max(500).optional(), -}); - -type ProductPayload = z.infer; - -interface WebhookPayload { - action: "sync" | "upsert" | "delete" | "batch_upsert"; - products?: ProductPayload[]; - product?: ProductPayload; - external_ids?: string[]; -} +type ProductPayload = + | NonNullable + | NonNullable; Deno.serve(async (req) => { - // Handle CORS preflight + // CORS preflight if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } @@ -64,39 +28,37 @@ Deno.serve(async (req) => { const supabase = createClient(supabaseUrl, supabaseServiceKey); try { - // Validate webhook secret + // Webhook secret check (mantido idêntico) const providedSecret = req.headers.get("x-webhook-secret"); if (webhookSecret && providedSecret !== webhookSecret) { console.error("Invalid webhook secret"); return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } } + JSON.stringify({ code: "unauthorized", message: "Unauthorized", fields: [] }), + { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); } - let rawBody: unknown; - try { rawBody = await req.json(); } catch { - return new Response(JSON.stringify({ error: "Invalid webhook payload" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } + // Parse + valida + versiona em um único passo + const result = await parseContract(req, ProductWebhookSchemas, { corsHeaders }); + if (!result.ok) return result.response; - const parsed = WebhookPayloadSchema.safeParse(rawBody); - if (!parsed.success) { - return new Response(JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - const payload: WebhookPayload = parsed.data; - console.log(`Product webhook action: ${payload.action}`); + const { version, data, responseHeaders } = result; + // headers anexados em TODAS as respostas de sucesso (versão + deprecation) + const okHeaders = { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }; + + console.log(`[product-webhook] version=${version} action=${data.action}`); + + // Normalização v1/v2 → forma interna comum + const products = data.products as ProductPayload[] | undefined; + const singleProduct = data.product as ProductPayload | undefined; + const externalIds = data.external_ids as string[] | undefined; - // Create sync log const { data: syncLog, error: logError } = await supabase .from("product_sync_logs") .insert({ status: "processing", - source: "n8n", - products_received: payload.products?.length || (payload.product ? 1 : 0), + source: version === "2" ? "n8n_v2" : "n8n", + products_received: products?.length || (singleProduct ? 1 : 0), }) .select() .single(); @@ -107,67 +69,58 @@ Deno.serve(async (req) => { const syncLogId = syncLog?.id; - let result: { - created: number; - updated: number; - failed: number; - errors: string[]; - } = { created: 0, updated: 0, failed: 0, errors: [] }; + let outcome: { created: number; updated: number; failed: number; errors: string[] } = { + created: 0, + updated: 0, + failed: 0, + errors: [], + }; - switch (payload.action) { + switch (data.action) { case "upsert": { - // Single product upsert - if (!payload.product) { + if (!singleProduct) { throw new Error("Product data is required for upsert action"); } - result = await upsertProducts(supabase, [payload.product]); + outcome = await upsertProducts(supabase, [singleProduct]); break; } case "batch_upsert": case "sync": { - // Batch upsert multiple products - if (!payload.products || payload.products.length === 0) { + if (!products || products.length === 0) { throw new Error("Products array is required for batch_upsert/sync action"); } - result = await upsertProducts(supabase, payload.products); + outcome = await upsertProducts(supabase, products); break; } case "delete": { - // Delete products by external_id - if (!payload.external_ids || payload.external_ids.length === 0) { + if (!externalIds || externalIds.length === 0) { throw new Error("external_ids array is required for delete action"); } - const { error: deleteError, count } = await supabase .from("products") .delete() - .in("external_id", payload.external_ids); - - if (deleteError) { - throw deleteError; - } - - result = { created: 0, updated: 0, failed: 0, errors: [] }; + .in("external_id", externalIds); + if (deleteError) throw deleteError; + outcome = { created: 0, updated: 0, failed: 0, errors: [] }; console.log(`Deleted ${count} products`); break; } default: - throw new Error(`Unknown action: ${payload.action}`); + throw new Error(`Unknown action: ${(data as { action: string }).action}`); } - // Update sync log if (syncLogId) { await supabase .from("product_sync_logs") .update({ - status: result.failed > 0 ? "partial" : "completed", - products_created: result.created, - products_updated: result.updated, - products_failed: result.failed, - error_message: result.errors.length > 0 ? result.errors.join("; ") : null, + status: outcome.failed > 0 ? "partial" : "completed", + products_created: outcome.created, + products_updated: outcome.updated, + products_failed: outcome.failed, + error_message: outcome.errors.length > 0 ? outcome.errors.join("; ") : null, completed_at: new Date().toISOString(), }) .eq("id", syncLogId); @@ -176,27 +129,27 @@ Deno.serve(async (req) => { return new Response( JSON.stringify({ success: true, - created: result.created, - updated: result.updated, - failed: result.failed, - errors: result.errors, + created: outcome.created, + updated: outcome.updated, + failed: outcome.failed, + errors: outcome.errors, sync_log_id: syncLogId, }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } + { headers: okHeaders }, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; console.error("Product webhook error:", error); return new Response( - JSON.stringify({ error: errorMessage }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + JSON.stringify({ code: "internal_error", message: errorMessage, fields: [] }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); } }); async function upsertProducts( supabase: any, - products: ProductPayload[] + products: ProductPayload[], ): Promise<{ created: number; updated: number; failed: number; errors: string[] }> { let created = 0; let updated = 0; @@ -205,10 +158,8 @@ async function upsertProducts( for (const product of products) { try { - // Determine stock status const stockStatus = calculateStockStatus(product.stock || 0); - // Prepare product data const productData = { external_id: product.external_id || null, sku: product.sku, @@ -239,9 +190,7 @@ async function upsertProducts( synced_at: new Date().toISOString(), }; - // Check if product exists by external_id or sku let existingProduct = null; - if (product.external_id) { const { data } = await supabase .from("products") @@ -250,7 +199,6 @@ async function upsertProducts( .maybeSingle(); existingProduct = data; } - if (!existingProduct) { const { data } = await supabase .from("products") @@ -261,21 +209,15 @@ async function upsertProducts( } if (existingProduct) { - // Update existing product const { error: updateError } = await supabase .from("products") .update(productData) .eq("id", existingProduct.id); - if (updateError) throw updateError; updated++; console.log(`Updated product: ${product.sku}`); } else { - // Insert new product - const { error: insertError } = await supabase - .from("products") - .insert(productData); - + const { error: insertError } = await supabase.from("products").insert(productData); if (insertError) throw insertError; created++; console.log(`Created product: ${product.sku}`); diff --git a/supabase/functions/webhook-inbound/index.ts b/supabase/functions/webhook-inbound/index.ts index 3970b5f9d..5632c8651 100644 --- a/supabase/functions/webhook-inbound/index.ts +++ b/supabase/functions/webhook-inbound/index.ts @@ -1,17 +1,33 @@ // webhook-inbound: receives external webhooks at /webhook-inbound?slug= // Validates HMAC signature using the secret stored in env (referenced by the // endpoint row), records every event in inbound_webhook_events. +// +// Contract validation: +// - v1 = passthrough (compat com produção). default. +// - v2 = envelope strict { event, occurred_at, data, idempotency_key? } +// Cliente seleciona via header `accept-version: 2` ou `?v=2`. +// v1 será descontinuada em 2026-09-30; resposta inclui headers Deprecation/Sunset. + 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"; -const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-signature-256","x-event"], allowMethods: "POST, OPTIONS" }); +const corsHeaders = buildPublicCorsHeaders({ + 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", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], + "raw", + enc.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], ); const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload)); return encodeHex(new Uint8Array(sig)); @@ -38,9 +54,10 @@ Deno.serve(async (req) => { || url.pathname.split("/").filter(Boolean).pop() || ""; if (!slug) { - return new Response(JSON.stringify({ error: "slug ausente" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ code: "missing_slug", message: "slug ausente", fields: [] }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); } const { data: endpoint } = await supabase @@ -50,60 +67,88 @@ Deno.serve(async (req) => { .eq("active", true) .maybeSingle(); if (!endpoint) { - return new Response(JSON.stringify({ error: "endpoint não encontrado" }), { - status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ code: "endpoint_not_found", message: "endpoint não encontrado", fields: [] }), + { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); } + // CRÍTICO: ler raw body UMA vez (HMAC precisa do raw exato; parseContract + // recebe via prereadBody pra não tentar consumir o stream novamente). const rawBody = await req.text(); + + // Validação de contrato (v1 = passthrough, v2 = envelope strict). + // Em v1, schema é `z.any()` → passa sempre que houver body. Já cobre missing/invalid_json. + const contractResult = await parseContract(req, WebhookInboundSchemas, { + corsHeaders, + prereadBody: rawBody, + }); + if (!contractResult.ok) return contractResult.response; + const { version, data: payloadParsed, responseHeaders } = contractResult; + const signatureHeader = req.headers.get("x-signature-256") || req.headers.get("x-webhook-signature") || ""; - const eventType = req.headers.get("x-event") || "unknown"; + 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; - const secretRes = await supabase.from('integration_credentials').select('secret_value').eq('secret_name', endpoint.hmac_secret_ref).maybeSingle(); + const secretRes = await supabase + .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=") ? signatureHeader : "sha256=" + signatureHeader; + const provided = signatureHeader.startsWith("sha256=") + ? signatureHeader + : "sha256=" + signatureHeader; signatureValid = timingSafeEqual(expected, provided); } - let parsedPayload: unknown = null; - try { parsedPayload = JSON.parse(rawBody); } catch { /* keep null */ } - await supabase.from("inbound_webhook_events").insert({ endpoint_id: endpoint.id, event_type: eventType, - payload: parsedPayload, + payload: payloadParsed, signature_valid: signatureValid, processed: signatureValid, source_ip: sourceIp, error: signatureValid ? null : "HMAC inválido ou ausente", + contract_version: version, }); - await supabase.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); + await supabase + .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); + + const okHeaders = { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }; if (!signatureValid) { - return new Response(JSON.stringify({ error: "Assinatura inválida" }), { - status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ code: "invalid_signature", message: "Assinatura inválida", fields: [] }), + { status: 401, headers: okHeaders }, + ); } - return new Response(JSON.stringify({ ok: true, received: true }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ ok: true, received: true }), + { headers: okHeaders }, + ); } catch (err) { const msg = err instanceof Error ? err.message : "Erro"; - return new Response(JSON.stringify({ error: msg }), { - status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ code: "internal_error", message: msg, fields: [] }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); } }); From eb32ced9012e7b1c5fdfcf9b9ac4818684370868 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:52:00 -0300 Subject: [PATCH 05/24] refactor(webhook-dispatcher): migrate to parseContract; v2 uses discriminated union (dispatch/replay/test) --- .../functions/webhook-dispatcher/index.ts | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/supabase/functions/webhook-dispatcher/index.ts b/supabase/functions/webhook-dispatcher/index.ts index da60088cc..a2e692df2 100644 --- a/supabase/functions/webhook-dispatcher/index.ts +++ b/supabase/functions/webhook-dispatcher/index.ts @@ -11,21 +11,14 @@ // Ver: supabase/functions/_shared/dispatcher-auth.ts 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 { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { WebhookDispatcherSchemas } from "../_shared/contracts/schemas/webhook-dispatcher.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; import { authorizeDispatcher } from "../_shared/dispatcher-auth.ts"; const corsHeaders = buildPublicCorsHeaders({ allowMethods: "POST, OPTIONS" }); -const BodySchema = z.object({ - event: z.string().min(1), - payload: z.unknown().optional(), - // Replay mode: re-deliver a single failed delivery by id - replay_delivery_id: z.string().uuid().optional(), - // Test mode (Onda 13 #9): dispatch to a specific webhook, no metrics, no breaker, no DB log - test_mode: z.boolean().optional(), - test_webhook_id: z.string().uuid().optional(), -}); +// Schemas (v1/v2) movidos para _shared/contracts/schemas/webhook-dispatcher.ts // Circuit breaker: 5 falhas consecutivas → desativa o webhook const CIRCUIT_BREAKER_THRESHOLD = 5; @@ -60,16 +53,52 @@ Deno.serve(async (req) => { } try { - // Body precisa ser parseado antes da auth pra saber se requer Modo B (test_mode/replay). - // Body parse falha → 400 antes da auth (não vaza info). - const parsed = BodySchema.safeParse(await req.json().catch(() => ({}))); - if (!parsed.success) { - return new Response(JSON.stringify({ error: "Invalid body" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + // Validação de contrato (v1 default, v2 via accept-version). + // parseContract retorna 400 (json inválido/vazio), 422 (validação), 406 (versão). + const contractResult = await parseContract(req, WebhookDispatcherSchemas, { corsHeaders }); + if (!contractResult.ok) return contractResult.response; + const { version: contractVersion, data: parsedData } = contractResult; + + // Normalização v1/v2 → forma interna comum. + // v2 usa discriminated union por `mode`; v1 é o shape histórico. + let event: string = ""; + let payload: unknown; + let replay_delivery_id: string | undefined; + let test_mode: boolean | undefined; + let test_webhook_id: string | undefined; + if (contractVersion === "2") { + const d = parsedData as { + mode: "dispatch" | "replay" | "test"; + event?: string; + payload?: Record; + replay_delivery_id?: string; + test_webhook_id?: string; + }; + if (d.mode === "replay") { + replay_delivery_id = d.replay_delivery_id; + } else if (d.mode === "test") { + event = d.event ?? ""; + payload = d.payload; + test_mode = true; + test_webhook_id = d.test_webhook_id; + } else { + event = d.event ?? ""; + payload = d.payload; + } + } else { + const d = parsedData as { + event: string; + payload?: unknown; + replay_delivery_id?: string; + test_mode?: boolean; + test_webhook_id?: string; + }; + event = d.event; + payload = d.payload; + replay_delivery_id = d.replay_delivery_id; + test_mode = d.test_mode; + test_webhook_id = d.test_webhook_id; } - let { event, payload } = parsed.data; - const { replay_delivery_id, test_mode, test_webhook_id } = parsed.data; // Operações que mexem com webhook específico (test/replay) só por Modo B const requiresUserContext = !!(test_mode || replay_delivery_id); From f602270c519131c6776dd185626a35832579eafd Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:52:44 -0300 Subject: [PATCH 06/24] test(contracts): add unit tests for errors + versioning (14 tests) --- tests/contracts/_helpers.ts | 64 +++++++++++++++++++ tests/contracts/errors.test.ts | 98 ++++++++++++++++++++++++++++++ tests/contracts/versioning.test.ts | 86 ++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 tests/contracts/_helpers.ts create mode 100644 tests/contracts/errors.test.ts create mode 100644 tests/contracts/versioning.test.ts diff --git a/tests/contracts/_helpers.ts b/tests/contracts/_helpers.ts new file mode 100644 index 000000000..be9d3b1d5 --- /dev/null +++ b/tests/contracts/_helpers.ts @@ -0,0 +1,64 @@ +/** + * Helpers de teste de contrato. Esses utilitários não dependem do runtime Deno; + * funcionam em vitest porque o pacote `_shared/contracts` usa apenas `Request` / + * `Response` (Web API, disponíveis em Node 20+). + * + * Vitest resolve `https://esm.sh/zod@...` → `zod` (npm) via alias em + * `vitest.config.ts`. + */ + +import { expect } from 'vitest'; +import type { ContractError } from '../../supabase/functions/_shared/contracts/errors'; + +export function makeRequest(opts: { + method?: string; + url?: string; + body?: unknown | string; + headers?: Record; +}): Request { + const headers = new Headers(opts.headers ?? {}); + let body: BodyInit | undefined; + if (opts.body !== undefined) { + if (typeof opts.body === 'string') { + body = opts.body; + } else { + body = JSON.stringify(opts.body); + if (!headers.has('content-type')) + headers.set('content-type', 'application/json'); + } + } + return new Request(opts.url ?? 'https://edge.local/fn', { + method: opts.method ?? 'POST', + headers, + body, + }); +} + +export async function readBody(res: Response): Promise { + return (await res.json()) as ContractError; +} + +/** Assert helper: a resposta segue o formato canônico de erro. */ +export async function expectContractError( + res: Response, + expected: { + status: number; + code: ContractError['code']; + fieldPaths?: string[]; + } +): Promise { + expect(res.status).toBe(expected.status); + const body = await readBody(res); + expect(body).toMatchObject({ + code: expected.code, + message: expect.any(String), + fields: expect.any(Array), + }); + if (expected.fieldPaths) { + const paths = body.fields.map((f) => f.path); + for (const expectedPath of expected.fieldPaths) { + expect(paths).toContain(expectedPath); + } + } + return body; +} diff --git a/tests/contracts/errors.test.ts b/tests/contracts/errors.test.ts new file mode 100644 index 000000000..845ba16a3 --- /dev/null +++ b/tests/contracts/errors.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { + invalidJsonResponse, + missingBodyResponse, + unsupportedVersionResponse, + validationErrorResponse, + zodErrorToFieldIssues, + zodValidationErrorResponse, +} from '../../supabase/functions/_shared/contracts/errors'; + +describe('contracts/errors — formato único de resposta', () => { + it('missingBodyResponse → 400 com code=missing_body e fields=[]', async () => { + const res = missingBodyResponse(); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body).toEqual({ + code: 'missing_body', + message: expect.any(String), + fields: [], + }); + expect(res.headers.get('content-type')).toContain('application/json'); + }); + + it('invalidJsonResponse → 400 com code=invalid_json', async () => { + const res = invalidJsonResponse(); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe('invalid_json'); + }); + + it('validationErrorResponse → 422 com fields preservados', async () => { + const res = validationErrorResponse([ + { path: 'product.sku', message: 'Required', code: 'invalid_type' }, + ]); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.code).toBe('validation_failed'); + expect(body.fields).toHaveLength(1); + expect(body.fields[0].path).toBe('product.sku'); + }); + + it('unsupportedVersionResponse → 406 listando versões suportadas', async () => { + const res = unsupportedVersionResponse('99', ['1', '2']); + expect(res.status).toBe(406); + const body = await res.json(); + expect(body.code).toBe('unsupported_version'); + expect(body.message).toContain('99'); + expect(body.message).toContain('1, 2'); + }); + + it('zodErrorToFieldIssues converte paths aninhados e índices', () => { + const schema = z.object({ + product: z.object({ + sku: z.string(), + images: z.array(z.string().url()), + }), + }); + const r = schema.safeParse({ + product: { sku: 123, images: ['not-a-url', 'https://ok.com'] }, + }); + expect(r.success).toBe(false); + if (!r.success) { + const issues = zodErrorToFieldIssues(r.error); + const paths = issues.map((i) => i.path); + expect(paths).toContain('product.sku'); + expect(paths).toContain('product.images[0]'); + } + }); + + it('zodValidationErrorResponse propaga corsHeaders e versão', async () => { + const schema = z.object({ sku: z.string() }); + const r = schema.safeParse({}); + if (r.success) throw new Error('expected failure'); + const res = zodValidationErrorResponse(r.error, { + corsHeaders: { 'access-control-allow-origin': '*' }, + version: '2', + }); + expect(res.status).toBe(422); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect(res.headers.get('x-contract-version')).toBe('2'); + const body = await res.json(); + expect(body.version).toBe('2'); + }); + + it('payload de validação inclui campo received quando explicitado', async () => { + const res = validationErrorResponse([ + { + path: 'qty', + message: 'must be positive', + code: 'too_small', + received: -1, + }, + ]); + const body = await res.json(); + expect(body.fields[0].received).toBe(-1); + }); +}); diff --git a/tests/contracts/versioning.test.ts b/tests/contracts/versioning.test.ts new file mode 100644 index 000000000..0042b7e04 --- /dev/null +++ b/tests/contracts/versioning.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { resolveContractVersion } from '../../supabase/functions/_shared/contracts/versioning'; +import { makeRequest } from './_helpers'; + +const config = { + supported: ['1', '2'], + default: '1', + deprecated: [ + { + version: '1', + sunset: '2026-08-31', + migrationUrl: 'https://example.com/migrate', + }, + ], +}; + +describe('contracts/versioning — negociação de versão', () => { + it('resolve default quando nenhum hint é fornecido', () => { + const req = makeRequest({}); + const r = resolveContractVersion(req, config); + expect(r.ok).toBe(true); + if (r.ok) expect(r.resolved.version).toBe('1'); + }); + + it('header accept-version tem prioridade sobre query', () => { + const req = makeRequest({ + url: 'https://edge.local/fn?v=2', + headers: { 'accept-version': '1' }, + }); + const r = resolveContractVersion(req, config); + expect(r.ok).toBe(true); + if (r.ok) expect(r.resolved.version).toBe('1'); + }); + + it('aceita formato v2, 2, 2.0', () => { + for (const v of ['2', 'v2', '2.0']) { + const req = makeRequest({ headers: { 'accept-version': v } }); + const r = resolveContractVersion(req, config); + expect(r.ok).toBe(true); + if (r.ok) expect(r.resolved.version).toBe('2'); + } + }); + + it('query ?v=2 é aceita quando header ausente', () => { + const req = makeRequest({ url: 'https://edge.local/fn?v=2' }); + const r = resolveContractVersion(req, config); + expect(r.ok).toBe(true); + if (r.ok) expect(r.resolved.version).toBe('2'); + }); + + it('versão não-suportada → 406 unsupported_version', async () => { + const req = makeRequest({ headers: { 'accept-version': '99' } }); + const r = resolveContractVersion(req, config); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.response.status).toBe(406); + const body = await r.response.json(); + expect(body.code).toBe('unsupported_version'); + } + }); + + it('versão deprecated → headers Deprecation/Sunset/Link (RFC 8594)', () => { + const req = makeRequest({ headers: { 'accept-version': '1' } }); + const r = resolveContractVersion(req, config); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.resolved.deprecation).toBeDefined(); + const h = r.resolved.responseHeaders; + expect(h['Deprecation']).toBe('true'); + expect(h['Sunset']).toMatch(/\d{4}/); + expect(h['Link']).toContain('https://example.com/migrate'); + expect(h['Link']).toContain('rel="deprecation"'); + } + }); + + it('versão não-deprecated → sem headers Deprecation', () => { + const req = makeRequest({ headers: { 'accept-version': '2' } }); + const r = resolveContractVersion(req, config); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.resolved.deprecation).toBeUndefined(); + expect(r.resolved.responseHeaders['Deprecation']).toBeUndefined(); + expect(r.resolved.responseHeaders['x-contract-version']).toBe('2'); + } + }); +}); From 8a65b75ef6bbc3e84971a22cba2e154e456b1cdb Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:53:47 -0300 Subject: [PATCH 07/24] test(contracts): add contract tests for product-webhook (13) + inbound/dispatcher (16) = 29 tests --- .../product-webhook.contract.test.ts | 220 ++++++++++++++++++ tests/contracts/webhooks.contract.test.ts | 210 +++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 tests/contracts/product-webhook.contract.test.ts create mode 100644 tests/contracts/webhooks.contract.test.ts diff --git a/tests/contracts/product-webhook.contract.test.ts b/tests/contracts/product-webhook.contract.test.ts new file mode 100644 index 000000000..c689f1de2 --- /dev/null +++ b/tests/contracts/product-webhook.contract.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest'; +import { parseContract } from '../../supabase/functions/_shared/contracts/parse'; +import { ProductWebhookSchemas } from '../../supabase/functions/_shared/contracts/schemas/product-webhook'; +import { makeRequest, expectContractError } from './_helpers'; + +describe('contract: product-webhook v1 (compat com produção)', () => { + it('aceita payload válido (upsert, default v1)', async () => { + const req = makeRequest({ + body: { + action: 'upsert', + product: { sku: 'ABC123', name: 'Caneta', price: 5.5 }, + }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.version).toBe('1'); + expect(r.data.action).toBe('upsert'); + expect(r.responseHeaders['Deprecation']).toBe('true'); + } + }); + + it('payload sem body → 400 missing_body', async () => { + const req = makeRequest({ body: '' }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 400, + code: 'missing_body', + }); + } + }); + + it('JSON quebrado → 400 invalid_json', async () => { + const req = makeRequest({ body: '{not-json' }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 400, + code: 'invalid_json', + }); + } + }); + + it("action inválido → 422 validation_failed em fields", async () => { + const req = makeRequest({ + body: { + action: 'explode', + product: { sku: 'X', name: 'Y', price: 1 }, + }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['action'], + }); + } + }); + + it('tipo errado em price (string em vez de number) → 422', async () => { + const req = makeRequest({ + body: { + action: 'upsert', + product: { sku: 'X', name: 'Y', price: 'free' }, + }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['product.price'], + }); + } + }); + + it("sku vazio ('') → 422 too_small", async () => { + const req = makeRequest({ + body: { action: 'upsert', product: { sku: '', name: 'Y', price: 1 } }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + const body = await r.response.json(); + const issue = body.fields.find( + (f: { path: string }) => f.path === 'product.sku' + ); + expect(issue).toBeDefined(); + } + }); +}); + +describe('contract: product-webhook v2 (strict)', () => { + const validV2 = { + action: 'upsert', + idempotency_key: '11111111-2222-3333-4444-555555555555', + product: { + external_id: 'ext-001', + sku: 'ABC', + name: 'Caneta', + price: 5.5, + }, + }; + + it('payload válido com accept-version: 2', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: validV2, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.version).toBe('2'); + expect(r.responseHeaders['Deprecation']).toBeUndefined(); + } + }); + + it('v2 sem idempotency_key → 422', async () => { + const { idempotency_key: _ik, ...rest } = validV2; + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: rest, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['idempotency_key'], + }); + } + }); + + it('v2 strict: campo extra desconhecido → 422', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { ...validV2, totally_random_field: 123 }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + const body = await r.response.json(); + expect(body.code).toBe('validation_failed'); + } + }); + + it("v2 action='delete' sem external_ids → 422", async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { + action: 'delete', + idempotency_key: '11111111-2222-3333-4444-555555555555', + }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['external_ids'], + }); + } + }); + + it('v2 batch_upsert com products=[] → 422', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { + action: 'batch_upsert', + idempotency_key: '11111111-2222-3333-4444-555555555555', + products: [], + }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['products'], + }); + } + }); + + it("v2 não aceita action='sync' (foi removido em v2)", async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { ...validV2, action: 'sync' }, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['action'], + }); + } + }); + + it('versão inexistente → 406 unsupported_version', async () => { + const req = makeRequest({ + headers: { 'accept-version': '99' }, + body: validV2, + }); + const r = await parseContract(req, ProductWebhookSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.response.status).toBe(406); + } + }); +}); diff --git a/tests/contracts/webhooks.contract.test.ts b/tests/contracts/webhooks.contract.test.ts new file mode 100644 index 000000000..f5ec5a7fc --- /dev/null +++ b/tests/contracts/webhooks.contract.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; +import { parseContract } from '../../supabase/functions/_shared/contracts/parse'; +import { WebhookInboundSchemas } from '../../supabase/functions/_shared/contracts/schemas/webhook-inbound'; +import { WebhookDispatcherSchemas } from '../../supabase/functions/_shared/contracts/schemas/webhook-dispatcher'; +import { makeRequest, expectContractError } from './_helpers'; + +// ─── webhook-inbound ─────────────────────────────────────── + +describe('contract: webhook-inbound v1 (passthrough)', () => { + it('aceita qualquer objeto (compat com produção)', async () => { + const req = makeRequest({ body: { hello: 'world', random: 42 } }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(true); + if (r.ok) expect(r.version).toBe('1'); + }); + + it('aceita array (compat — v1 é any)', async () => { + const req = makeRequest({ body: [1, 2, 3] }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(true); + }); + + it('body vazio → 400 missing_body', async () => { + const req = makeRequest({ body: '' }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 400, + code: 'missing_body', + }); + } + }); +}); + +describe('contract: webhook-inbound v2 (envelope strict)', () => { + const validV2 = { + event: 'order.created', + occurred_at: '2026-05-21T10:00:00Z', + data: { order_id: 'abc' }, + }; + + it('envelope válido', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: validV2, + }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(true); + if (r.ok) expect(r.version).toBe('2'); + }); + + it('sem event → 422', async () => { + const { event: _e, ...rest } = validV2; + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: rest, + }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['event'], + }); + } + }); + + it('occurred_at não-ISO → 422', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { ...validV2, occurred_at: 'ontem às 10h' }, + }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['occurred_at'], + }); + } + }); + + it('event com chars inválidos → 422', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { ...validV2, event: 'order created!!' }, + }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(false); + }); + + it('idempotency_key precisa ser UUID quando presente', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { ...validV2, idempotency_key: 'not-a-uuid' }, + }); + const r = await parseContract(req, WebhookInboundSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['idempotency_key'], + }); + } + }); +}); + +// ─── webhook-dispatcher ──────────────────────────────────── + +describe('contract: webhook-dispatcher v1 (compat)', () => { + it('aceita {event, payload}', async () => { + const req = makeRequest({ + body: { event: 'test.fired', payload: { a: 1 } }, + }); + const r = await parseContract(req, WebhookDispatcherSchemas); + expect(r.ok).toBe(true); + }); + + it('aceita replay_delivery_id UUID', async () => { + const req = makeRequest({ + body: { + event: 'noop', + replay_delivery_id: '11111111-2222-3333-4444-555555555555', + }, + }); + const r = await parseContract(req, WebhookDispatcherSchemas); + expect(r.ok).toBe(true); + }); + + it('event vazio → 422', async () => { + const req = makeRequest({ body: { event: '' } }); + const r = await parseContract(req, WebhookDispatcherSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['event'], + }); + } + }); +}); + +describe('contract: webhook-dispatcher v2 (discriminated union)', () => { + it("mode='dispatch' válido", async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { mode: 'dispatch', event: 'order.created', payload: { id: 1 } }, + }); + const r = await parseContract(req, WebhookDispatcherSchemas); + expect(r.ok).toBe(true); + }); + + it("mode='replay' válido apenas com UUID", async () => { + const ok = makeRequest({ + headers: { 'accept-version': '2' }, + body: { + mode: 'replay', + replay_delivery_id: '11111111-2222-3333-4444-555555555555', + }, + }); + const okR = await parseContract(ok, WebhookDispatcherSchemas); + expect(okR.ok).toBe(true); + + const bad = makeRequest({ + headers: { 'accept-version': '2' }, + body: { mode: 'replay', replay_delivery_id: 'abc' }, + }); + const badR = await parseContract(bad, WebhookDispatcherSchemas); + expect(badR.ok).toBe(false); + }); + + it("mode='test' exige test_webhook_id", async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { mode: 'test', event: 'x', payload: {} }, + }); + const r = await parseContract(req, WebhookDispatcherSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { + status: 422, + code: 'validation_failed', + fieldPaths: ['test_webhook_id'], + }); + } + }); + + it('mode inválido → 422', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { mode: 'delete-everything' }, + }); + const r = await parseContract(req, WebhookDispatcherSchemas); + expect(r.ok).toBe(false); + }); + + it('v2 não aceita payload extra fora do schema (strict)', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { mode: 'dispatch', event: 'ok', payload: {}, hidden: true }, + }); + const r = await parseContract(req, WebhookDispatcherSchemas); + expect(r.ok).toBe(false); + }); +}); From e15a092af9cdb98bf181892b5eb82e324a4d0585 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:54:37 -0300 Subject: [PATCH 08/24] =?UTF-8?q?refactor(scripts):=20rewrite=20contract-t?= =?UTF-8?q?esting.mjs=20=E2=80=94=20consume=20central=20schemas,=20remove?= =?UTF-8?q?=20hardcoded=20service=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/contract-testing.mjs | 347 +++++++++++++++++++++++++++-------- 1 file changed, 269 insertions(+), 78 deletions(-) diff --git a/scripts/contract-testing.mjs b/scripts/contract-testing.mjs index 1d0d35fcf..ff2967be7 100644 --- a/scripts/contract-testing.mjs +++ b/scripts/contract-testing.mjs @@ -1,112 +1,303 @@ -import * as dotenv from 'dotenv'; -dotenv.config(); +#!/usr/bin/env node +/** + * scripts/contract-testing.mjs + * + * Smoke contract tests dispatched against running Edge Functions. + * + * Diferente do unitário vitest (`tests/contracts/*.test.ts`), este script: + * - É executado fora da CI (manualmente ou via `npm run test:contract`) + * - Faz HTTP real contra o ambiente alvo (default: localhost supabase functions) + * - Verifica que o formato de resposta {code, message, fields} é respeitado + * - Testa: payload válido, missing body, invalid JSON, missing field, wrong type, + * unsupported version, deprecated version (headers Deprecation/Sunset) + * + * Variáveis de ambiente: + * SUPABASE_URL default http://localhost:54321 + * SUPABASE_ANON_KEY obrigatório + * N8N_PRODUCT_WEBHOOK_SECRET se setado, enviado em x-webhook-secret + * CONTRACT_TEST_TIMEOUT_MS default 10000 + * + * Códigos de saída: + * 0 → todos os contratos passaram + * 1 → ao menos um falhou (detalhes no stdout) + * 2 → variável de ambiente obrigatória ausente + */ -const SUPABASE_URL = process.env.SUPABASE_URL || "https://pqpdolkaeqlyzpdpbizo.supabase.co"; -// Usando a chave de simulação estável definida para este projeto -const SERVICE_ROLE_KEY = "a46c3981-244a-4f81-9f57-bab5c45b5cde"; +import process from 'node:process'; + +// --------------------------------------------------------------------------- +// Configuração +// --------------------------------------------------------------------------- + +const SUPABASE_URL = process.env.SUPABASE_URL || 'http://localhost:54321'; +const ANON_KEY = process.env.SUPABASE_ANON_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; +const PRODUCT_WEBHOOK_SECRET = process.env.N8N_PRODUCT_WEBHOOK_SECRET || ''; +const TIMEOUT_MS = Number(process.env.CONTRACT_TEST_TIMEOUT_MS || 10000); + +if (!ANON_KEY) { + console.error('❌ SUPABASE_ANON_KEY (ou SERVICE_ROLE_KEY) é obrigatório.'); + process.exit(2); +} + +// --------------------------------------------------------------------------- +// Definição dos contratos +// --------------------------------------------------------------------------- const CONTRACTS = [ { - name: "product-webhook", - endpoint: "product-webhook", - headers: { "x-webhook-secret": process.env.N8N_PRODUCT_WEBHOOK_SECRET || "sim-secret" }, + name: 'product-webhook', + endpoint: 'product-webhook', + extraHeaders: PRODUCT_WEBHOOK_SECRET + ? { 'x-webhook-secret': PRODUCT_WEBHOOK_SECRET } + : {}, scenarios: [ { - description: "Valid upsert payload", + description: 'valid payload v1 (default)', payload: { - action: "upsert", - product: { sku: `TEST-${Date.now()}`, name: "Test Product", price: 10.5 } + action: 'upsert', + product: { sku: `CT-${Date.now()}`, name: 'Contract test', price: 1.0 }, }, - expectedStatus: 200, - validateResponse: (data) => data.success === true && typeof data.sync_log_id === 'string' + expect: { status: 200, deprecation: true, version: '1' }, }, { - description: "Invalid action enum", - payload: { action: "invalid-action", product: { sku: "T", name: "T", price: 0 } }, - expectedStatus: 400, - validateResponse: (data) => data.error === "Validation failed" && data.details.action !== undefined - } - ] + description: 'missing body → 400 missing_body', + rawBody: '', + expect: { status: 400, code: 'missing_body' }, + }, + { + description: 'invalid JSON → 400 invalid_json', + rawBody: '{not-json', + expect: { status: 400, code: 'invalid_json' }, + }, + { + description: 'invalid action enum → 422 validation_failed', + payload: { + action: 'explode', + product: { sku: 'X', name: 'Y', price: 1 }, + }, + expect: { + status: 422, + code: 'validation_failed', + fieldPaths: ['action'], + }, + }, + { + description: 'wrong type in price → 422', + payload: { + action: 'upsert', + product: { sku: 'X', name: 'Y', price: 'free' }, + }, + expect: { + status: 422, + code: 'validation_failed', + fieldPaths: ['product.price'], + }, + }, + { + description: 'unsupported version → 406', + headers: { 'accept-version': '99' }, + payload: { action: 'upsert', product: { sku: 'X', name: 'Y', price: 1 } }, + expect: { status: 406, code: 'unsupported_version' }, + }, + { + description: 'v2 valid with idempotency_key → 200, no deprecation', + headers: { 'accept-version': '2' }, + payload: { + action: 'upsert', + idempotency_key: '11111111-2222-3333-4444-555555555555', + product: { + external_id: 'ct-ext', + sku: `CT2-${Date.now()}`, + name: 'Contract v2', + price: 1.0, + }, + }, + expect: { status: 200, deprecation: false, version: '2' }, + }, + ], }, { - name: "cnpj-lookup", - endpoint: "cnpj-lookup", + name: 'webhook-dispatcher', + endpoint: 'webhook-dispatcher', + extraHeaders: process.env.WEBHOOK_DISPATCHER_SECRET + ? { 'x-dispatcher-secret': process.env.WEBHOOK_DISPATCHER_SECRET } + : {}, scenarios: [ { - description: "Valid format simulation", - payload: { cnpj: "00.000.000/0001-91" }, - expectedStatus: 200, - validateResponse: (data) => data.cnpj !== undefined || data.error !== undefined - } - ] + description: 'v1 event-only → ok or dispatched', + payload: { event: 'contract.test', payload: { hello: 'world' } }, + expect: { statusIn: [200, 401] }, // 401 se dispatcher secret faltar + }, + { + description: 'v1 empty event → 422', + payload: { event: '' }, + expect: { status: 422, code: 'validation_failed', fieldPaths: ['event'] }, + }, + { + description: 'v2 mode=replay sem UUID → 422', + headers: { 'accept-version': '2' }, + payload: { mode: 'replay', replay_delivery_id: 'abc' }, + expect: { status: 422, code: 'validation_failed' }, + }, + ], }, { - name: "external-db-bridge", - endpoint: "external-db-bridge", + name: 'webhook-inbound', + endpoint: 'webhook-inbound?slug=contract-test-slug', + extraHeaders: {}, scenarios: [ { - description: "Valid select simulation", - payload: { operation: "select", table: "products", limit: 1 }, - expectedStatus: 200, - validateResponse: (data) => Array.isArray(data.records || data.data?.records) + description: 'unknown slug → 404', + payload: { hello: 'world' }, + expect: { statusIn: [404, 400] }, + }, + { + description: 'empty body → 400 missing_body', + rawBody: '', + expect: { statusIn: [400, 404] }, // 404 vem antes de body check + }, + { + description: 'v2 valid envelope (will fail at HMAC, but contract passes)', + headers: { 'accept-version': '2' }, + payload: { + event: 'contract.test', + occurred_at: new Date().toISOString(), + data: { ping: true }, + }, + expect: { statusIn: [200, 401, 404] }, + }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +async function runScenario(contract, scenario) { + const url = `${SUPABASE_URL}/functions/v1/${contract.endpoint}`; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ANON_KEY}`, + ...(contract.extraHeaders ?? {}), + ...(scenario.headers ?? {}), + }; + + const body = + scenario.rawBody !== undefined ? scenario.rawBody : JSON.stringify(scenario.payload); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + let res; + try { + res = await fetch(url, { method: 'POST', headers, body, signal: controller.signal }); + } catch (err) { + return { ok: false, reason: `fetch error: ${err.message}` }; + } finally { + clearTimeout(timer); + } + + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + json = null; + } + + // 1. Status check + if (scenario.expect.status !== undefined && res.status !== scenario.expect.status) { + return { + ok: false, + reason: `status: expected ${scenario.expect.status}, got ${res.status} body=${text.slice(0, 200)}`, + }; + } + if (scenario.expect.statusIn && !scenario.expect.statusIn.includes(res.status)) { + return { + ok: false, + reason: `status not in ${scenario.expect.statusIn.join('|')}, got ${res.status}`, + }; + } + + // 2. Error code check + if (scenario.expect.code) { + if (!json || json.code !== scenario.expect.code) { + return { + ok: false, + reason: `code: expected "${scenario.expect.code}", got ${json?.code ?? ''}`, + }; + } + if (!Array.isArray(json.fields)) { + return { ok: false, reason: 'response missing fields[]' }; + } + } + + // 3. Field path check + if (scenario.expect.fieldPaths) { + const paths = (json?.fields ?? []).map((f) => f.path); + for (const expected of scenario.expect.fieldPaths) { + if (!paths.includes(expected)) { + return { + ok: false, + reason: `expected field path "${expected}" not in ${JSON.stringify(paths)}`, + }; } - ] + } } -]; -async function runContractTests() { - console.log("🚀 Iniciando Testes de Contrato (Simulation Mode)..."); + // 4. Version headers + if (scenario.expect.version !== undefined) { + const v = res.headers.get('x-contract-version'); + if (v !== scenario.expect.version) { + return { + ok: false, + reason: `x-contract-version: expected "${scenario.expect.version}", got "${v}"`, + }; + } + } + if (scenario.expect.deprecation === true) { + if (res.headers.get('Deprecation') !== 'true') { + return { ok: false, reason: 'missing Deprecation: true header' }; + } + if (!res.headers.get('Sunset')) { + return { ok: false, reason: 'missing Sunset header' }; + } + } + if (scenario.expect.deprecation === false) { + if (res.headers.get('Deprecation') === 'true') { + return { ok: false, reason: 'unexpected Deprecation header on non-deprecated version' }; + } + } + + return { ok: true }; +} + +async function main() { + console.log(`🚀 Contract tests against ${SUPABASE_URL}`); let passed = 0; - let failedCount = 0; + let failed = 0; for (const contract of CONTRACTS) { - console.log(`\n📦 Contrato: ${contract.name}`); + console.log(`\n📦 ${contract.name}`); for (const scenario of contract.scenarios) { process.stdout.write(` - ${scenario.description}: `); - try { - const url = `${SUPABASE_URL}/functions/v1/${contract.endpoint}`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${SERVICE_ROLE_KEY}`, - ...contract.headers - }, - body: JSON.stringify(scenario.payload) - }); - - const actualStatus = response.status; - const responseData = await response.json().catch(() => ({})); - - const statusMatch = actualStatus === scenario.expectedStatus; - const validationMatch = scenario.validateResponse ? scenario.validateResponse(responseData) : true; - - if (statusMatch && validationMatch) { - console.log("✅ PASS"); - passed++; - } else { - console.log("❌ FAIL"); - console.log(` Esperado: ${scenario.expectedStatus}, Obtido: ${actualStatus}`); - console.log(` Resposta: ${JSON.stringify(responseData)}`); - failedCount++; - } - } catch (err) { - console.log("💥 CRASH"); - console.error(err); - failedCount++; + const result = await runScenario(contract, scenario); + if (result.ok) { + console.log('✅ PASS'); + passed++; + } else { + console.log(`❌ FAIL — ${result.reason}`); + failed++; } } } - console.log(`\n--- RESULTADO DOS TESTES DE CONTRATO ---`); - console.log(`Sucessos: ${passed}`); - console.log(`Falhas: ${failedCount}`); - console.log(`----------------------------------------\n`); - - if (failedCount > 0) process.exit(1); + console.log(`\n${'='.repeat(50)}`); + console.log(`Total: ${passed + failed} passed: ${passed} failed: ${failed}`); + process.exit(failed === 0 ? 0 : 1); } -runContractTests().catch(err => { - console.error(err); +main().catch((err) => { + console.error('Fatal:', err); process.exit(1); }); From 0c1a069eef7f2a64e4b83a5196df0f1ead8bcc69 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 20:55:51 -0300 Subject: [PATCH 09/24] docs(contracts): add README + MIGRATION_GUIDE (priorized P0/P1/P2 list of 14 funcs + 5-step recipe + special cases) --- docs/contracts/MIGRATION_GUIDE.md | 191 ++++++++++++++++++++++++++++++ docs/contracts/README.md | 156 ++++++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 docs/contracts/MIGRATION_GUIDE.md create mode 100644 docs/contracts/README.md diff --git a/docs/contracts/MIGRATION_GUIDE.md b/docs/contracts/MIGRATION_GUIDE.md new file mode 100644 index 000000000..14baba9c6 --- /dev/null +++ b/docs/contracts/MIGRATION_GUIDE.md @@ -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/.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**. diff --git a/docs/contracts/README.md b/docs/contracts/README.md new file mode 100644 index 000000000..bb983593f --- /dev/null +++ b/docs/contracts/README.md @@ -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: ; 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/.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://.supabase.co \ +SUPABASE_ANON_KEY=... \ + npm run test:contract +``` From 3f0d7dfcb48e3639cee7bd92167eed9dbc9674a8 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:23:48 -0300 Subject: [PATCH 10/24] =?UTF-8?q?feat(contracts):=20P0=20schemas=20(1/4)?= =?UTF-8?q?=20=E2=80=94=20send-transactional-email,=20kit-ai-builder,=20bi?= =?UTF-8?q?-copilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_shared/contracts/schemas/bi-copilot.ts | 41 +++++++++++++++++ .../contracts/schemas/kit-ai-builder.ts | 35 +++++++++++++++ .../schemas/send-transactional-email.ts | 45 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 supabase/functions/_shared/contracts/schemas/bi-copilot.ts create mode 100644 supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts create mode 100644 supabase/functions/_shared/contracts/schemas/send-transactional-email.ts diff --git a/supabase/functions/_shared/contracts/schemas/bi-copilot.ts b/supabase/functions/_shared/contracts/schemas/bi-copilot.ts new file mode 100644 index 000000000..4b3588210 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/bi-copilot.ts @@ -0,0 +1,41 @@ +/** + * supabase/functions/_shared/contracts/schemas/bi-copilot.ts + * + * v1: question 1-500 chars + context/history opcionais (usados no handler). + * Sunset 2026-10-31. + * v2: strict + context obrigatório. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +const ChatMessage = z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().max(10000), +}); + +export const BiCopilotV1 = z.object({ + question: z.string().min(1).max(500), + context: z.record(z.unknown()).optional(), + history: z.array(ChatMessage).max(50).optional(), +}); + +export const BiCopilotV2 = z + .object({ + question: z.string().min(3).max(500), + context: z.record(z.unknown()), + history: z.array(ChatMessage).max(50).optional(), + }) + .strict(); + +export const BiCopilotSchemas = { + name: "bi-copilot", + versions: { "1": BiCopilotV1, "2": BiCopilotV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-10-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#bi-copilot", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts b/supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts new file mode 100644 index 000000000..13f5f46d9 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts @@ -0,0 +1,35 @@ +/** + * supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts + * + * v1: prompt 6-2000 chars. Sunset 2026-10-31. + * v2: strict + idempotency_key. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +export const KitAiBuilderV1 = z.object({ + prompt: z + .string() + .min(6, { message: "prompt inválido (6–2000 chars)" }) + .max(2000, { message: "prompt inválido (6–2000 chars)" }), +}); + +export const KitAiBuilderV2 = z + .object({ + prompt: z.string().min(6).max(2000), + idempotency_key: z.string().uuid(), + }) + .strict(); + +export const KitAiBuilderSchemas = { + name: "kit-ai-builder", + versions: { "1": KitAiBuilderV1, "2": KitAiBuilderV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-10-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#kit-ai-builder", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/send-transactional-email.ts b/supabase/functions/_shared/contracts/schemas/send-transactional-email.ts new file mode 100644 index 000000000..97d80a71f --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/send-transactional-email.ts @@ -0,0 +1,45 @@ +/** + * supabase/functions/_shared/contracts/schemas/send-transactional-email.ts + * + * v1: shape EmailRequest atual (compat). Sunset 2026-10-31. + * v2: strict + idempotency_key. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +const EmailEvent = z.enum([ + "quote_sent", + "quote_approved", + "quote_rejected", + "order_created", +]); + +export const SendTransactionalEmailV1 = z.object({ + event_type: EmailEvent, + recipient_email: z.string().email().max(320), + recipient_name: z.string().max(200).optional(), + data: z.record(z.unknown()).default({}), +}); + +export const SendTransactionalEmailV2 = z + .object({ + event_type: EmailEvent, + recipient_email: z.string().email().max(320), + recipient_name: z.string().min(1).max(200).optional(), + data: z.record(z.unknown()), + idempotency_key: z.string().uuid(), + }) + .strict(); + +export const SendTransactionalEmailSchemas = { + name: "send-transactional-email", + versions: { "1": SendTransactionalEmailV1, "2": SendTransactionalEmailV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-10-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#send-transactional-email", + }, + ], +}; From 0d2b75b2dab26475e4fd34383d73c74810acd020 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:24:26 -0300 Subject: [PATCH 11/24] =?UTF-8?q?feat(contracts):=20P0=20schemas=20(2/4)?= =?UTF-8?q?=20=E2=80=94=20market-intelligence-insights,=20step-up-verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schemas/market-intelligence-insights.ts | 48 +++++++++++ .../contracts/schemas/step-up-verify.ts | 84 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts create mode 100644 supabase/functions/_shared/contracts/schemas/step-up-verify.ts diff --git a/supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts b/supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts new file mode 100644 index 000000000..6c989750c --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts @@ -0,0 +1,48 @@ +/** + * supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts + * + * v1: todos campos opcionais (compat com body vazio). Sunset 2026-10-31. + * v2: strict, UUIDs validados. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +export const MarketIntelligenceInsightsV1 = z.object({ + days: z.number().int().min(1).max(365).optional(), + categoryId: z.string().max(100).nullable().optional(), + supplierId: z.string().max(100).nullable().optional(), + productId: z.string().max(100).nullable().optional(), + categoryName: z.string().max(255).nullable().optional(), + supplierName: z.string().max(255).nullable().optional(), + productName: z.string().max(255).nullable().optional(), + forceRefresh: z.boolean().optional(), +}); + +export const MarketIntelligenceInsightsV2 = z + .object({ + days: z.number().int().min(1).max(365).default(30), + categoryId: z.string().uuid().nullable().optional(), + supplierId: z.string().uuid().nullable().optional(), + productId: z.string().uuid().nullable().optional(), + categoryName: z.string().min(1).max(255).nullable().optional(), + supplierName: z.string().min(1).max(255).nullable().optional(), + productName: z.string().min(1).max(255).nullable().optional(), + forceRefresh: z.boolean().default(false), + }) + .strict(); + +export const MarketIntelligenceInsightsSchemas = { + name: "market-intelligence-insights", + versions: { + "1": MarketIntelligenceInsightsV1, + "2": MarketIntelligenceInsightsV2, + }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-10-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#market-intelligence-insights", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/step-up-verify.ts b/supabase/functions/_shared/contracts/schemas/step-up-verify.ts new file mode 100644 index 000000000..b00111df1 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/step-up-verify.ts @@ -0,0 +1,84 @@ +/** + * supabase/functions/_shared/contracts/schemas/step-up-verify.ts + * + * v1: shape único permissivo (todos campos opcionais exceto step). + * Sunset 2026-10-31. + * v2: discriminated union por step — auth sensível. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +const ActionEnum = z.enum([ + "promote_dev", + "demote_dev", + "mcp_full_issue", + "mcp_full_escalate", + "mcp_key_revoke", + "mcp_key_rotate", + "secret_rotation", + "secret_revoke", +]); + +export const StepUpVerifyV1 = z.object({ + step: z.enum(["request", "verify_password", "verify_otp", "cancel"]), + action: ActionEnum.optional(), + action_label: z.string().max(200).optional(), + target_ref: z.string().max(500).nullable().optional(), + challenge_id: z.string().max(100).optional(), + password: z.string().max(1000).optional(), + otp: z.string().max(20).optional(), + cancel_reason: z.string().max(500).optional(), +}); + +const RequestStepV2 = z + .object({ + step: z.literal("request"), + action: ActionEnum, + action_label: z.string().min(1).max(200).optional(), + target_ref: z.string().max(500).nullable().optional(), + }) + .strict(); + +const VerifyPasswordStepV2 = z + .object({ + step: z.literal("verify_password"), + challenge_id: z.string().uuid(), + password: z.string().min(1).max(1000), + }) + .strict(); + +const VerifyOtpStepV2 = z + .object({ + step: z.literal("verify_otp"), + challenge_id: z.string().uuid(), + otp: z.string().min(4).max(20), + }) + .strict(); + +const CancelStepV2 = z + .object({ + step: z.literal("cancel"), + challenge_id: z.string().uuid(), + cancel_reason: z.string().max(500).optional(), + }) + .strict(); + +export const StepUpVerifyV2 = z.discriminatedUnion("step", [ + RequestStepV2, + VerifyPasswordStepV2, + VerifyOtpStepV2, + CancelStepV2, +]); + +export const StepUpVerifySchemas = { + name: "step-up-verify", + versions: { "1": StepUpVerifyV1, "2": StepUpVerifyV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-10-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#step-up-verify", + }, + ], +}; From d49ef3e5e2695de5da8bc0b0e9dad79d4a7ab748 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:25:06 -0300 Subject: [PATCH 12/24] =?UTF-8?q?feat(contracts):=20P1=20schemas=20(3/4)?= =?UTF-8?q?=20=E2=80=94=20ownership-audit,=20ownership-repair,=20simulatio?= =?UTF-8?q?n-orchestrator,=20sync-external-db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contracts/schemas/ownership-audit.ts | 31 ++++++++++++ .../contracts/schemas/ownership-repair.ts | 36 ++++++++++++++ .../schemas/simulation-orchestrator.ts | 47 +++++++++++++++++++ .../contracts/schemas/sync-external-db.ts | 37 +++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 supabase/functions/_shared/contracts/schemas/ownership-audit.ts create mode 100644 supabase/functions/_shared/contracts/schemas/ownership-repair.ts create mode 100644 supabase/functions/_shared/contracts/schemas/simulation-orchestrator.ts create mode 100644 supabase/functions/_shared/contracts/schemas/sync-external-db.ts diff --git a/supabase/functions/_shared/contracts/schemas/ownership-audit.ts b/supabase/functions/_shared/contracts/schemas/ownership-audit.ts new file mode 100644 index 000000000..e2d859f1f --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/ownership-audit.ts @@ -0,0 +1,31 @@ +/** + * supabase/functions/_shared/contracts/schemas/ownership-audit.ts + * + * v1: body opcional; default "cron" no handler. Sunset 2026-11-30. + * v2: triggered_by obrigatório. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +export const OwnershipAuditV1 = z.object({ + triggered_by: z.string().max(64).optional(), +}); + +export const OwnershipAuditV2 = z + .object({ + triggered_by: z.string().min(1).max(64), + }) + .strict(); + +export const OwnershipAuditSchemas = { + name: "ownership-audit", + versions: { "1": OwnershipAuditV1, "2": OwnershipAuditV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-11-30", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#ownership-audit", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/ownership-repair.ts b/supabase/functions/_shared/contracts/schemas/ownership-repair.ts new file mode 100644 index 000000000..3f3ec5eea --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/ownership-repair.ts @@ -0,0 +1,36 @@ +/** + * supabase/functions/_shared/contracts/schemas/ownership-repair.ts + * + * v1: todos opcionais. Sunset 2026-11-30. + * v2: dry_run obrigatório + idempotency_key. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +export const OwnershipRepairV1 = z.object({ + report_id: z.string().max(100).optional(), + dry_run: z.boolean().optional(), + triggered_by: z.string().max(64).optional(), +}); + +export const OwnershipRepairV2 = z + .object({ + report_id: z.string().uuid().optional(), + dry_run: z.boolean(), + triggered_by: z.string().min(1).max(64), + idempotency_key: z.string().uuid(), + }) + .strict(); + +export const OwnershipRepairSchemas = { + name: "ownership-repair", + versions: { "1": OwnershipRepairV1, "2": OwnershipRepairV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-11-30", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#ownership-repair", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/simulation-orchestrator.ts b/supabase/functions/_shared/contracts/schemas/simulation-orchestrator.ts new file mode 100644 index 000000000..56dfa4f3e --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/simulation-orchestrator.ts @@ -0,0 +1,47 @@ +/** + * supabase/functions/_shared/contracts/schemas/simulation-orchestrator.ts + * + * v1: opcionais com defaults no handler. Sunset 2026-11-30. + * v2: targetFunctions com 1+ items, mode obrigatório, idempotency_key. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +const ModeEnum = z.enum(["resilience", "load", "fuzzing"]); +const TargetFnEnum = z.enum([ + "external-db-bridge", + "webhook-inbound", + "product-webhook", + "webhook-dispatcher", +]); + +export const SimulationOrchestratorV1 = z.object({ + count: z.number().int().min(1).max(10000).optional(), + targetFunctions: z.array(z.string().max(100)).max(20).optional(), + mode: ModeEnum.optional(), +}); + +export const SimulationOrchestratorV2 = z + .object({ + count: z.number().int().min(1).max(10000).default(100), + targetFunctions: z.array(TargetFnEnum).min(1).max(20), + mode: ModeEnum, + idempotency_key: z.string().uuid(), + }) + .strict(); + +export const SimulationOrchestratorSchemas = { + name: "simulation-orchestrator", + versions: { + "1": SimulationOrchestratorV1, + "2": SimulationOrchestratorV2, + }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-11-30", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#simulation-orchestrator", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/sync-external-db.ts b/supabase/functions/_shared/contracts/schemas/sync-external-db.ts new file mode 100644 index 000000000..a94b24d0e --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/sync-external-db.ts @@ -0,0 +1,37 @@ +/** + * supabase/functions/_shared/contracts/schemas/sync-external-db.ts + * + * v1: table obrigatório. Sunset 2026-11-30. + * v2: strict + since como ISO 8601. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +const DirectionEnum = z.enum(["to-external", "from-external"]); + +export const SyncExternalDbV1 = z.object({ + table: z.string().min(1).max(100), + direction: DirectionEnum.optional(), + since: z.string().max(50).optional(), +}); + +export const SyncExternalDbV2 = z + .object({ + table: z.string().min(1).max(63), + direction: DirectionEnum, + since: z.string().datetime({ offset: true }).optional(), + }) + .strict(); + +export const SyncExternalDbSchemas = { + name: "sync-external-db", + versions: { "1": SyncExternalDbV1, "2": SyncExternalDbV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-11-30", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#sync-external-db", + }, + ], +}; From c4108def7a0e17818b65c52cc3f40b76b935f6db Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:25:40 -0300 Subject: [PATCH 13/24] =?UTF-8?q?feat(contracts):=20P1+P2=20schemas=20(4/4?= =?UTF-8?q?)=20=E2=80=94=20trends-insights,=20force-global-logout,=20e2e-c?= =?UTF-8?q?leanup,=20block-ip-temporarily?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contracts/schemas/block-ip-temporarily.ts | 45 ++++++++++++++++++ .../_shared/contracts/schemas/e2e-cleanup.ts | 47 +++++++++++++++++++ .../contracts/schemas/force-global-logout.ts | 32 +++++++++++++ .../contracts/schemas/trends-insights.ts | 31 ++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts create mode 100644 supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts create mode 100644 supabase/functions/_shared/contracts/schemas/force-global-logout.ts create mode 100644 supabase/functions/_shared/contracts/schemas/trends-insights.ts diff --git a/supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts b/supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts new file mode 100644 index 000000000..0d9ee9d36 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts @@ -0,0 +1,45 @@ +/** + * supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts + * + * v1: regex permissivo (mesmo do handler atual). Sunset 2026-12-31. + * v2: regex IPv4/IPv6/CIDR rigoroso + strict. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +// V1 = mesmo regex permissivo do handler atual em produção (compat exato) +const IP_REGEX_V1 = /^[0-9a-fA-F:.\/]{3,45}$/; +// V2 = validação rigorosa IPv4/IPv6/CIDR +const IP_REGEX_V2 = + /^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$|^([0-9a-fA-F:]+)(\/([0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$/; + +export const BlockIpTemporarilyV1 = z.object({ + ip: z.string().min(1).max(45).regex(IP_REGEX_V1, { + message: "IP inválido (use IPv4, IPv6 ou CIDR)", + }), + reason: z.string().max(500).optional(), + hours: z.number().int().min(1).max(720).optional(), +}); + +export const BlockIpTemporarilyV2 = z + .object({ + ip: z.string().min(1).max(45).regex(IP_REGEX_V2, { + message: "IP inválido (use IPv4, IPv6 ou CIDR)", + }), + reason: z.string().min(1).max(500), + hours: z.number().int().min(1).max(720), + }) + .strict(); + +export const BlockIpTemporarilySchemas = { + name: "block-ip-temporarily", + versions: { "1": BlockIpTemporarilyV1, "2": BlockIpTemporarilyV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-12-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#block-ip-temporarily", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts b/supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts new file mode 100644 index 000000000..f4c9af3d5 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts @@ -0,0 +1,47 @@ +/** + * supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts + * + * v1: shape permissivo do handler. Sunset 2026-12-31. + * v2: strict + email obrigatório + confirm:true + idempotency_key. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +const SellerScopeEnum = z.enum(["self", "explicit"]); + +export const E2eCleanupV1 = z.object({ + email: z.string().email().max(320).optional(), + dryRun: z.boolean().optional(), + sellerScope: SellerScopeEnum.optional(), + sellerId: z.string().max(100).optional(), + nameFilterPrefix: z.string().max(100).optional(), +}); + +export const E2eCleanupV2 = z + .object({ + email: z.string().email().max(320), + dryRun: z.boolean(), + sellerScope: SellerScopeEnum.default("self"), + sellerId: z.string().uuid().optional(), + nameFilterPrefix: z.string().min(1).max(100).optional(), + confirm: z.literal(true), + idempotency_key: z.string().uuid(), + }) + .strict() + .refine( + (v) => v.sellerScope !== "explicit" || !!v.sellerId, + { message: "sellerId is required when sellerScope='explicit'", path: ["sellerId"] }, + ); + +export const E2eCleanupSchemas = { + name: "e2e-cleanup", + versions: { "1": E2eCleanupV1, "2": E2eCleanupV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-12-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#e2e-cleanup", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/force-global-logout.ts b/supabase/functions/_shared/contracts/schemas/force-global-logout.ts new file mode 100644 index 000000000..d9ad9da43 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/force-global-logout.ts @@ -0,0 +1,32 @@ +/** + * supabase/functions/_shared/contracts/schemas/force-global-logout.ts + * + * v1: confirm literal. Sunset 2026-12-31. + * v2: confirm + idempotency_key (destrutivo). + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +export const ForceGlobalLogoutV1 = z.object({ + confirm: z.literal("FORCE_LOGOUT_ALL"), +}); + +export const ForceGlobalLogoutV2 = z + .object({ + confirm: z.literal("FORCE_LOGOUT_ALL"), + idempotency_key: z.string().uuid(), + }) + .strict(); + +export const ForceGlobalLogoutSchemas = { + name: "force-global-logout", + versions: { "1": ForceGlobalLogoutV1, "2": ForceGlobalLogoutV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-12-31", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#force-global-logout", + }, + ], +}; diff --git a/supabase/functions/_shared/contracts/schemas/trends-insights.ts b/supabase/functions/_shared/contracts/schemas/trends-insights.ts new file mode 100644 index 000000000..c06df44c1 --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/trends-insights.ts @@ -0,0 +1,31 @@ +/** + * supabase/functions/_shared/contracts/schemas/trends-insights.ts + * + * v1: days opcional (1-365). Sunset 2026-11-30. + * v2: strict. + */ +import { z } from "https://esm.sh/zod@3.23.8"; + +export const TrendsInsightsV1 = z.object({ + days: z.number().int().min(1).max(365).optional(), +}); + +export const TrendsInsightsV2 = z + .object({ + days: z.number().int().min(1).max(365), + }) + .strict(); + +export const TrendsInsightsSchemas = { + name: "trends-insights", + versions: { "1": TrendsInsightsV1, "2": TrendsInsightsV2 }, + defaultVersion: "1" as const, + deprecated: [ + { + version: "1", + sunset: "2026-11-30", + migrationUrl: + "https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#trends-insights", + }, + ], +}; From efc1490e2b984536a4d754e27b680ee4cd109bec Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:28:00 -0300 Subject: [PATCH 14/24] =?UTF-8?q?feat(contracts):=20handler=20P0=20?= =?UTF-8?q?=E2=80=94=20send-transactional-email=20migrado=20para=20parseCo?= =?UTF-8?q?ntract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../send-transactional-email/index.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/supabase/functions/send-transactional-email/index.ts b/supabase/functions/send-transactional-email/index.ts index b2fbebe5c..7b4211988 100644 --- a/supabase/functions/send-transactional-email/index.ts +++ b/supabase/functions/send-transactional-email/index.ts @@ -6,6 +6,10 @@ import { getCorsHeaders } from "../_shared/cors.ts"; */ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + SendTransactionalEmailSchemas, +} from "../_shared/contracts/schemas/send-transactional-email.ts"; interface EmailRequest { event_type: "quote_sent" | "quote_approved" | "quote_rejected" | "order_created"; @@ -155,14 +159,11 @@ serve(async (req) => { }); } - const body: EmailRequest = await req.json(); - - if (!body.event_type || !body.recipient_email) { - return new Response(JSON.stringify({ error: "Missing event_type or recipient_email" }), { - status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } + const contractResult = await parseContract(req, SendTransactionalEmailSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const { data: body, responseHeaders } = contractResult; const { subject, html } = buildEmailContent(body); @@ -188,7 +189,7 @@ serve(async (req) => { message: "Email queued successfully", preview: { subject, recipient: body.recipient_email, event_type: body.event_type }, }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } + { headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" } } ); } catch (error) { const message = error instanceof Error ? error.message : String(error ?? "Internal error"); From 4e9d531dd646948c9d85c79ebb74d0cc70946824 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:29:17 -0300 Subject: [PATCH 15/24] =?UTF-8?q?feat(contracts):=20handlers=20P0=20?= =?UTF-8?q?=E2=80=94=20kit-ai-builder,=20bi-copilot=20migrados=20para=20pa?= =?UTF-8?q?rseContract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/bi-copilot/index.ts | 18 ++++++++++-------- supabase/functions/kit-ai-builder/index.ts | 20 +++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/supabase/functions/bi-copilot/index.ts b/supabase/functions/bi-copilot/index.ts index 78310ed2e..36365bc47 100644 --- a/supabase/functions/bi-copilot/index.ts +++ b/supabase/functions/bi-copilot/index.ts @@ -1,5 +1,9 @@ import { getCorsHeaders } from "../_shared/cors.ts"; import { authenticateRequest, requireRole, authErrorResponse } from "../_shared/auth.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + BiCopilotSchemas, +} from "../_shared/contracts/schemas/bi-copilot.ts"; /** * Edge function `bi-copilot` — responde perguntas do vendedor sobre um cliente * com base no contexto BI (score, sazonalidade, afinidade, tendências, benchmarks). @@ -40,13 +44,11 @@ Deno.serve(async (req) => { ); } - const body = (await req.json()) as RequestBody; - if (!body.question || body.question.length > 500) { - return new Response(JSON.stringify({ error: "Pergunta inválida." }), { - status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } + const contractResult = await parseContract(req, BiCopilotSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const { data: body, responseHeaders } = contractResult; const systemPrompt = `Você é um copiloto de Business Intelligence comercial para vendedores B2B de brindes corporativos. Você analisa dados reais de UM cliente específico e responde perguntas estratégicas com clareza, brevidade e ação. @@ -108,7 +110,7 @@ ${JSON.stringify(body.context, null, 2)}`; return new Response(JSON.stringify({ answer }), { status: 200, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } catch (e) { console.error("bi-copilot error:", e); diff --git a/supabase/functions/kit-ai-builder/index.ts b/supabase/functions/kit-ai-builder/index.ts index 189c49039..2d12924c2 100644 --- a/supabase/functions/kit-ai-builder/index.ts +++ b/supabase/functions/kit-ai-builder/index.ts @@ -1,5 +1,9 @@ import { getCorsHeaders } from "../_shared/cors.ts"; import { authenticateRequest, requireRole, authErrorResponse } from "../_shared/auth.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + KitAiBuilderSchemas, +} from "../_shared/contracts/schemas/kit-ai-builder.ts"; // ============================================================ // EDGE FUNCTION: kit-ai-builder // Recebe um prompt natural e devolve uma sugestão estruturada de kit @@ -27,14 +31,12 @@ Deno.serve(async (req: Request) => { try { - const body = (await req.json().catch(() => ({}))) as RequestBody; - const prompt = (body.prompt ?? '').trim(); - if (!prompt || prompt.length < 6 || prompt.length > 2000) { - return new Response( - JSON.stringify({ error: 'prompt inválido (6–2000 chars)' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } + const contractResult = await parseContract(req, KitAiBuilderSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const { data: body, responseHeaders } = contractResult; + const prompt = body.prompt.trim(); const LOVABLE_API_KEY = Deno.env.get('LOVABLE_API_KEY'); if (!LOVABLE_API_KEY) { @@ -131,7 +133,7 @@ Use português do Brasil. Seja conciso e prático.`; return new Response( JSON.stringify({ suggestion }), - { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + { status: 200, headers: { ...corsHeaders, ...responseHeaders, 'Content-Type': 'application/json' } } ); } catch (e) { console.error('kit-ai-builder error:', e); From a64b63167404e76774b25ebaf9224cd97dd3c863 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:31:11 -0300 Subject: [PATCH 16/24] =?UTF-8?q?feat(contracts):=20handler=20P0=20?= =?UTF-8?q?=E2=80=94=20market-intelligence-insights=20migrado=20para=20par?= =?UTF-8?q?seContract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../market-intelligence-insights/index.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/supabase/functions/market-intelligence-insights/index.ts b/supabase/functions/market-intelligence-insights/index.ts index 517ce1852..16ee69c72 100644 --- a/supabase/functions/market-intelligence-insights/index.ts +++ b/supabase/functions/market-intelligence-insights/index.ts @@ -4,6 +4,10 @@ import { getCorsHeaders } from "../_shared/cors.ts"; // v2: server-side cache, structured logging, telemetry, quota check, smart empty state. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { authenticateRequest, authErrorResponse } from "../_shared/auth.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + MarketIntelligenceInsightsSchemas, +} from "../_shared/contracts/schemas/market-intelligence-insights.ts"; const FUNCTION_NAME = "market-intelligence-insights"; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h @@ -214,10 +218,16 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); let userId: string | null = null; + let responseHeaders: Record = {}; try { const auth = await authenticateRequest(req); userId = auth.userId; - const body = (await req.json().catch(() => ({}))) as RequestBody; + const contractResult = await parseContract(req, MarketIntelligenceInsightsSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const body = contractResult.data as RequestBody; + responseHeaders = contractResult.responseHeaders; const cacheKey = await buildCacheKey(body); // 1) Cache lookup (skip if forceRefresh) @@ -240,7 +250,7 @@ Deno.serve(async (req) => { }); return new Response( JSON.stringify({ ...(cached.payload as object), cached: true, generated_at: cached.created_at }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" } }, ); } } @@ -254,7 +264,7 @@ Deno.serve(async (req) => { error: "quota_exceeded", message: `Limite mensal de IA atingido (${quota.used}/${quota.limit}).`, }), - { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + { status: 429, headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" } }, ); } @@ -266,7 +276,7 @@ Deno.serve(async (req) => { const empty = buildEmptyState(summary); log("info", "empty_state", { user_id: userId, period_days: summary.period_days }); return new Response(JSON.stringify(empty), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } @@ -274,7 +284,7 @@ Deno.serve(async (req) => { if (!LOVABLE_API_KEY) { log("warn", "missing_api_key", { user_id: userId }); return new Response(JSON.stringify(buildFallback(summary)), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } @@ -321,21 +331,21 @@ Deno.serve(async (req) => { log("warn", "ai_rate_limited", { user_id: userId, ai_duration_ms: aiDuration }); return new Response(JSON.stringify({ error: "rate_limited", ...buildFallback(summary) }), { status: 429, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } if (aiResp.status === 402) { log("warn", "ai_no_credits", { user_id: userId }); return new Response(JSON.stringify({ error: "no_credits", ...buildFallback(summary) }), { status: 402, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } if (!aiResp.ok) { const txt = await aiResp.text(); log("error", "ai_error", { user_id: userId, status: aiResp.status, body: txt.slice(0, 300) }); return new Response(JSON.stringify(buildFallback(summary)), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } @@ -352,7 +362,7 @@ Deno.serve(async (req) => { if (!parsed || !parsed.summary) { log("warn", "ai_parse_failed", { user_id: userId }); return new Response(JSON.stringify(buildFallback(summary)), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } @@ -399,7 +409,7 @@ Deno.serve(async (req) => { }); return new Response(JSON.stringify({ ...parsed, cached: false, generated_at: new Date().toISOString() }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } catch (e: any) { if (e?.status) return authErrorResponse(e, getCorsHeaders(req)); From d35b8f960dff7f410dd5cc5acb94e54d7896ec44 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:32:12 -0300 Subject: [PATCH 17/24] =?UTF-8?q?feat(contracts):=20handler=20P0=20?= =?UTF-8?q?=E2=80=94=20step-up-verify=20migrado=20para=20parseContract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/step-up-verify/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/supabase/functions/step-up-verify/index.ts b/supabase/functions/step-up-verify/index.ts index b98e25ae3..34b546a77 100644 --- a/supabase/functions/step-up-verify/index.ts +++ b/supabase/functions/step-up-verify/index.ts @@ -5,9 +5,15 @@ import { getCorsHeaders } from "../_shared/cors.ts"; // target_ref e action_label para rastreabilidade humana. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + StepUpVerifySchemas, +} from "../_shared/contracts/schemas/step-up-verify.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; +// Module-scope contract response headers (Deprecation/Sunset) — setado após parseContract OK. +let contractResponseHeaders: Record = {}; type RpcEnvelope = { data: T | null; error: { message: string } | null }; type StepUpChallengeRow = { challenge_id: string; otp_plain: string; expires_at: string }; @@ -39,10 +45,10 @@ interface RequestBody { cancel_reason?: string; } -function json(data: unknown, status = 200) { +function json(data: unknown, status = 200, extraHeaders: Record = {}) { return new Response(JSON.stringify(data), { status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...contractResponseHeaders, ...extraHeaders, "Content-Type": "application/json" }, }); } @@ -56,6 +62,7 @@ function safeLabel(label: string | undefined | null, max = 200): string | null { Deno.serve(async (req) => { corsHeaders = getCorsHeaders(req); + contractResponseHeaders = {}; if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null; @@ -116,7 +123,12 @@ Deno.serve(async (req) => { return json({ error: "unauthorized" }, 401); } - const body = (await req.json()) as RequestBody; + const contractResult = await parseContract(req, StepUpVerifySchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const body = contractResult.data as RequestBody; + contractResponseHeaders = contractResult.responseHeaders; const action = body.action ?? null; const targetRef = body.target_ref ?? null; const actionLabel = safeLabel(body.action_label); From dd9b5eac374b8d3dd6cdddd41a3c597ac1280936 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:34:17 -0300 Subject: [PATCH 18/24] =?UTF-8?q?feat(contracts):=20handlers=20P1=20(1/2)?= =?UTF-8?q?=20=E2=80=94=20ownership-audit,=20ownership-repair,=20sync-exte?= =?UTF-8?q?rnal-db,=20trends-insights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/ownership-audit/index.ts | 21 ++++++++++------ supabase/functions/ownership-repair/index.ts | 16 +++++++++--- supabase/functions/sync-external-db/index.ts | 26 +++++++++++--------- supabase/functions/trends-insights/index.ts | 22 +++++++++++------ 4 files changed, 56 insertions(+), 29 deletions(-) diff --git a/supabase/functions/ownership-audit/index.ts b/supabase/functions/ownership-audit/index.ts index a789f5e96..ae7f9657b 100644 --- a/supabase/functions/ownership-audit/index.ts +++ b/supabase/functions/ownership-audit/index.ts @@ -10,12 +10,18 @@ import { authorizeCron } from "../_shared/dispatcher-auth.ts"; */ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + OwnershipAuditSchemas, +} from "../_shared/contracts/schemas/ownership-audit.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; +let contractResponseHeaders: Record = {}; Deno.serve(async (req) => { corsHeaders = getCorsHeaders(req); + contractResponseHeaders = {}; if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas @@ -33,13 +39,12 @@ Deno.serve(async (req) => { return json({ error: "missing_env" }, 500); } - let triggeredBy = "cron"; - try { - const body = await req.json(); - if (body && typeof body.triggered_by === "string") triggeredBy = body.triggered_by; - } catch { - // sem body — ok, usa default "cron" - } + const contractResult = await parseContract(req, OwnershipAuditSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + contractResponseHeaders = contractResult.responseHeaders; + const triggeredBy = contractResult.data.triggered_by ?? "cron"; const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, @@ -99,6 +104,6 @@ Deno.serve(async (req) => { function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...contractResponseHeaders, "Content-Type": "application/json" }, }); } diff --git a/supabase/functions/ownership-repair/index.ts b/supabase/functions/ownership-repair/index.ts index 3a2603f41..b792b38ee 100644 --- a/supabase/functions/ownership-repair/index.ts +++ b/supabase/functions/ownership-repair/index.ts @@ -10,9 +10,14 @@ import { getCorsHeaders } from "../_shared/cors.ts"; */ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + OwnershipRepairSchemas, +} from "../_shared/contracts/schemas/ownership-repair.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; +let contractResponseHeaders: Record = {}; type RepairOrphansResult = { report_id?: string; @@ -21,6 +26,7 @@ type RepairOrphansResult = { Deno.serve(async (req) => { corsHeaders = getCorsHeaders(req); + contractResponseHeaders = {}; if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); try { @@ -39,8 +45,12 @@ Deno.serve(async (req) => { const { data: userData, error: uErr } = await userClient.auth.getUser(); if (uErr || !userData.user) return json({ error: "unauthorized" }, 401); - let body: { report_id?: string; dry_run?: boolean; triggered_by?: string } = {}; - try { body = await req.json(); } catch { /* sem body */ } + const contractResult = await parseContract(req, OwnershipRepairSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const body = contractResult.data; + contractResponseHeaders = contractResult.responseHeaders; const dryRun = body.dry_run !== false; // default true const triggeredBy = (body.triggered_by ?? "manual_admin").slice(0, 64); @@ -84,6 +94,6 @@ Deno.serve(async (req) => { function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...contractResponseHeaders, "Content-Type": "application/json" }, }); } diff --git a/supabase/functions/sync-external-db/index.ts b/supabase/functions/sync-external-db/index.ts index 47652293f..04b3037de 100644 --- a/supabase/functions/sync-external-db/index.ts +++ b/supabase/functions/sync-external-db/index.ts @@ -1,5 +1,9 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + SyncExternalDbSchemas, +} from "../_shared/contracts/schemas/sync-external-db.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -12,14 +16,14 @@ serve(async (req) => { } try { - const { table, direction = "to-external", since } = await req.json(); - - if (!table) { - return new Response(JSON.stringify({ error: "Table name is required" }), { - status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } + const contractResult = await parseContract(req, SyncExternalDbSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const { data: body, responseHeaders } = contractResult; + const { table } = body; + const direction = body.direction ?? "to-external"; + const since = body.since; // 1. Conexão com Supabase Interno (Lovable) const internalUrl = Deno.env.get("SUPABASE_URL")!; @@ -33,7 +37,7 @@ serve(async (req) => { if (!externalUrl || !externalKey) { return new Response(JSON.stringify({ error: "External Supabase credentials not configured" }), { status: 500, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } @@ -58,7 +62,7 @@ serve(async (req) => { if (!sourceData || sourceData.length === 0) { return new Response(JSON.stringify({ message: "No data to sync", count: 0 }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } @@ -75,7 +79,7 @@ serve(async (req) => { count: sourceData.length, last_updated: sourceData.length > 0 ? sourceData.reduce((max, r) => (r.updated_at > max ? r.updated_at : max), sourceData[0].updated_at) : null }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } catch (error) { diff --git a/supabase/functions/trends-insights/index.ts b/supabase/functions/trends-insights/index.ts index cb020fb49..ea786803b 100644 --- a/supabase/functions/trends-insights/index.ts +++ b/supabase/functions/trends-insights/index.ts @@ -2,6 +2,10 @@ import { getCorsHeaders } from "../_shared/cors.ts"; // Edge function: trends-insights // Agrega métricas de Tendências e gera narrativa via Lovable AI Gateway. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + TrendsInsightsSchemas, +} from "../_shared/contracts/schemas/trends-insights.ts"; interface Body { days?: number; @@ -45,8 +49,12 @@ Deno.serve(async (req) => { }); } - const body = (await req.json().catch(() => ({}))) as Body; - const days = Math.max(1, Math.min(body.days ?? 30, 365)); + const contractResult = await parseContract(req, TrendsInsightsSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const { data: body, responseHeaders } = contractResult; + const days = body.days ?? 30; const sinceCurrent = new Date(Date.now() - days * 86400000).toISOString(); const sincePrevious = new Date(Date.now() - days * 2 * 86400000).toISOString(); @@ -160,18 +168,18 @@ Retorne via tool call.`; if (!aiResp.ok) { if (aiResp.status === 429) { return new Response(JSON.stringify({ error: "Rate limit exceeded" }), { - status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 429, headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } if (aiResp.status === 402) { return new Response(JSON.stringify({ error: "Payment required" }), { - status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 402, headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } const errText = await aiResp.text(); console.error("AI error:", aiResp.status, errText); return new Response(JSON.stringify({ error: "AI gateway error" }), { - status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 500, headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } @@ -183,12 +191,12 @@ Retorne via tool call.`; what_changed: "Aguardando mais atividade no catálogo.", why: "Volume baixo de eventos no período.", next_action: "Continue acompanhando — em breve haverá padrões claros.", - }), { headers: { ...corsHeaders, "Content-Type": "application/json" } }); + }), { headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" } }); } const parsed = JSON.parse(toolCall.function.arguments); return new Response(JSON.stringify(parsed), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, }); } catch (e) { console.error("trends-insights error:", e); From 9f3c139a7a9d6eb685d9e8bc17c199bfc892372b Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:35:01 -0300 Subject: [PATCH 19/24] =?UTF-8?q?feat(contracts):=20handler=20P1=20(2/2)?= =?UTF-8?q?=20=E2=80=94=20simulation-orchestrator=20migrado=20para=20parse?= =?UTF-8?q?Contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../simulation-orchestrator/index.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/supabase/functions/simulation-orchestrator/index.ts b/supabase/functions/simulation-orchestrator/index.ts index 7e5f16cac..2ecd1ef2b 100644 --- a/supabase/functions/simulation-orchestrator/index.ts +++ b/supabase/functions/simulation-orchestrator/index.ts @@ -1,6 +1,10 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + SimulationOrchestratorSchemas, +} from "../_shared/contracts/schemas/simulation-orchestrator.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -46,11 +50,14 @@ serve(async (req) => { const startTime = performance.now(); try { - const { - count = 100, - targetFunctions = ["external-db-bridge", "webhook-inbound", "product-webhook"], - mode = "resilience" // "resilience", "load", "fuzzing" - } = await req.json(); + const contractResult = await parseContract(req, SimulationOrchestratorSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const { data: parsedBody, responseHeaders } = contractResult; + const count = parsedBody.count ?? 100; + const targetFunctions = parsedBody.targetFunctions ?? ["external-db-bridge", "webhook-inbound", "product-webhook"]; + const mode = parsedBody.mode ?? "resilience"; const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; @@ -214,7 +221,7 @@ serve(async (req) => { } return new Response(JSON.stringify(report), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" }, status: 200, }); } catch (error) { From 623cb4105534e232e06ab9a8d6054d293230be90 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:35:56 -0300 Subject: [PATCH 20/24] =?UTF-8?q?feat(contracts):=20handlers=20P2=20(1/2)?= =?UTF-8?q?=20=E2=80=94=20force-global-logout,=20block-ip-temporarily?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../functions/block-ip-temporarily/index.ts | 26 ++++++++++++------- .../functions/force-global-logout/index.ts | 26 ++++++++++--------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/supabase/functions/block-ip-temporarily/index.ts b/supabase/functions/block-ip-temporarily/index.ts index f8c8a513d..a5050bd95 100644 --- a/supabase/functions/block-ip-temporarily/index.ts +++ b/supabase/functions/block-ip-temporarily/index.ts @@ -1,13 +1,18 @@ import { getCorsHeaders } from "../_shared/cors.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + BlockIpTemporarilySchemas, +} from "../_shared/contracts/schemas/block-ip-temporarily.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; +let contractResponseHeaders: Record = {}; function jsonRes(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...contractResponseHeaders, "Content-Type": "application/json" }, }); } @@ -20,6 +25,7 @@ Deno.serve(async (req) => { } corsHeaders = getCorsHeaders(req); + contractResponseHeaders = {}; try { const supabaseUrl = Deno.env.get("SUPABASE_URL")!; @@ -46,16 +52,16 @@ Deno.serve(async (req) => { return jsonRes({ error: "Apenas admins podem bloquear IPs" }, 403); } - let body: { ip?: string; reason?: string; hours?: number } = {}; - try { body = await req.json(); } catch { return jsonRes({ error: "Body inválido" }, 400); } - - const ip = (body.ip || "").trim(); - if (!ip || !IP_REGEX.test(ip) || ip.length > 45) { - return jsonRes({ error: "IP inválido (use IPv4, IPv6 ou CIDR)" }, 400); - } + const contractResult = await parseContract(req, BlockIpTemporarilySchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + const body = contractResult.data; + contractResponseHeaders = contractResult.responseHeaders; - const hours = Math.max(1, Math.min(720, Number(body.hours) || 24)); - const reason = (body.reason || "Bloqueio temporário via Security Center").slice(0, 500); + const ip = body.ip.trim(); + const hours = body.hours ?? 24; + const reason = (body.reason ?? "Bloqueio temporário via Security Center").slice(0, 500); const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString(); const { error: insertErr } = await supabaseAdmin.from("ip_access_control").insert({ diff --git a/supabase/functions/force-global-logout/index.ts b/supabase/functions/force-global-logout/index.ts index 276027b6f..8aa364a56 100644 --- a/supabase/functions/force-global-logout/index.ts +++ b/supabase/functions/force-global-logout/index.ts @@ -1,13 +1,18 @@ import { getCorsHeaders } from "../_shared/cors.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + ForceGlobalLogoutSchemas, +} from "../_shared/contracts/schemas/force-global-logout.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; +let contractResponseHeaders: Record = {}; function jsonRes(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + headers: { ...corsHeaders, ...contractResponseHeaders, "Content-Type": "application/json" }, }); } @@ -17,6 +22,7 @@ Deno.serve(async (req) => { } corsHeaders = getCorsHeaders(req); + contractResponseHeaders = {}; try { const supabaseUrl = Deno.env.get("SUPABASE_URL")!; @@ -49,17 +55,13 @@ Deno.serve(async (req) => { return jsonRes({ error: "Apenas administradores ou desenvolvedores podem forçar logout global" }, 403); } - // Parse confirmation - let body: { confirm?: string } = {}; - try { - body = await req.json(); - } catch { - return jsonRes({ error: "Body inválido" }, 400); - } - - if (body.confirm !== "FORCE_LOGOUT_ALL") { - return jsonRes({ error: 'Confirmação inválida. Envie {"confirm": "FORCE_LOGOUT_ALL"}' }, 400); - } + // Parse + validate body via parseContract + const contractResult = await parseContract(req, ForceGlobalLogoutSchemas, { + corsHeaders, + }); + if (!contractResult.ok) return contractResult.response; + contractResponseHeaders = contractResult.responseHeaders; + // confirm já validado pelo schema (literal "FORCE_LOGOUT_ALL") // List all users and sign them out (exclude caller to keep current admin session) let totalSignedOut = 0; From e19a02a80cf5be0ea0b18daa2c73023c56493096 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:38:33 -0300 Subject: [PATCH 21/24] =?UTF-8?q?feat(contracts):=20handler=20P2=20(2/2)?= =?UTF-8?q?=20=E2=80=94=20e2e-cleanup=20migrado=20para=20parseContract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/e2e-cleanup/index.ts | 42 ++++++++++++++----------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/supabase/functions/e2e-cleanup/index.ts b/supabase/functions/e2e-cleanup/index.ts index dff62707d..f173f6a34 100644 --- a/supabase/functions/e2e-cleanup/index.ts +++ b/supabase/functions/e2e-cleanup/index.ts @@ -27,6 +27,10 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; +import { parseContract } from "../_shared/contracts/index.ts"; +import { + E2eCleanupSchemas, +} from "../_shared/contracts/schemas/e2e-cleanup.ts"; type E2ERateLimitRow = { allowed: boolean; @@ -35,11 +39,12 @@ type E2ERateLimitRow = { }; const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-e2e-cleanup-token"], allowMethods: "POST, OPTIONS" }); +let contractResponseHeaders: Record = {}; function jsonResponse(body: unknown, status = 200, extraHeaders: Record = {}) { return new Response(JSON.stringify(body), { status, - headers: { ...corsHeaders, "Content-Type": "application/json", ...extraHeaders }, + headers: { ...corsHeaders, ...contractResponseHeaders, "Content-Type": "application/json", ...extraHeaders }, }); } @@ -159,6 +164,9 @@ Deno.serve(async (req: Request) => { return jsonResponse({ error: "method_not_allowed" }, 405); } + // Reset module-scope response headers para esta request + contractResponseHeaders = {}; + const startedAt = Date.now(); const ip = clientIp(req); const userAgent = req.headers.get("user-agent"); @@ -232,23 +240,19 @@ Deno.serve(async (req: Request) => { return jsonResponse({ error: "invalid_cleanup_token" }, 401); } - // --- parse body --------------------------------------------------------- - let body: { - email?: unknown; - dryRun?: unknown; - sellerScope?: unknown; - sellerId?: unknown; - nameFilterPrefix?: unknown; - }; - try { - body = await req.json(); - } catch { + // --- parse + validate body via parseContract ---------------------------- + const contractResult = await parseContract(req, E2eCleanupSchemas, { + corsHeaders, + }); + if (!contractResult.ok) { + // Preserva audit log de tentativa inválida (mantém rastreabilidade + // do comportamento atual que loga `invalid_json` quando body falha). await writeAudit(admin, { email: "", user_id: null, dry_run: true, status: "invalid", - reason: "invalid_json", + reason: contractResult.response.status === 400 ? "invalid_json" : "invalid_payload", ip, user_agent: userAgent, total_deleted: 0, @@ -256,9 +260,11 @@ Deno.serve(async (req: Request) => { errors: {}, duration_ms: Date.now() - startedAt, }); - return jsonResponse({ error: "invalid_json" }, 400); + return contractResult.response; } - const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + const body = contractResult.data; + contractResponseHeaders = contractResult.responseHeaders; + const email = (body.email ?? "").trim().toLowerCase(); const dryRun = body.dryRun === false ? false : true; // sellerScope: "self" (default) usa o user_id resolvido como seller_id. @@ -269,9 +275,7 @@ Deno.serve(async (req: Request) => { const sellerScope: "self" | "explicit" = body.sellerScope === "explicit" ? "explicit" : "self"; const requestedSellerId = - typeof body.sellerId === "string" && body.sellerId.length > 0 - ? body.sellerId - : null; + body.sellerId && body.sellerId.length > 0 ? body.sellerId : null; // nameFilterPrefix: quando presente, restringe DELETEs a recursos cujo // nome começa com o prefixo (ex.: "[E2E]"). Garante isolamento contra @@ -280,7 +284,7 @@ Deno.serve(async (req: Request) => { // escopadas apenas por user_id/seller_id (são internas/órfãs por // construção). const nameFilterPrefix = - typeof body.nameFilterPrefix === "string" && body.nameFilterPrefix.length > 0 + body.nameFilterPrefix && body.nameFilterPrefix.length > 0 ? body.nameFilterPrefix : null; // Sanitiza % e _ (LIKE wildcards) para evitar match acidental amplo. From 3aa25f1f3637f252c851d08ac73ceb6df8a6661c Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 22:41:23 -0300 Subject: [PATCH 22/24] test(contracts): adiciona 49 testes de contrato para os 13 endpoints migrados --- .../migrated-endpoints.contract.test.ts | 252 ++++++++++++++++++ .../send-transactional-email.contract.test.ts | 127 +++++++++ .../contracts/step-up-verify.contract.test.ts | 90 +++++++ 3 files changed, 469 insertions(+) create mode 100644 tests/contracts/migrated-endpoints.contract.test.ts create mode 100644 tests/contracts/send-transactional-email.contract.test.ts create mode 100644 tests/contracts/step-up-verify.contract.test.ts diff --git a/tests/contracts/migrated-endpoints.contract.test.ts b/tests/contracts/migrated-endpoints.contract.test.ts new file mode 100644 index 000000000..116f7c219 --- /dev/null +++ b/tests/contracts/migrated-endpoints.contract.test.ts @@ -0,0 +1,252 @@ +/** + * Suite consolidada de contract tests para os endpoints migrados em #46/47/48. + * + * `send-transactional-email` e `step-up-verify` têm arquivos próprios (testes + * mais detalhados); este arquivo cobre o smoke-test dos outros 11. + */ +import { describe, it, expect } from 'vitest'; +import { parseContract } from '../../supabase/functions/_shared/contracts/parse'; +import { KitAiBuilderSchemas } from '../../supabase/functions/_shared/contracts/schemas/kit-ai-builder'; +import { BiCopilotSchemas } from '../../supabase/functions/_shared/contracts/schemas/bi-copilot'; +import { MarketIntelligenceInsightsSchemas } from '../../supabase/functions/_shared/contracts/schemas/market-intelligence-insights'; +import { OwnershipAuditSchemas } from '../../supabase/functions/_shared/contracts/schemas/ownership-audit'; +import { OwnershipRepairSchemas } from '../../supabase/functions/_shared/contracts/schemas/ownership-repair'; +import { SimulationOrchestratorSchemas } from '../../supabase/functions/_shared/contracts/schemas/simulation-orchestrator'; +import { SyncExternalDbSchemas } from '../../supabase/functions/_shared/contracts/schemas/sync-external-db'; +import { TrendsInsightsSchemas } from '../../supabase/functions/_shared/contracts/schemas/trends-insights'; +import { ForceGlobalLogoutSchemas } from '../../supabase/functions/_shared/contracts/schemas/force-global-logout'; +import { E2eCleanupSchemas } from '../../supabase/functions/_shared/contracts/schemas/e2e-cleanup'; +import { BlockIpTemporarilySchemas } from '../../supabase/functions/_shared/contracts/schemas/block-ip-temporarily'; +import { makeRequest, expectContractError } from './_helpers'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +describe('contract: kit-ai-builder', () => { + it('v1 aceita prompt 6-2000 chars', async () => { + const r = await parseContract(makeRequest({ body: { prompt: 'x'.repeat(50) } }), KitAiBuilderSchemas); + expect(r.ok).toBe(true); + }); + it('v1 rejeita prompt < 6 → 422', async () => { + const r = await parseContract(makeRequest({ body: { prompt: 'abc' } }), KitAiBuilderSchemas); + expect(r.ok).toBe(false); + if (!r.ok) await expectContractError(r.response, { status: 422, code: 'validation_failed' }); + }); + it('v2 exige idempotency_key', async () => { + const r = await parseContract( + makeRequest({ headers: { 'accept-version': '2' }, body: { prompt: 'x'.repeat(50) } }), + KitAiBuilderSchemas, + ); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: bi-copilot', () => { + it('v1 aceita question simples', async () => { + const r = await parseContract(makeRequest({ body: { question: 'Qual ticket médio?' } }), BiCopilotSchemas); + expect(r.ok).toBe(true); + }); + it('v1 aceita context+history opcionais', async () => { + const r = await parseContract( + makeRequest({ + body: { + question: 'Como melhorar?', + context: { client_id: 'X' }, + history: [{ role: 'user', content: 'olá' }], + }, + }), + BiCopilotSchemas, + ); + expect(r.ok).toBe(true); + }); + it('v1 rejeita question vazia → 422', async () => { + const r = await parseContract(makeRequest({ body: { question: '' } }), BiCopilotSchemas); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: market-intelligence-insights', () => { + it('v1 aceita body vazio (defaults)', async () => { + const r = await parseContract(makeRequest({ body: {} }), MarketIntelligenceInsightsSchemas); + expect(r.ok).toBe(true); + }); + it('v1 valida days range 1-365', async () => { + const r = await parseContract(makeRequest({ body: { days: 400 } }), MarketIntelligenceInsightsSchemas); + expect(r.ok).toBe(false); + }); + it('v2 exige UUIDs em filtros', async () => { + const r = await parseContract( + makeRequest({ headers: { 'accept-version': '2' }, body: { categoryId: 'not-uuid' } }), + MarketIntelligenceInsightsSchemas, + ); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: ownership-audit', () => { + it('v1 aceita body vazio (default cron no handler)', async () => { + const r = await parseContract(makeRequest({ body: {} }), OwnershipAuditSchemas); + expect(r.ok).toBe(true); + }); + it('v2 exige triggered_by', async () => { + const r = await parseContract( + makeRequest({ headers: { 'accept-version': '2' }, body: {} }), + OwnershipAuditSchemas, + ); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: ownership-repair', () => { + it('v1 aceita body vazio', async () => { + const r = await parseContract(makeRequest({ body: {} }), OwnershipRepairSchemas); + expect(r.ok).toBe(true); + }); + it('v2 exige dry_run+triggered_by+idempotency_key', async () => { + const r = await parseContract( + makeRequest({ + headers: { 'accept-version': '2' }, + body: { dry_run: true, triggered_by: 'manual', idempotency_key: UUID }, + }), + OwnershipRepairSchemas, + ); + expect(r.ok).toBe(true); + }); +}); + +describe('contract: simulation-orchestrator', () => { + it('v1 aceita body vazio (defaults)', async () => { + const r = await parseContract(makeRequest({ body: {} }), SimulationOrchestratorSchemas); + expect(r.ok).toBe(true); + }); + it('v2 exige campos completos', async () => { + const r = await parseContract( + makeRequest({ + headers: { 'accept-version': '2' }, + body: { targetFunctions: ['webhook-inbound'], mode: 'resilience', idempotency_key: UUID }, + }), + SimulationOrchestratorSchemas, + ); + expect(r.ok).toBe(true); + }); +}); + +describe('contract: sync-external-db', () => { + it('v1 exige table → 422 sem table', async () => { + const r = await parseContract(makeRequest({ body: {} }), SyncExternalDbSchemas); + expect(r.ok).toBe(false); + if (!r.ok) await expectContractError(r.response, { status: 422, code: 'validation_failed' }); + }); + it('v1 aceita table simples', async () => { + const r = await parseContract(makeRequest({ body: { table: 'products' } }), SyncExternalDbSchemas); + expect(r.ok).toBe(true); + }); + it('v2 valida since como ISO 8601', async () => { + const r = await parseContract( + makeRequest({ + headers: { 'accept-version': '2' }, + body: { table: 'products', direction: 'to-external', since: 'not-iso' }, + }), + SyncExternalDbSchemas, + ); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: trends-insights', () => { + it('v1 aceita body vazio', async () => { + const r = await parseContract(makeRequest({ body: {} }), TrendsInsightsSchemas); + expect(r.ok).toBe(true); + }); + it('v1 valida days range 1-365', async () => { + const r = await parseContract(makeRequest({ body: { days: 400 } }), TrendsInsightsSchemas); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: force-global-logout', () => { + it('v1 exige confirm literal', async () => { + const r = await parseContract(makeRequest({ body: { confirm: 'wrong' } }), ForceGlobalLogoutSchemas); + expect(r.ok).toBe(false); + }); + it('v1 aceita literal correto', async () => { + const r = await parseContract(makeRequest({ body: { confirm: 'FORCE_LOGOUT_ALL' } }), ForceGlobalLogoutSchemas); + expect(r.ok).toBe(true); + }); + it('v2 exige idempotency_key', async () => { + const r = await parseContract( + makeRequest({ headers: { 'accept-version': '2' }, body: { confirm: 'FORCE_LOGOUT_ALL' } }), + ForceGlobalLogoutSchemas, + ); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: e2e-cleanup', () => { + it('v1 aceita body vazio (defaults aplicados no handler)', async () => { + const r = await parseContract(makeRequest({ body: {} }), E2eCleanupSchemas); + expect(r.ok).toBe(true); + }); + it('v2 exige email + dryRun + confirm + idempotency_key', async () => { + const r = await parseContract( + makeRequest({ + headers: { 'accept-version': '2' }, + body: { + email: 'e2e@test.com', + dryRun: true, + confirm: true, + idempotency_key: UUID, + }, + }), + E2eCleanupSchemas, + ); + expect(r.ok).toBe(true); + }); + it('v2 com sellerScope=explicit exige sellerId', async () => { + const r = await parseContract( + makeRequest({ + headers: { 'accept-version': '2' }, + body: { + email: 'e2e@test.com', + dryRun: true, + sellerScope: 'explicit', + confirm: true, + idempotency_key: UUID, + }, + }), + E2eCleanupSchemas, + ); + expect(r.ok).toBe(false); + }); +}); + +describe('contract: block-ip-temporarily', () => { + it('v1 aceita IPv4', async () => { + const r = await parseContract(makeRequest({ body: { ip: '192.168.0.1' } }), BlockIpTemporarilySchemas); + expect(r.ok).toBe(true); + }); + it('v1 aceita CIDR', async () => { + const r = await parseContract(makeRequest({ body: { ip: '10.0.0.0/8' } }), BlockIpTemporarilySchemas); + expect(r.ok).toBe(true); + }); + it('v1 rejeita string lixo → 422', async () => { + const r = await parseContract(makeRequest({ body: { ip: 'not-an-ip-zzz' } }), BlockIpTemporarilySchemas); + expect(r.ok).toBe(false); + }); + it('v1 valida hours range', async () => { + const r = await parseContract( + makeRequest({ body: { ip: '1.2.3.4', hours: 721 } }), + BlockIpTemporarilySchemas, + ); + expect(r.ok).toBe(false); + }); + it('v2 strict — extras rejeitados', async () => { + const r = await parseContract( + makeRequest({ + headers: { 'accept-version': '2' }, + body: { ip: '1.2.3.4', reason: 'abuse', hours: 24, extra: true }, + }), + BlockIpTemporarilySchemas, + ); + expect(r.ok).toBe(false); + }); +}); diff --git a/tests/contracts/send-transactional-email.contract.test.ts b/tests/contracts/send-transactional-email.contract.test.ts new file mode 100644 index 000000000..bbdbf01c0 --- /dev/null +++ b/tests/contracts/send-transactional-email.contract.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { parseContract } from '../../supabase/functions/_shared/contracts/parse'; +import { SendTransactionalEmailSchemas } from '../../supabase/functions/_shared/contracts/schemas/send-transactional-email'; +import { makeRequest, expectContractError } from './_helpers'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +describe('contract: send-transactional-email v1 (compat)', () => { + it('aceita payload válido (default v1)', async () => { + const req = makeRequest({ + body: { + event_type: 'quote_sent', + recipient_email: 'ana@example.com', + data: { quote_number: 'Q-001' }, + }, + }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.version).toBe('1'); + expect(r.data.recipient_email).toBe('ana@example.com'); + expect(r.responseHeaders['Deprecation']).toBe('true'); + } + }); + + it('aceita recipient_name opcional', async () => { + const req = makeRequest({ + body: { + event_type: 'order_created', + recipient_email: 'cliente@example.com', + recipient_name: 'João', + data: {}, + }, + }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(true); + }); + + it('event_type fora do enum → 422', async () => { + const req = makeRequest({ + body: { + event_type: 'invalid_event', + recipient_email: 'a@b.com', + data: {}, + }, + }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { status: 422, code: 'validation_failed' }); + } + }); + + it('email inválido → 422', async () => { + const req = makeRequest({ + body: { event_type: 'quote_sent', recipient_email: 'not-an-email', data: {} }, + }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { status: 422, code: 'validation_failed' }); + } + }); + + it('body vazio → 400 missing_body', async () => { + const req = makeRequest({ body: '' }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { status: 400, code: 'missing_body' }); + } + }); + + it('JSON malformado → 400 invalid_json', async () => { + const req = makeRequest({ body: '{broken' }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { status: 400, code: 'invalid_json' }); + } + }); +}); + +describe('contract: send-transactional-email v2 (strict + idempotency)', () => { + it('aceita payload v2 completo via accept-version header', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { + event_type: 'quote_approved', + recipient_email: 'cliente@example.com', + data: { quote_number: 'Q-002' }, + idempotency_key: UUID, + }, + }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.version).toBe('2'); + // v2 não deprecated → sem header Deprecation + expect(r.responseHeaders['Deprecation']).toBeUndefined(); + } + }); + + it('v2 sem idempotency_key → 422', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { event_type: 'quote_sent', recipient_email: 'a@b.com', data: {} }, + }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { status: 422, code: 'validation_failed' }); + } + }); + + it('versão não suportada → 406 unsupported_version', async () => { + const req = makeRequest({ + headers: { 'accept-version': '99' }, + body: { event_type: 'quote_sent', recipient_email: 'a@b.com', data: {} }, + }); + const r = await parseContract(req, SendTransactionalEmailSchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { status: 406, code: 'unsupported_version' }); + } + }); +}); diff --git a/tests/contracts/step-up-verify.contract.test.ts b/tests/contracts/step-up-verify.contract.test.ts new file mode 100644 index 000000000..3d239d649 --- /dev/null +++ b/tests/contracts/step-up-verify.contract.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { parseContract } from '../../supabase/functions/_shared/contracts/parse'; +import { StepUpVerifySchemas } from '../../supabase/functions/_shared/contracts/schemas/step-up-verify'; +import { makeRequest, expectContractError } from './_helpers'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +describe('contract: step-up-verify v1 (compat permissiva)', () => { + it('aceita step=request com action', async () => { + const req = makeRequest({ + body: { step: 'request', action: 'promote_dev' }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(true); + }); + + it('aceita step=verify_password com challenge_id+password', async () => { + const req = makeRequest({ + body: { step: 'verify_password', challenge_id: 'abc', password: 'x' }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(true); + }); + + it('step desconhecido → 422', async () => { + const req = makeRequest({ body: { step: 'unknown' } }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(false); + if (!r.ok) { + await expectContractError(r.response, { status: 422, code: 'validation_failed' }); + } + }); +}); + +describe('contract: step-up-verify v2 (discriminated union strict)', () => { + it('request: exige action', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { step: 'request' }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(false); + }); + + it('request: aceita com action válido', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { step: 'request', action: 'secret_rotation' }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(true); + if (r.ok) expect(r.version).toBe('2'); + }); + + it('verify_password: exige challenge_id (UUID) + password', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { step: 'verify_password', challenge_id: 'not-a-uuid', password: 'x' }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(false); + }); + + it('verify_password: aceita challenge_id UUID válido', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { step: 'verify_password', challenge_id: UUID, password: 'senha-123' }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(true); + }); + + it('verify_otp: exige otp min 4 chars', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { step: 'verify_otp', challenge_id: UUID, otp: 'abc' }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(false); + }); + + it('cancel: aceita só com challenge_id', async () => { + const req = makeRequest({ + headers: { 'accept-version': '2' }, + body: { step: 'cancel', challenge_id: UUID }, + }); + const r = await parseContract(req, StepUpVerifySchemas); + expect(r.ok).toBe(true); + }); +}); From 56ea2f8034a3af52511d82ce1767d1894de68ce9 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 23:28:27 -0300 Subject: [PATCH 23/24] refactor(contracts): pin Zod via _zod.ts barrel (core do pacote) --- supabase/functions/_shared/contracts/_zod.ts | 12 ++++++++++++ supabase/functions/_shared/contracts/errors.ts | 2 +- supabase/functions/_shared/contracts/index.ts | 2 +- supabase/functions/_shared/contracts/parse.ts | 2 +- supabase/functions/_shared/zod-validate.ts | 4 ++-- 5 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 supabase/functions/_shared/contracts/_zod.ts 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/contracts/errors.ts b/supabase/functions/_shared/contracts/errors.ts index 76874bdb4..19bb2800e 100644 --- a/supabase/functions/_shared/contracts/errors.ts +++ b/supabase/functions/_shared/contracts/errors.ts @@ -23,7 +23,7 @@ * Toda Edge Function que aceita body externo DEVE responder erros nesse formato. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "./_zod.ts"; // --------------------------------------------------------------------------- // Tipos diff --git a/supabase/functions/_shared/contracts/index.ts b/supabase/functions/_shared/contracts/index.ts index 3e70cd7f8..5dc14b5f1 100644 --- a/supabase/functions/_shared/contracts/index.ts +++ b/supabase/functions/_shared/contracts/index.ts @@ -31,4 +31,4 @@ export { } from "./parse.ts"; // Re-export do Zod para padronizar o pinning em todo o repo -export { z } from "https://esm.sh/zod@3.23.8"; +export { z } from "./_zod.ts"; diff --git a/supabase/functions/_shared/contracts/parse.ts b/supabase/functions/_shared/contracts/parse.ts index 52bb40e1d..b45d94af0 100644 --- a/supabase/functions/_shared/contracts/parse.ts +++ b/supabase/functions/_shared/contracts/parse.ts @@ -22,7 +22,7 @@ * configurados no `VersionConfig`. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "./_zod.ts"; import { invalidJsonResponse, missingBodyResponse, 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. From bea6aae82b975fe6cd55d04e06ccaa8acf64059b Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Thu, 21 May 2026 23:29:53 -0300 Subject: [PATCH 24/24] refactor(contracts): schemas A-O importam Zod via ../_zod.ts --- supabase/functions/_shared/contracts/schemas/bi-copilot.ts | 2 +- .../functions/_shared/contracts/schemas/block-ip-temporarily.ts | 2 +- supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts | 2 +- .../functions/_shared/contracts/schemas/force-global-logout.ts | 2 +- supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts | 2 +- .../_shared/contracts/schemas/market-intelligence-insights.ts | 2 +- supabase/functions/_shared/contracts/schemas/ownership-audit.ts | 2 +- .../functions/_shared/contracts/schemas/ownership-repair.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/supabase/functions/_shared/contracts/schemas/bi-copilot.ts b/supabase/functions/_shared/contracts/schemas/bi-copilot.ts index 4b3588210..219379e00 100644 --- a/supabase/functions/_shared/contracts/schemas/bi-copilot.ts +++ b/supabase/functions/_shared/contracts/schemas/bi-copilot.ts @@ -5,7 +5,7 @@ * Sunset 2026-10-31. * v2: strict + context obrigatório. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; const ChatMessage = z.object({ role: z.enum(["user", "assistant"]), diff --git a/supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts b/supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts index 0d9ee9d36..12e3421ac 100644 --- a/supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts +++ b/supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts @@ -4,7 +4,7 @@ * v1: regex permissivo (mesmo do handler atual). Sunset 2026-12-31. * v2: regex IPv4/IPv6/CIDR rigoroso + strict. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; // V1 = mesmo regex permissivo do handler atual em produção (compat exato) const IP_REGEX_V1 = /^[0-9a-fA-F:.\/]{3,45}$/; diff --git a/supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts b/supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts index f4c9af3d5..9cd20483a 100644 --- a/supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts +++ b/supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts @@ -4,7 +4,7 @@ * v1: shape permissivo do handler. Sunset 2026-12-31. * v2: strict + email obrigatório + confirm:true + idempotency_key. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; const SellerScopeEnum = z.enum(["self", "explicit"]); diff --git a/supabase/functions/_shared/contracts/schemas/force-global-logout.ts b/supabase/functions/_shared/contracts/schemas/force-global-logout.ts index d9ad9da43..5e73f9392 100644 --- a/supabase/functions/_shared/contracts/schemas/force-global-logout.ts +++ b/supabase/functions/_shared/contracts/schemas/force-global-logout.ts @@ -4,7 +4,7 @@ * v1: confirm literal. Sunset 2026-12-31. * v2: confirm + idempotency_key (destrutivo). */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; export const ForceGlobalLogoutV1 = z.object({ confirm: z.literal("FORCE_LOGOUT_ALL"), diff --git a/supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts b/supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts index 13f5f46d9..3b87fadd5 100644 --- a/supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts +++ b/supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts @@ -4,7 +4,7 @@ * v1: prompt 6-2000 chars. Sunset 2026-10-31. * v2: strict + idempotency_key. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; export const KitAiBuilderV1 = z.object({ prompt: z diff --git a/supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts b/supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts index 6c989750c..e617fe334 100644 --- a/supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts +++ b/supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts @@ -4,7 +4,7 @@ * v1: todos campos opcionais (compat com body vazio). Sunset 2026-10-31. * v2: strict, UUIDs validados. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; export const MarketIntelligenceInsightsV1 = z.object({ days: z.number().int().min(1).max(365).optional(), diff --git a/supabase/functions/_shared/contracts/schemas/ownership-audit.ts b/supabase/functions/_shared/contracts/schemas/ownership-audit.ts index e2d859f1f..b1d9c3084 100644 --- a/supabase/functions/_shared/contracts/schemas/ownership-audit.ts +++ b/supabase/functions/_shared/contracts/schemas/ownership-audit.ts @@ -4,7 +4,7 @@ * v1: body opcional; default "cron" no handler. Sunset 2026-11-30. * v2: triggered_by obrigatório. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; export const OwnershipAuditV1 = z.object({ triggered_by: z.string().max(64).optional(), diff --git a/supabase/functions/_shared/contracts/schemas/ownership-repair.ts b/supabase/functions/_shared/contracts/schemas/ownership-repair.ts index 3f3ec5eea..4b027ec9e 100644 --- a/supabase/functions/_shared/contracts/schemas/ownership-repair.ts +++ b/supabase/functions/_shared/contracts/schemas/ownership-repair.ts @@ -4,7 +4,7 @@ * v1: todos opcionais. Sunset 2026-11-30. * v2: dry_run obrigatório + idempotency_key. */ -import { z } from "https://esm.sh/zod@3.23.8"; +import { z } from "../_zod.ts"; export const OwnershipRepairV1 = z.object({ report_id: z.string().max(100).optional(),