Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions supabase/functions/_shared/createEdge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* createEdge — template unificado para Edge Functions do PromoGifts.
*
* Resolve o problema de 4 padrões de auth coexistindo em 83 funções.
* Novas edges devem usar este template. Migração das existentes é gradual.
*
* Modos suportados:
* jwt → JWT obrigatório + verificação de role (usa _shared/auth.ts)
* cron → x-cron-secret timing-safe (usa _shared/dispatcher-auth.ts)
* hmac → HMAC de payload (usar diretamente dispatcher-auth.ts)
* public → sem auth; bot-protection opcional (explicitamente declarado)
*
* Uso:
* export default createEdge(
* { auth: 'jwt', role: 'agente' },
* async (req, ctx) => {
* const { userId, userRole } = ctx;
* return new Response(JSON.stringify({ ok: true }), { status: 200 });
* }
* );
*
* Para crons:
* export default createEdge(
* { auth: 'cron', secretEnv: 'CRON_SECRET' },
* async (req, _ctx) => { ... }
* );
*/

import { getCorsHeaders, buildPublicCorsHeaders } from "./cors.ts";
import {
authenticateRequest,
requireRole,
authErrorResponse,
type AuthResult,
} from "./auth.ts";
import { authorizeCron } from "./dispatcher-auth.ts";

// ---------------------------------------------------------------------------
// Tipos públicos
// ---------------------------------------------------------------------------

export type EdgeRole = "agente" | "supervisor" | "dev";

export type EdgeConfig =
| { auth: "jwt"; role?: EdgeRole }
| { auth: "cron"; secretEnv: string; headerName?: string }
| { auth: "public" };

export interface EdgeContext {
/** Presente apenas no modo 'jwt'. */
user?: Pick<AuthResult, "userId" | "userRole" | "userRoles" | "localServiceClient">;
corsHeaders: Record<string, string>;
}

export type EdgeHandler = (
req: Request,
ctx: EdgeContext,
) => Promise<Response>;

// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------

export function createEdge(
config: EdgeConfig,
handler: EdgeHandler,
): (req: Request) => Promise<Response> {
return async (req: Request): Promise<Response> => {
// CORS headers — modo public usa buildPublicCorsHeaders
const corsHeaders =
config.auth === "public"
? buildPublicCorsHeaders()
: getCorsHeaders(req);
Comment on lines +69 to +73

// Preflight OPTIONS — responde sempre
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders, status: 204 });
}

