Skip to content

feat(contracts): contracts/02+03+04 — migrate 13 edge functions to parseContract#69

Merged
adm01-debug merged 13 commits into
feat/contracts-validation-zod-422-versioningfrom
feat/contracts-02-03-04-migrate-13-functions
May 22, 2026
Merged

feat(contracts): contracts/02+03+04 — migrate 13 edge functions to parseContract#69
adm01-debug merged 13 commits into
feat/contracts-validation-zod-422-versioningfrom
feat/contracts-02-03-04-migrate-13-functions

Conversation

@adm01-debug
Copy link
Copy Markdown
Owner

@adm01-debug adm01-debug commented May 22, 2026

Resumo

Migra 13 edge functions para o pacote _shared/contracts entregue em #45.

Cada função ganha:

  • schema Zod v1 (compat: shape idêntico ao body atual em produção)
  • schema Zod v2 (.strict() + idempotency_key onde houver side effects)
  • parseContract(req, XSchemas, { corsHeaders }) substitui req.json() manual
  • propagação de responseHeaders (Deprecation/Sunset/X-Contract-Version)
  • testes de contrato com casos críticos (happy path, 422, 400, 406)

Endpoints migrados

P0 — sunset v1: 2026-10-31 (#46)

Endpoint Justificativa
send-transactional-email E-mail real → side effect externo
kit-ai-builder Chamada OpenAI custosa
bi-copilot Chamada OpenAI custosa
market-intelligence-insights Chamada OpenAI custosa
step-up-verify Auth sensível, v2 = discriminated union por step

P1 — sunset v1: 2026-11-30 (#47)

Endpoint Notas v2
ownership-audit triggered_by obrigatório
ownership-repair dry_run obrigatório + idempotency_key
simulation-orchestrator targetFunctions min 1 + idempotency_key
sync-external-db since valida ISO 8601
trends-insights .strict()

P2 — sunset v1: 2026-12-31 (#48)

Endpoint Notas v2
force-global-logout idempotency_key
e2e-cleanup confirm: true + idempotency_key + .refine (sellerScope/sellerId)
block-ip-temporarily Regex IP rigoroso (v1 mantém permissivo p/ compat)

Cobertura de testes

Compatibilidade

Schemas V1 são byte-for-byte equivalentes aos shapes atuais em produção. Nenhum consumidor existente quebra — o que mudou é apenas:

  • Erros de validação agora retornam 422 (era 400) com formato canônico {code, message, fields[], version}
  • Headers Deprecation: true + Sunset: <date> aparecem nas respostas v1

Comunicação aos consumidores n8n sobre essa mudança 400→422 está rastreada em #54 (ação pendente do humano antes do merge).

Dependências

Próximos passos pós-merge


Summary by cubic

Migrated 13 edge functions to contract-based validation using parseContract and versioned Zod schemas. Adds v1 (compat) and v2 (strict) contracts, standardized errors/headers, and 49 contract tests.

  • Refactors

    • Added v1/v2 schemas under supabase/functions/_shared/contracts/schemas/*.
    • Replaced manual req.json() with parseContract(...) in all 13 handlers.
    • Propagate Deprecation, Sunset, and X-Contract-Version headers from handlers.
    • step-up-verify v2 uses a discriminated union per step for stricter auth flows.
    • Added 49 contract tests covering happy paths, 400/406/422 cases.
  • Migration

    • v1 stays default and matches current payloads; no breaking changes.
    • Validation errors now return 422 with { code, message, fields, version }.
    • Opt in to v2 via Accept-Version: 2. v1 responds with Deprecation: true and Sunset (P0: 2026-10-31, P1: 2026-11-30, P2: 2026-12-31).
    • For v2, add idempotency_key on endpoints with side effects (send-transactional-email, kit-ai-builder, bi-copilot, simulation-orchestrator, ownership-repair, e2e-cleanup, force-global-logout).
    • v2 tightens contracts overall: strict objects, union-by-step, ISO 8601 dates (since), stricter IP/CIDR, required fields where missing in v1.

Written for commit 3aa25f1. Summary will update on new commits. Review in cubic

Copilot AI review requested due to automatic review settings May 22, 2026 01:41
@vercel
Copy link
Copy Markdown

vercel Bot commented May 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
we-dream-big Ready Ready Preview, Comment May 22, 2026 1:42am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ea9f2906-b324-4290-aed2-0e84a5565168

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/contracts-02-03-04-migrate-13-functions

Comment @coderabbitai help to get the list of available commands and usage tips.

@supabase
Copy link
Copy Markdown

supabase Bot commented May 22, 2026

This pull request has been ignored for the connected project doufsxqlfjyuvxuezpln due to reaching the limit of concurrent preview branches.
Go to Project Integrations Settings ↗︎ if you wish to update this limit.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

10 issues found across 29 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="supabase/functions/trends-insights/index.ts">

<violation number="1" location="supabase/functions/trends-insights/index.ts:52">
P1: This change makes empty request bodies fail, but this endpoint previously accepted no body and defaulted `days` to 30. It introduces a compatibility regression for existing callers that omit payloads.</violation>
</file>

<file name="supabase/functions/e2e-cleanup/index.ts">

<violation number="1" location="supabase/functions/e2e-cleanup/index.ts:42">
P2: Request-specific contract headers are stored in module-scope mutable state, which can leak/cross-contaminate headers between requests.</violation>
</file>

<file name="supabase/functions/ownership-repair/index.ts">

<violation number="1" location="supabase/functions/ownership-repair/index.ts:20">
P2: Avoid storing `contractResponseHeaders` in module scope; shared mutable state can leak headers across concurrent requests.</violation>
</file>

<file name="supabase/functions/bi-copilot/index.ts">

<violation number="1" location="supabase/functions/bi-copilot/index.ts:113">
P2: `responseHeaders` are only applied on the success response. After parsing succeeds, error responses (429/402/502/500) should also include contract headers to keep version/deprecation signaling consistent.</violation>
</file>

<file name="supabase/functions/block-ip-temporarily/index.ts">

<violation number="1" location="supabase/functions/block-ip-temporarily/index.ts:10">
P2: `contractResponseHeaders` is stored in module scope and mutated per request, which can leak/overwrite headers across concurrent requests.</violation>

<violation number="2" location="supabase/functions/block-ip-temporarily/index.ts:55">
P2: `hours` input became stricter: numeric strings that previously worked are now rejected by `z.number()`, causing a compatibility break for existing clients.</violation>
</file>

<file name="supabase/functions/ownership-audit/index.ts">

<violation number="1" location="supabase/functions/ownership-audit/index.ts:42">
P1: This migration changed ownership-audit from accepting empty request bodies to returning 422. Empty-body invocations previously defaulted `triggered_by` to `"cron"`, so scheduled calls without payload can now fail.</violation>
</file>

<file name="supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts">

<violation number="1" location="supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts:12">
P2: The V2 IP regex is not actually strict: it accepts invalid IPv4/IPv6 values, so malformed addresses can pass contract validation.</violation>

<violation number="2" location="supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts:29">
P2: V2 is missing `idempotency_key` even though this endpoint performs writes; retries can create duplicate side effects and this is inconsistent with other mutating V2 contracts.</violation>
</file>

<file name="supabase/functions/_shared/contracts/schemas/step-up-verify.ts">

<violation number="1" location="supabase/functions/_shared/contracts/schemas/step-up-verify.ts:32">
P2: `step-up-verify` V2 is strict but has no `idempotency_key`, so retried side-effect requests cannot be deduplicated and may execute twice.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +52 to +54
const contractResult = await parseContract(req, TrendsInsightsSchemas, {
corsHeaders,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: This change makes empty request bodies fail, but this endpoint previously accepted no body and defaulted days to 30. It introduces a compatibility regression for existing callers that omit payloads.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/trends-insights/index.ts, line 52:

<comment>This change makes empty request bodies fail, but this endpoint previously accepted no body and defaulted `days` to 30. It introduces a compatibility regression for existing callers that omit payloads.</comment>

<file context>
@@ -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,
+    });
</file context>
Suggested change
const contractResult = await parseContract(req, TrendsInsightsSchemas, {
corsHeaders,
});
const rawBody = await req.text().catch(() => "");
const contractResult = await parseContract(req, TrendsInsightsSchemas, {
corsHeaders,
prereadBody: rawBody.trim() === "" ? "{}" : rawBody,
});

Comment on lines +42 to +47
const contractResult = await parseContract(req, OwnershipAuditSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
contractResponseHeaders = contractResult.responseHeaders;
const triggeredBy = contractResult.data.triggered_by ?? "cron";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: This migration changed ownership-audit from accepting empty request bodies to returning 422. Empty-body invocations previously defaulted triggered_by to "cron", so scheduled calls without payload can now fail.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/ownership-audit/index.ts, line 42:

<comment>This migration changed ownership-audit from accepting empty request bodies to returning 422. Empty-body invocations previously defaulted `triggered_by` to `"cron"`, so scheduled calls without payload can now fail.</comment>

<file context>
@@ -33,13 +39,12 @@ Deno.serve(async (req) => {
-    } catch {
-      // sem body — ok, usa default "cron"
-    }
+    const contractResult = await parseContract(req, OwnershipAuditSchemas, {
+      corsHeaders,
+    });
</file context>
Suggested change
const contractResult = await parseContract(req, OwnershipAuditSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
contractResponseHeaders = contractResult.responseHeaders;
const triggeredBy = contractResult.data.triggered_by ?? "cron";
const rawBody = await req.text();
const contractResult = await parseContract(req, OwnershipAuditSchemas, {
corsHeaders,
prereadBody: rawBody.trim() === "" ? "{}" : rawBody,
});
if (!contractResult.ok) return contractResult.response;
contractResponseHeaders = contractResult.responseHeaders;
const triggeredBy = contractResult.data.triggered_by ?? "cron";

};

const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-e2e-cleanup-token"], allowMethods: "POST, OPTIONS" });
let contractResponseHeaders: Record<string, string> = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Request-specific contract headers are stored in module-scope mutable state, which can leak/cross-contaminate headers between requests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/e2e-cleanup/index.ts, line 42:

<comment>Request-specific contract headers are stored in module-scope mutable state, which can leak/cross-contaminate headers between requests.</comment>

<file context>
@@ -35,11 +39,12 @@ type E2ERateLimitRow = {
 };
 
 const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-e2e-cleanup-token"], allowMethods: "POST, OPTIONS" });
+let contractResponseHeaders: Record<string, string> = {};
 
 function jsonResponse(body: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
</file context>


// Module-scope CORS headers — atribuído per-request no handler.
let corsHeaders: Record<string, string> = {};
let contractResponseHeaders: Record<string, string> = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Avoid storing contractResponseHeaders in module scope; shared mutable state can leak headers across concurrent requests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/ownership-repair/index.ts, line 20:

<comment>Avoid storing `contractResponseHeaders` in module scope; shared mutable state can leak headers across concurrent requests.</comment>

<file context>
@@ -10,9 +10,14 @@ import { getCorsHeaders } from "../_shared/cors.ts";
 
 // Module-scope CORS headers — atribuído per-request no handler.
 let corsHeaders: Record<string, string> = {};
+let contractResponseHeaders: Record<string, string> = {};
 
 type RepairOrphansResult = {
</file context>

return new Response(JSON.stringify({ answer }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: responseHeaders are only applied on the success response. After parsing succeeds, error responses (429/402/502/500) should also include contract headers to keep version/deprecation signaling consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/bi-copilot/index.ts, line 113:

<comment>`responseHeaders` are only applied on the success response. After parsing succeeds, error responses (429/402/502/500) should also include contract headers to keep version/deprecation signaling consistent.</comment>

<file context>
@@ -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) {
</file context>

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, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: hours input became stricter: numeric strings that previously worked are now rejected by z.number(), causing a compatibility break for existing clients.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/block-ip-temporarily/index.ts, line 55:

<comment>`hours` input became stricter: numeric strings that previously worked are now rejected by `z.number()`, causing a compatibility break for existing clients.</comment>

<file context>
@@ -46,16 +52,16 @@ Deno.serve(async (req) => {
-    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,
+    });
</file context>


// Module-scope CORS headers — atribuído per-request no handler.
let corsHeaders: Record<string, string> = {};
let contractResponseHeaders: Record<string, string> = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: contractResponseHeaders is stored in module scope and mutated per request, which can leak/overwrite headers across concurrent requests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/block-ip-temporarily/index.ts, line 10:

<comment>`contractResponseHeaders` is stored in module scope and mutated per request, which can leak/overwrite headers across concurrent requests.</comment>

<file context>
@@ -1,13 +1,18 @@
 
 // Module-scope CORS headers — atribuído per-request no handler.
 let corsHeaders: Record<string, string> = {};
+let contractResponseHeaders: Record<string, string> = {};
 
 function jsonRes(body: unknown, status = 200) {
</file context>

message: "IP inválido (use IPv4, IPv6 ou CIDR)",
}),
reason: z.string().min(1).max(500),
hours: z.number().int().min(1).max(720),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: V2 is missing idempotency_key even though this endpoint performs writes; retries can create duplicate side effects and this is inconsistent with other mutating V2 contracts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts, line 29:

<comment>V2 is missing `idempotency_key` even though this endpoint performs writes; retries can create duplicate side effects and this is inconsistent with other mutating V2 contracts.</comment>

<file context>
@@ -0,0 +1,45 @@
+      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();
</file context>

// 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 =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The V2 IP regex is not actually strict: it accepts invalid IPv4/IPv6 values, so malformed addresses can pass contract validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts, line 12:

<comment>The V2 IP regex is not actually strict: it accepts invalid IPv4/IPv6 values, so malformed addresses can pass contract validation.</comment>

<file context>
@@ -0,0 +1,45 @@
+// 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]))?$/;
+
</file context>

cancel_reason: z.string().max(500).optional(),
});

const RequestStepV2 = z
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: step-up-verify V2 is strict but has no idempotency_key, so retried side-effect requests cannot be deduplicated and may execute twice.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_shared/contracts/schemas/step-up-verify.ts, line 32:

<comment>`step-up-verify` V2 is strict but has no `idempotency_key`, so retried side-effect requests cannot be deduplicated and may execute twice.</comment>

<file context>
@@ -0,0 +1,84 @@
+  cancel_reason: z.string().max(500).optional(),
+});
+
+const RequestStepV2 = z
+  .object({
+    step: z.literal("request"),
</file context>

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3aa25f1f36

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +42 to +45
const contractResult = await parseContract(req, OwnershipAuditSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve no-body cron calls for ownership audit

This change makes ownership-audit reject empty POST bodies: parseContract returns missing_body (400) when req.text() is empty, so the handler now exits before applying the previous default triggeredBy = "cron". Before this commit, the function explicitly tolerated missing JSON (catch { /* sem body */ }), which is a common cron invocation pattern; with this change those calls fail and the daily audit job can stop running unless every caller sends {}.

Useful? React with 👍 / 👎.

Comment on lines +48 to +51
const contractResult = await parseContract(req, OwnershipRepairSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep ownership-repair default behavior on empty body

Switching to parseContract also removes the old fallback for empty/missing JSON in ownership-repair. Previously the handler defaulted to {} and proceeded with dry_run=true/default triggered_by; now empty-body requests fail at validation with 400 before reaching those defaults. Any existing caller that relied on the documented optional body behavior will regress even when using v1.

Useful? React with 👍 / 👎.

Comment on lines +225 to +228
const contractResult = await parseContract(req, MarketIntelligenceInsightsSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep empty-body defaults for market insights v1

This migration changes market-intelligence-insights from tolerant parsing (req.json().catch(() => ({}))) to hard failure on empty body via parseContract, so requests that previously relied on default filters/days now return missing_body 400 before business logic runs. Because v1 schema/documentation treat all fields as optional and defaultable, rejecting no-body calls is a behavioral regression for existing clients that POST without {}.

Useful? React with 👍 / 👎.

Comment on lines +52 to +55
const contractResult = await parseContract(req, TrendsInsightsSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve no-body fallback in trends insights v1

The new contract parse path rejects empty request bodies, but this endpoint previously accepted them and computed days from defaults (req.json().catch(() => ({}))). As a result, callers that omit the body (a valid v1 usage before this commit) now get missing_body 400 instead of a normal insights response, which is an unintended compatibility break.

Useful? React with 👍 / 👎.

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

export const MarketIntelligenceInsightsV1 = z.object({
days: z.number().int().min(1).max(365).optional(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep v1 days coercion/clamping in market insights

The new v1 contract enforces days as a strict bounded integer, but the previous handler coerced/clamped days (Math.max(1, Math.min(body.days ?? 30, 365))), so values like "30", 0, or 400 used to be normalized and processed successfully. After this commit, those inputs fail validation with 422 before reaching the existing normalization path, which breaks backward compatibility for clients relying on v1 leniency.

Useful? React with 👍 / 👎.

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

export const TrendsInsightsV1 = z.object({
days: z.number().int().min(1).max(365).optional(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep v1 days coercion/clamping in trends insights

The v1 schema now rejects out-of-range or string days (z.number().int().min(1).max(365)), but the pre-migration endpoint intentionally clamped/coerced days via arithmetic before querying. This means formerly tolerated inputs (e.g., "7", 0, 999) now return 422 instead of being normalized, creating a compatibility regression in the default v1 contract.

Useful? React with 👍 / 👎.

message: "IP inválido (use IPv4, IPv6 ou CIDR)",
}),
reason: z.string().max(500).optional(),
hours: z.number().int().min(1).max(720).optional(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve v1 numeric-string support for block duration

In block-ip-temporarily, v1 now requires hours to be a typed number, while the previous handler deliberately coerced with Number(body.hours) and clamped to [1,720]. Requests sending hours as a numeric string (common in loosely typed clients) previously succeeded but now fail contract validation, which is a backward-incompatible change in the v1 path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR continues the contracts migration introduced in _shared/contracts, moving 13 Supabase Edge Functions to parseContract-based request parsing with v1/v2 Zod schemas, deprecation/version headers, and a larger contract test suite.

Changes:

  • Migrates 13 Edge Functions from manual req.json() parsing to parseContract(req, Schemas, { corsHeaders }) and propagates contract responseHeaders on success paths.
  • Adds 13 new contract schema modules under supabase/functions/_shared/contracts/schemas/ (v1 compat + v2 strict/idempotency/discriminated unions where applicable).
  • Adds/expands contract tests under tests/contracts/ (endpoint-specific + consolidated suite).

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 30 comments.

Show a summary per file
File Description
tests/contracts/step-up-verify.contract.test.ts Adds contract tests covering v1 compat + v2 discriminated-union behavior.
tests/contracts/send-transactional-email.contract.test.ts Adds contract tests for v1/v2, error codes, and version negotiation.
tests/contracts/migrated-endpoints.contract.test.ts Consolidated smoke-style contract tests for the other migrated endpoints.
supabase/functions/trends-insights/index.ts Switches request parsing to parseContract and propagates response headers on most responses.
supabase/functions/sync-external-db/index.ts Uses parseContract for request validation and adds contract headers to responses.
supabase/functions/step-up-verify/index.ts Uses parseContract and threads contract headers into the JSON helper.
supabase/functions/simulation-orchestrator/index.ts Uses parseContract and adds contract headers to the success response.
supabase/functions/send-transactional-email/index.ts Uses parseContract and adds contract headers to the success response.
supabase/functions/ownership-repair/index.ts Uses parseContract and threads contract headers into the JSON helper.
supabase/functions/ownership-audit/index.ts Uses parseContract and threads contract headers into the JSON helper.
supabase/functions/market-intelligence-insights/index.ts Uses parseContract and propagates contract headers on most responses.
supabase/functions/kit-ai-builder/index.ts Uses parseContract and propagates contract headers on the success response.
supabase/functions/force-global-logout/index.ts Uses parseContract and threads contract headers into the JSON helper.
supabase/functions/e2e-cleanup/index.ts Uses parseContract and threads contract headers into the JSON helper.
supabase/functions/block-ip-temporarily/index.ts Uses parseContract and threads contract headers into the JSON helper.
supabase/functions/bi-copilot/index.ts Uses parseContract and propagates contract headers on the success response.
supabase/functions/_shared/contracts/schemas/trends-insights.ts Adds v1/v2 contract schemas + deprecation metadata for trends-insights.
supabase/functions/_shared/contracts/schemas/sync-external-db.ts Adds v1/v2 contract schemas + deprecation metadata for sync-external-db.
supabase/functions/_shared/contracts/schemas/step-up-verify.ts Adds v1/v2 contract schemas with v2 discriminated union.
supabase/functions/_shared/contracts/schemas/simulation-orchestrator.ts Adds v1/v2 contract schemas including v2 idempotency requirements.
supabase/functions/_shared/contracts/schemas/send-transactional-email.ts Adds v1/v2 contract schemas including v2 idempotency requirements.
supabase/functions/_shared/contracts/schemas/ownership-repair.ts Adds v1/v2 contract schemas including v2 idempotency requirements.
supabase/functions/_shared/contracts/schemas/ownership-audit.ts Adds v1/v2 contract schemas (v2 requires triggered_by).
supabase/functions/_shared/contracts/schemas/market-intelligence-insights.ts Adds v1/v2 contract schemas (v2 strict + UUID validation).
supabase/functions/_shared/contracts/schemas/kit-ai-builder.ts Adds v1/v2 contract schemas including v2 idempotency requirements.
supabase/functions/_shared/contracts/schemas/force-global-logout.ts Adds v1/v2 contract schemas including v2 idempotency requirements.
supabase/functions/_shared/contracts/schemas/e2e-cleanup.ts Adds v1/v2 contract schemas including v2 confirm + refine rule.
supabase/functions/_shared/contracts/schemas/block-ip-temporarily.ts Adds v1/v2 contract schemas including stricter IP validation in v2.
supabase/functions/_shared/contracts/schemas/bi-copilot.ts Adds v1/v2 contract schemas (v2 strict + required context).
Comments suppressed due to low confidence (1)

supabase/functions/trends-insights/index.ts:205

  • Após um parseContract bem-sucedido, o catch ainda retorna headers apenas com corsHeaders (sem responseHeaders). Isso faz respostas de erro perderem x-contract-version e, em v1, Deprecation/Sunset/Link. Sugestão: declarar responseHeaders fora do try e sempre mesclar em todas as respostas depois do parse (inclusive no catch).
      headers: { ...corsHeaders, ...responseHeaders, "Content-Type": "application/json" },
    });
  } catch (e) {
    console.error("trends-insights error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +52 to 58
const contractResult = await parseContract(req, TrendsInsightsSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
const { data: body, responseHeaders } = contractResult;
const days = body.days ?? 30;

Comment on lines +34 to +39
const contractResult = await parseContract(req, KitAiBuilderSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
const { data: body, responseHeaders } = contractResult;
const prompt = body.prompt.trim();
Comment on lines +12 to +18
.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),
Comment on lines +47 to 52
const contractResult = await parseContract(req, BiCopilotSchemas, {
corsHeaders,
});
if (!contractResult.ok) return contractResult.response;
const { data: body, responseHeaders } = contractResult;

Comment on lines 185 to 193
// Return email content (actual sending happens when email domain is configured)
return new Response(
JSON.stringify({
success: true,
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" } }
);
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",
version: "1",
sunset: "2026-11-30",
migrationUrl:
"https://github.com/adm01-debug/promo-gifts-v4/blob/main/docs/contracts/MIGRATION_GUIDE.md#simulation-orchestrator",
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",
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",
Comment on lines +9 to +18
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(),
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants