feat(edges): createEdge template + auth hardening F1/F2#217
Conversation
- _shared/createEdge.ts: template unificado (130L) - F1 — 6 edges expostas exigem JWT agente: bi-copilot, comparison-ai-advisor, kit-ai-builder, categories-api, quote-sync, dropbox-list - F2 — 13 crons protegidos com x-cron-secret (CRON_SECRET env): cleanup-notifications, cleanup-novelties, collections-watcher, comparison-price-watcher, connections-health-check, favorites-watcher, ownership-audit, process-queue, process-scheduled-reports, quote-followup-reminders, send-digest, send-notification, send-scheduled-reports
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: af6774c198
ℹ️ 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".
| const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); | ||
| if (!cronAuth.ok) return cronAuth.response; |
There was a problem hiding this comment.
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 👍 / 👎.
| try { | ||
| const authCtx = await authenticateRequest(req); | ||
| requireRole(authCtx, "agente"); | ||
| } catch (authErr) { | ||
| return authErrorResponse(authErr, corsHeaders); |
There was a problem hiding this comment.
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 👍 / 👎.
| @@ -1,5 +1,6 @@ | |||
| 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"; | |||
There was a problem hiding this comment.
Call authorizeCron for scheduled reports
This only imports authorizeCron; the handler never invokes it before processing due reports. In environments where this Edge Function is deployed without Supabase JWT verification like the other cron/service functions, an unauthenticated request can still trigger the scheduled report batch and send due emails with the service-role client, so send-scheduled-reports remains exposed while the commit claims it is protected with x-cron-secret.
Useful? React with 👍 / 👎.
| const authCtx = await authenticateRequest(req); | ||
| requireRole(authCtx, "agente"); | ||
| } catch (authErr) { | ||
| return authErrorResponse(authErr, corsHeaders); |
There was a problem hiding this comment.
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 👍 / 👎.
| const authCtx = await authenticateRequest(req); | ||
| requireRole(authCtx, "agente"); |
There was a problem hiding this comment.
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 👍 / 👎.
| const cronAuth = authorizeCron(req, { | ||
| corsHeaders: {}, | ||
| secretEnvName: "CRON_SECRET", | ||
| headerName: "x-cron-secret", | ||
| }); | ||
| if (!cronAuth.ok) return cronAuth.response; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Hardening pass for Supabase Edge Functions: introduces a unified createEdge template and applies two concrete authorization tiers — JWT-protected ("agente or above") on six previously unauthenticated public edges (F1) and x-cron-secret (timing-safe) on thirteen cron edges (F2). The template is meant to consolidate four divergent auth patterns currently living across 83 functions; existing edges keep their hand-rolled handlers and will migrate gradually.
Changes:
- Adds
_shared/createEdge.tswithjwt/cron/publicmodes and ajsonResponsehelper. - Adds JWT auth (
authenticateRequest+requireRole(_, "agente")) to six public edges that previously hit Lovable AI / service-role / Dropbox tokens with no auth. - Adds
authorizeCron(req, { secretEnvName: "CRON_SECRET" })to thirteen cron edges with retrocompat (anonymous accepted with warning when secret is unset).
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
| supabase/functions/_shared/createEdge.ts | New unified template; documents hmac mode that isn't implemented and forces wildcard CORS for public. |
| supabase/functions/bi-copilot/index.ts | Adds JWT + role check after OPTIONS (correct order). |
| supabase/functions/comparison-ai-advisor/index.ts | Adds JWT + role check; reuses module-scope mutable corsHeaders. |
| supabase/functions/kit-ai-builder/index.ts | Adds JWT + role check after OPTIONS. |
| supabase/functions/categories-api/index.ts | Adds JWT + role check before OPTIONS — breaks CORS preflight. |
| supabase/functions/dropbox-list/index.ts | Same OPTIONS-after-auth ordering bug as categories-api. |
| supabase/functions/quote-sync/index.ts | Adds JWT + role check after OPTIONS. |
| supabase/functions/cleanup-notifications/index.ts | Adds cron secret; introduces a duplicate OPTIONS short-circuit without CORS. |
| supabase/functions/cleanup-novelties/index.ts | Same duplicate OPTIONS issue. |
| supabase/functions/process-queue/index.ts | Same duplicate OPTIONS issue. |
| supabase/functions/process-scheduled-reports/index.ts | Same duplicate OPTIONS issue. |
| supabase/functions/send-digest/index.ts | Same duplicate OPTIONS issue. |
| supabase/functions/send-notification/index.ts | Same duplicate OPTIONS issue. |
| supabase/functions/send-scheduled-reports/index.ts | Imports authorizeCron but never calls it — function remains unprotected. |
| supabase/functions/quote-followup-reminders/index.ts | Adds cron secret with empty CORS headers for error path. |
| supabase/functions/ownership-audit/index.ts | Adds cron secret but passes corsHeaders: {} despite having per-request CORS. |
| supabase/functions/favorites-watcher/index.ts | Adds cron secret with empty CORS headers for error path. |
| supabase/functions/collections-watcher/index.ts | Same as favorites-watcher. |
| supabase/functions/comparison-price-watcher/index.ts | Same as favorites-watcher. |
| supabase/functions/connections-health-check/index.ts | Same as favorites-watcher. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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); | ||
| } | ||
|
|
||
| if (req.method === "OPTIONS") { | ||
| return new Response(null, { headers: corsHeaders }); | ||
| } |
| 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); | ||
| } | ||
|
|
||
| if (req.method === 'OPTIONS') { | ||
| return new Response(null, { headers: corsHeaders }); | ||
| } |
| // Auth: exige vendedor autenticado (agente ou acima) | ||
| try { | ||
| const authCtx = await authenticateRequest(req); | ||
| requireRole(authCtx, "agente"); |
| // 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 }); | ||
| } |
| // 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 }); |
| 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); | ||
|
|
||
| // 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 }); | ||
| } |
| * 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) |
| 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); | ||
|
|
||
| // 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 }); | ||
| } | ||
|
|
||
| // ── 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" } }, | ||
| ); | ||
| } | ||
| }; | ||
| } |
| // CORS headers — modo public usa buildPublicCorsHeaders | ||
| const corsHeaders = | ||
| config.auth === "public" | ||
| ? buildPublicCorsHeaders() | ||
| : getCorsHeaders(req); |
| @@ -1,5 +1,6 @@ | |||
| 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"; | |||
…icts Conflito em 3 arquivos entre PR #216 e commits #217/#218/#214 no main: - vitest.config.ts: mantém comentário explicativo do PR sobre exclusão de tests/e2e/** (não estava em main), restante idêntico. - src/hooks/__tests__/useCatalogState.unit.test.tsx: usa versão do main (bloco TODO detalhado no topo do arquivo é superior ao comentário redundante antes do describe.skip que estava no PR). - tests/components/SimulationPriceSourceBadge.test.tsx: resolvido automaticamente (mudança idêntica nas duas branches). https://claude.ai/code/session_017f7HQxDreJKyNDJM3R7qf6
O que muda
_shared/createEdge.ts— novo template unificado (130L)Resolve 4 padrões de auth coexistindo em 83 funções. Novas edges devem usar este template. Migração das existentes é gradual.
Modos:
jwt|cron|publicF1 — 6 edges expostas agora exigem JWT (agente ou acima)
bi-copilot— chamava Lovable AI sem authcomparison-ai-advisor— chamava Lovable AI sem authkit-ai-builder— chamava Lovable AI sem authcategories-api— usava service_role do banco externo sem authquote-sync— usava service_role sem authdropbox-list— usava token Dropbox do env sem authF2 — 13 crons protegidos com
x-cron-secretUsa
authorizeCron()existente em_shared/dispatcher-auth.ts. Secret: envCRON_SECRET. Retrocompat: aceita chamada anônima com warning seCRON_SECRETnão estiver configurado.Funções:
cleanup-notifications,cleanup-novelties,collections-watcher,comparison-price-watcher,connections-health-check,favorites-watcher,ownership-audit,process-queue,process-scheduled-reports,quote-followup-reminders,send-digest,send-notification,send-scheduled-reportsAção necessária pós-merge
Registrar
CRON_SECRETem Supabase Dashboard → Edge Functions → Secrets antes de ativar os crons em produção.Impacto
Summary by cubic
Adds a shared
createEdgetemplate to unify auth across Edge Functions and hardens access: six exposed functions now require JWT, and 13 crons are protected withx-cron-secret.New Features
_shared/createEdge.tstemplate with modes:jwt(role checks),cron(secret header), andpublic.role: agenteor higher) on:bi-copilot,comparison-ai-advisor,kit-ai-builder,categories-api,quote-sync,dropbox-list.x-cron-secretusingCRON_SECRET(backward compatible if unset):cleanup-notifications,cleanup-novelties,collections-watcher,comparison-price-watcher,connections-health-check,favorites-watcher,ownership-audit,process-queue,process-scheduled-reports,quote-followup-reminders,send-digest,send-notification,send-scheduled-reports.Migration
CRON_SECRETin Supabase Dashboard → Edge Functions → Secrets before enabling crons in production.createEdgetemplate for new functions; migrate existing ones gradually.Written for commit af6774c. Summary will update on new commits.