try {
// ── Modo jwt ──────────────────────────────────────────────────────────
if (config.auth === "jwt") {
const auth = await authenticateRequest(req);
if (config.role) requireRole(auth, config.role);
return await handler(req, { user: auth, corsHeaders });
}
Comment on lines +42 to +86

// ── Modo cron ─────────────────────────────────────────────────────────
if (config.auth === "cron") {
const result = authorizeCron(req, {
corsHeaders,
secretEnvName: config.secretEnv,
headerName: config.headerName ?? "x-cron-secret",
});
if (!result.ok) return result.response;
return await handler(req, { corsHeaders });
}

// ── Modo public ───────────────────────────────────────────────────────
return await handler(req, { corsHeaders });

} catch (err) {
// Erros lançados por authenticateRequest / requireRole (status + message)
if ((err as any)?.status) {
return authErrorResponse(err, corsHeaders);
}
// Erros inesperados
console.error("[createEdge] unhandled error:", err);
return new Response(
JSON.stringify({ error: "internal_error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
};
}
Comment on lines +64 to +115

// ---------------------------------------------------------------------------
// Helper: resposta JSON padronizada
// ---------------------------------------------------------------------------

export function jsonResponse(
body: unknown,
status = 200,
corsHeaders: Record<string, string> = {},
): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
9 changes: 9 additions & 0 deletions supabase/functions/bi-copilot/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from "../_shared/cors.ts";
import { authenticateRequest, requireRole, authErrorResponse } from "../_shared/auth.ts";
/**
* Edge function `bi-copilot` — responde perguntas do vendedor sobre um cliente
* com base no contexto BI (score, sazonalidade, afinidade, tendências, benchmarks).
Expand All @@ -23,6 +24,14 @@ Deno.serve(async (req) => {

const corsHeaders = getCorsHeaders(req);

// Auth: exige vendedor autenticado (agente ou acima)
try {
const authCtx = await authenticateRequest(req);
requireRole(authCtx, "agente");
} catch (authErr) {
return authErrorResponse(authErr, corsHeaders);
}

try {
if (!LOVABLE_API_KEY) {
return new Response(
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/categories-api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from '../_shared/cors.ts';
import { authenticateRequest, requireRole, authErrorResponse } from '../_shared/auth.ts';
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";
import { z } from '../_shared/zod-validate.ts';

Expand All @@ -10,6 +11,14 @@ const CategoriesRequestSchema = z.object({

Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req);
// Auth: exige vendedor autenticado (agente ou acima)
try {
const authCtx = await authenticateRequest(req);
requireRole(authCtx, "agente");
} catch (authErr) {
return authErrorResponse(authErr, corsHeaders);
Comment on lines +15 to +19
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 Move CORS preflight before JWT auth

For browser calls to categories-api (for example src/hooks/useProductsByCategory.ts uses supabase.functions.invoke), the browser sends an unauthenticated OPTIONS preflight before the real request. Because this JWT check runs before the OPTIONS branch below, the preflight gets a 401 instead of a 2xx CORS response, so the browser blocks the actual authenticated category request.

Useful? React with 👍 / 👎.

}

if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
Comment on lines 12 to 24
Expand Down
6 changes: 6 additions & 0 deletions supabase/functions/cleanup-notifications/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";
import { buildPublicCorsHeaders } from "../_shared/cors.ts";
import { authorizeCron } from "../_shared/dispatcher-auth.ts";

const corsHeaders = buildPublicCorsHeaders();

Deno.serve(async (req) => {
// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
if (req.method === "OPTIONS") return new Response(null, { status: 204 });
const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" });
if (!cronAuth.ok) return cronAuth.response;

if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
Comment on lines +8 to 15
Expand Down
6 changes: 6 additions & 0 deletions supabase/functions/cleanup-novelties/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { getCorsHeaders, handleCorsPreflightIfNeeded } from '../_shared/cors.ts';
import { authorizeCron } from '../_shared/dispatcher-auth.ts';
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";

// CORS headers are now dynamic — use getCorsHeaders(req) inside the handler
// See _shared/cors.ts for the centralized configuration

Deno.serve(async (req) => {
// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
if (req.method === "OPTIONS") return new Response(null, { status: 204 });
const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" });
if (!cronAuth.ok) return cronAuth.response;

const corsHeaders = getCorsHeaders(req);
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
Comment on lines +9 to 16
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/collections-watcher/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from "../_shared/cors.ts";
import { authorizeCron } from "../_shared/dispatcher-auth.ts";
// collections-watcher: cron diário que detecta quedas de preço em itens de coleções
// e gera workspace_notifications (categoria "collections") com dedupe 24h.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.95.0";
Expand Down Expand Up @@ -32,6 +33,14 @@ Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req);
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });

// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
const cronAuth = authorizeCron(req, {
corsHeaders: {},
secretEnvName: "CRON_SECRET",
headerName: "x-cron-secret",
});
if (!cronAuth.ok) return cronAuth.response;

try {
const service = createClient(
Deno.env.get("SUPABASE_URL")!,
Expand Down
10 changes: 10 additions & 0 deletions supabase/functions/comparison-ai-advisor/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildPublicCorsHeaders, getCorsHeaders } from "../_shared/cors.ts";
import { authenticateRequest, requireRole, authErrorResponse } from "../_shared/auth.ts";
// Comparison AI Advisor — Lovable AI Gateway
// Recebe lista slim de produtos e retorna 3-5 bullets + bestFor highVolume/fastDelivery/premium.

Expand Down Expand Up @@ -57,6 +58,15 @@ const ToolSchema = {
serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: getCorsHeaders(req) });

corsHeaders = getCorsHeaders(req);
// Auth: exige vendedor autenticado (agente ou acima)
try {
const authCtx = await authenticateRequest(req);
requireRole(authCtx, "agente");
Comment on lines +64 to +65
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 Allow supervisors through seller guards

The new guards are commented as allowing “agente ou acima”, but requireRole(authCtx, "agente") only accepts a literal agente role (besides dev); supervisor/legacy admin only pass when the required role is supervisor or admin. A supervisor who does not also have an agente row will now get 403 on these newly protected frontend flows, including comparison AI, even though the intended hierarchy says supervisors are above agents.

Useful? React with 👍 / 👎.

} catch (authErr) {
return authErrorResponse(authErr, corsHeaders);
}

try {
const json = await req.json().catch(() => ({}));
const parsed = BodySchema.safeParse(json);
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/comparison-price-watcher/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from "../_shared/cors.ts";
import { authorizeCron } from "../_shared/dispatcher-auth.ts";
/**
* comparison-price-watcher (C6 #7) — Cron diário.
* Cruza user_comparisons ativas com price_history; se houve queda > 5% nos
Expand All @@ -13,6 +14,14 @@ Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req);
if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) });

// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
const cronAuth = authorizeCron(req, {
corsHeaders: {},
secretEnvName: "CRON_SECRET",
headerName: "x-cron-secret",
});
if (!cronAuth.ok) return cronAuth.response;

const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/connections-health-check/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from "../_shared/cors.ts";
import { authorizeCron } from "../_shared/dispatcher-auth.ts";
// connections-health-check: cron-driven (every 15min). Re-tests every active
// connection, notifies admins on transitions (active→error), on auto-disabled
// outbound webhooks and on stale secrets (>90 days). Dedupe 4h per (key) to
Expand Down Expand Up @@ -60,6 +61,14 @@ Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req);
if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) });

// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
const cronAuth = authorizeCron(req, {
corsHeaders: {},
secretEnvName: "CRON_SECRET",
headerName: "x-cron-secret",
});
if (!cronAuth.ok) return cronAuth.response;

try {
const service = createClient(
Deno.env.get("SUPABASE_URL")!,
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/dropbox-list/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from '../_shared/cors.ts';
import { authenticateRequest, requireRole, authErrorResponse } from '../_shared/auth.ts';
import { z } from "https://esm.sh/zod@3.23.8";
import { fetchWithBreaker, CircuitOpenError, circuitOpenResponse } from '../_shared/external-fetch.ts';

Expand All @@ -9,6 +10,14 @@ const BodySchema = z.object({

Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req);
// Auth: exige vendedor autenticado (agente ou acima)
try {
const authCtx = await authenticateRequest(req);
requireRole(authCtx, "agente");
} catch (authErr) {
return authErrorResponse(authErr, corsHeaders);
Comment on lines +15 to +18
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 Move Dropbox preflight before JWT auth

For browser calls to dropbox-list (documented in src/hooks/useDropboxFiles.ts), the unauthenticated OPTIONS preflight reaches this JWT guard before the handler's OPTIONS response below. That makes the preflight return 401, so the browser blocks the real authenticated Dropbox list/check request even when the user has a valid session.

Useful? React with 👍 / 👎.

}

if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
Comment on lines 11 to 23
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/favorites-watcher/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from "../_shared/cors.ts";
import { authorizeCron } from "../_shared/dispatcher-auth.ts";
// favorites-watcher: cron diário que detecta quedas de preço em favoritos
// e gera workspace_notifications (categoria "favorites") com dedupe 24h.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.95.0";
Expand Down Expand Up @@ -32,6 +33,14 @@ Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req);
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });

// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
const cronAuth = authorizeCron(req, {
corsHeaders: {},
secretEnvName: "CRON_SECRET",
headerName: "x-cron-secret",
});
if (!cronAuth.ok) return cronAuth.response;

try {
const service = createClient(
Deno.env.get("SUPABASE_URL")!,
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/kit-ai-builder/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from "../_shared/cors.ts";
import { authenticateRequest, requireRole, authErrorResponse } from "../_shared/auth.ts";
// ============================================================
// EDGE FUNCTION: kit-ai-builder
// Recebe um prompt natural e devolve uma sugestão estruturada de kit
Expand All @@ -16,6 +17,14 @@ Deno.serve(async (req: Request) => {
}

const corsHeaders = getCorsHeaders(req);
// Auth: exige vendedor autenticado (agente ou acima)
try {
const authCtx = await authenticateRequest(req);
requireRole(authCtx, "agente");
} catch (authErr) {
return authErrorResponse(authErr, corsHeaders);
}


try {
const body = (await req.json().catch(() => ({}))) as RequestBody;
Expand Down
9 changes: 9 additions & 0 deletions supabase/functions/ownership-audit/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCorsHeaders } from "../_shared/cors.ts";
import { authorizeCron } from "../_shared/dispatcher-auth.ts";
/**
* ownership-audit — executa a varredura de propriedade de registros.
*
Expand All @@ -17,6 +18,14 @@ Deno.serve(async (req) => {
corsHeaders = getCorsHeaders(req);
if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) });

// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
const cronAuth = authorizeCron(req, {
corsHeaders: {},
secretEnvName: "CRON_SECRET",
headerName: "x-cron-secret",
});
if (!cronAuth.ok) return cronAuth.response;
Comment on lines +22 to +27
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 manual admin ownership audits

Once CRON_SECRET is configured, this guard only accepts x-cron-secret, but the admin page still invokes ownership-audit from the browser with a normal Supabase JWT (src/pages/admin/OwnershipAuditAdminPage.tsx). That means the “run now” action for admins/devs will start returning 401 even though this function is documented as cron-or-manual and the underlying RPC checks admin permissions.

Useful? React with 👍 / 👎.

Comment on lines +21 to +27

try {
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
Expand Down
6 changes: 6 additions & 0 deletions supabase/functions/process-queue/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";
import { buildPublicCorsHeaders } from "../_shared/cors.ts";
import { authorizeCron } from "../_shared/dispatcher-auth.ts";

const corsHeaders = buildPublicCorsHeaders();

Deno.serve(async (req) => {
// Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas
if (req.method === "OPTIONS") return new Response(null, { status: 204 });
const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" });
if (!cronAuth.ok) return cronAuth.response;
Comment on lines +10 to +11
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 Keep existing pg_cron callers authorized

When CRON_SECRET is configured as the post-merge action requires, this new authorizeCron check only accepts the x-cron-secret header. I checked supabase/cron/cron-config.sql, and the existing scheduled callers for process-queue, send-digest, and cleanup-notifications still send only Authorization: Bearer <service_role> plus Content-Type, so those jobs will start receiving 401s and stop processing queues/digests/cleanup unless the cron definitions are updated or service-role bearer is still accepted.

Useful? React with 👍 / 👎.


if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
Comment on lines +8 to 15
Expand Down
Loading
Loading