Skip to content
Merged
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
342 changes: 318 additions & 24 deletions supabase/functions/_shared/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,329 @@
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
/**
* Shared Zod schemas for Edge Function input validation.
* Import: import { z, parseBody, ... } from "../_shared/schemas.ts";
*/
import { z } from "https://esm.sh/zod@3.23.8";

/** Schema para análise de conversa (ai-conversation-summary) */
export const AiConversationSummarySchema = z.object({
contactId: z.string().uuid().optional().nullable(),
contactName: z.string().optional().nullable(),
messages: z.array(z.object({
role: z.enum(['user', 'assistant', 'system', 'agent', 'client']),
content: z.string(),
sender: z.string().optional(),
timestamp: z.string().optional(),
})).min(1, "Lista de mensagens vazia"),
export { z };

// ─── Common reusable schemas ─────────────────────────────────
export const UUIDSchema = z.string().uuid("Must be a valid UUID");
export const EmailSchema = z.string().email("Invalid email").max(255);
export const SafeStringSchema = (maxLen = 10000) => z.string().max(maxLen).transform(s => s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim());

// ─── AI function schemas ─────────────────────────────────────
export const MessageSchema = z.object({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MessageSchema makes 'content' and 'sender' optional when they should be required, and 'sender' lacks enum validation

Fix on Vercel

sender: z.string().max(50).optional(),
content: z.string().max(5000).optional(),
created_at: z.string().optional(),
message_type: z.string().max(50).optional(),
});
Comment on lines +15 to 20

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

MessageSchema está aceitando mensagens vazias.

Do jeito que está, {} passa e ainda conta para o .min(5) de AiConversationAnalysisSchema e AiConversationSummarySchema. Isso deixa payload sem conteúdo entrar nas rotas de IA e enfraquece a validação no ponto mais compartilhado.

Diff sugerido
 export const MessageSchema = z.object({
   sender: z.string().max(50).optional(),
   content: z.string().max(5000).optional(),
   created_at: z.string().optional(),
   message_type: z.string().max(50).optional(),
-});
+}).refine(
+  (message) => Boolean(message.content?.trim() || message.message_type),
+  { message: "Message must include content or message_type" },
+);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/_shared/schemas.ts` around lines 15 - 20, O MessageSchema
está permitindo objetos vazios (por exemplo {}), o que faz com que entradas sem
conteúdo ainda satisfaçam os .min(5) em AiConversationAnalysisSchema e
AiConversationSummarySchema; corrija MessageSchema tornando o campo content
obrigatório e proibindo string vazia (por exemplo trocar content:
z.string().max(5000).optional() por uma string não-vazia com min(1) e
max(5000)), mantendo outros campos opcionais conforme necessário, para que
mensagens sem texto não sejam aceitas.


/** Schema para sugestão de resposta (ai-suggest-reply) */
export const AiSuggestReplySchema = z.object({
messages: z.array(MessageSchema).max(50).optional(),
contactName: z.string().max(200).optional().default('Cliente'),
contactId: z.string().uuid().optional().nullable(),
context: z.string().max(500).optional(),
Comment on lines 22 to +26
});

export const AiEnhanceMessageSchema = z.object({
message: z.string().min(1, "Mensagem é obrigatória").max(4096),
tone: z.enum(['professional', 'casual', 'persuasive', 'empathetic', 'concise', 'detailed']).optional().default('professional'),
contactName: z.string().max(200).optional(),
});

export const AiConversationAnalysisSchema = z.object({
messages: z.array(MessageSchema).min(5, "Conversation must have at least 5 messages").max(200),
contactName: z.string().max(200).optional(),
contactId: z.string().uuid().optional().nullable(),
conversationHistory: z.array(z.object({
role: z.string(),
content: z.string(),
})),
context: z.string().optional(),
});

/** Helper para parse seguro */
export function parseBody<T>(schema: z.ZodSchema<T>, body: unknown) {
const result = schema.safeParse(body);
export const AiAutoTagSchema = z.object({
contactId: z.string().uuid().optional().nullable(),
messages: z.array(MessageSchema).max(100).optional(),
});

export const AiChurnAnalysisSchema = z.object({
contactIds: z.array(z.string().uuid()).min(1, "contactIds é obrigatório").max(50),
});

export const AiClassifyTicketsSchema = z.object({
limit: z.number().int().min(1).max(200).optional().default(50),
});

// ─── ElevenLabs schemas ──────────────────────────────────────
export const ElevenLabsTTSSchema = z.object({
text: z.string().min(1, "Text is required").max(5000),
voiceId: z.string().max(100).optional(),
modelId: z.string().max(100).optional(),
languageCode: z.string().max(10).optional(),
applyTextNormalization: z.string().max(20).optional(),
});

export const ElevenLabsSFXSchema = z.object({
prompt: z.string().min(1, "Prompt is required").max(2000),
duration: z.number().min(1).max(300).optional(),
mode: z.enum(['sfx', 'music']).optional().default('sfx'),
});

export const ElevenLabsDialogueSchema = z.object({
script: z.array(z.object({
voice_id: z.string().min(1, "voice_id is required").max(100),
text: z.string().min(1, "text is required").max(5000),
})).min(1, "Script is required"),
languageCode: z.string().max(10).optional().default('pt'),
});

export const ElevenLabsVoiceDesignPreviewSchema = z.object({
action: z.literal('preview').optional(),
description: z.string().min(1, "Voice description is required").max(1000),
text: z.string().max(2000).optional(),
});

export const ElevenLabsVoiceDesignCreateSchema = z.object({
action: z.literal('create'),
voice_name: z.string().min(1, "Voice name is required").max(255),
voice_description: z.string().max(1000).optional(),
generated_voice_id: z.string().min(1, "Generated voice ID is required").max(255),
labels: z.record(z.string()).optional(),
});

// ─── Transcription schemas ───────────────────────────────────
const ALLOWED_LANGUAGES = ['por', 'eng', 'spa', 'fra', 'deu', 'ita', 'jpn', 'kor', 'zho', 'ara', 'hin', 'rus'] as const;
export const TranscribeAudioSchema = z.object({
audioUrl: z.string().url("Invalid audio URL").max(2048),
messageId: z.string().max(100).optional(),
languageCode: z.enum(ALLOWED_LANGUAGES).optional().default('por'),
enableDiarization: z.boolean().optional().default(false),
tagAudioEvents: z.boolean().optional().default(true),
});
Comment on lines +93 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

As URLs remotas precisam ser restringidas.

z.string().url() só valida formato. Para functions que baixam áudio/imagem, isso permite apontar para hosts internos ou metadata se o handler fizer fetch() direto nesses campos. Pelo menos force https:; idealmente use allowlist dos domínios esperados.

Diff sugerido
+const RemoteAssetUrlSchema = z.string().url().max(2048).refine((value) => {
+  const url = new URL(value);
+  return url.protocol === "https:";
+}, "Only HTTPS URLs are allowed");
+
 export const TranscribeAudioSchema = z.object({
-  audioUrl: z.string().url("Invalid audio URL").max(2048),
+  audioUrl: RemoteAssetUrlSchema,
   messageId: z.string().max(100).optional(),
   languageCode: z.enum(ALLOWED_LANGUAGES).optional().default('por'),
   enableDiarization: z.boolean().optional().default(false),
   tagAudioEvents: z.boolean().optional().default(true),
 });
 
 export const ClassifyAudioMemeSchema = z.object({
-  audio_url: z.string().url().max(2048).optional().nullable(),
+  audio_url: RemoteAssetUrlSchema.optional().nullable(),
   file_name: z.string().max(500).optional().nullable(),
 });
 
 export const ClassifyEmojiSchema = z.object({
-  image_url: z.string().url().max(2048).optional().nullable(),
+  image_url: RemoteAssetUrlSchema.optional().nullable(),
   file_name: z.string().max(500).optional().nullable(),
 });
 
 export const ClassifyStickerSchema = z.object({
-  image_url: z.string().url().max(2048).optional().nullable(),
+  image_url: RemoteAssetUrlSchema.optional().nullable(),
 });

Also applies to: 102-114

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/_shared/schemas.ts` around lines 93 - 99, The audioUrl
field in TranscribeAudioSchema currently only checks URL format
(z.string().url()) and must be restricted to HTTPS and optionally to an
allowlist of domains; update the audioUrl validator on TranscribeAudioSchema
(audioUrl) to first validate URL format, then .refine(...) to require it starts
with "https://" (or check new URL(value).protocol === "https:"), and optionally
add a hostname allowlist check (parse new URL(value).hostname and assert it is
in the allowedDomains array) so internal hosts are rejected; apply the same
change to the other URL fields mentioned (the schemas around lines 102-114)
using the same pattern and clear error messages.


// ─── Classifier schemas ──────────────────────────────────────
export const ClassifyAudioMemeSchema = z.object({
audio_url: z.string().url().max(2048).optional().nullable(),
file_name: z.string().max(500).optional().nullable(),
});

export const ClassifyEmojiSchema = z.object({
image_url: z.string().url().max(2048).optional().nullable(),
file_name: z.string().max(500).optional().nullable(),
});

export const ClassifyStickerSchema = z.object({
image_url: z.string().url().max(2048).optional().nullable(),
});

// ─── Email schemas ───────────────────────────────────────────
export const SendEmailSchema = z.object({
to: z.union([z.string().email(), z.array(z.string().email()).min(1).max(50)]),
subject: z.string().min(1, "Subject is required").max(500),
html: z.string().max(100000).optional(),
text: z.string().max(100000).optional(),
from: z.string().max(255).optional(),
reply_to: z.string().email().optional(),
cc: z.array(z.string().email()).max(20).optional(),
bcc: z.array(z.string().email()).max(20).optional(),
attachments: z.array(z.object({
filename: z.string().max(255),
content: z.string(), // base64
content_type: z.string().max(100).optional(),
})).max(10).optional(),
});

// ─── Sentiment Alert ─────────────────────────────────────────
export const SentimentAlertSchema = z.object({
contactId: z.string().uuid(),
contactName: z.string().max(200),
sentimentScore: z.number().min(0).max(100),
previousScore: z.number().min(0).max(100).optional(),
analysisId: z.string().uuid(),
agentEmail: z.string().email().optional(),
threshold: z.number().min(0).max(100).optional().default(30),
consecutiveRequired: z.number().int().min(1).max(10).optional().default(2),
});

// ─── Rate Limit Alert ────────────────────────────────────────
export const RateLimitAlertSchema = z.object({
ip_address: z.string().max(45),
endpoint: z.string().max(500),
request_count: z.number().int().min(1),
blocked: z.boolean(),
});

// ─── Password Reset ──────────────────────────────────────────
export const ApprovePasswordResetSchema = z.object({
requestId: z.string().uuid("requestId must be a valid UUID"),
action: z.enum(["approve", "reject"]),
rejectionReason: z.string().max(500).optional(),
});

// ─── Conversation Analysis / Summary ─────────────────────────
export const AiConversationSummarySchema = z.object({
messages: z.array(MessageSchema).min(5, "Conversation must have at least 5 messages").max(200),
contactName: z.string().max(200).optional(),
contactId: z.string().uuid().optional().nullable(),
});

// ─── Chatbot L1 ──────────────────────────────────────────────
export const ChatbotL1Schema = z.object({
contactId: z.string().uuid("contactId must be a valid UUID"),

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 chatbot health-check payloads through validation

This validator now requires contactId to be a UUID, but the existing settings health check invokes chatbot-l1 with body: { contactId: 'test', message: 'Olá, teste de conexão', connectionId: 'test' } in src/components/settings/ChatbotL1Config.tsx:234-236. In that UI path the request will be rejected by parseBody before the function can report connectivity, so the “Testar Conexão” button always shows an error even when the edge function is reachable.

Useful? React with 👍 / 👎.

message: z.string().min(1, "Message is required").max(5000),
connectionId: z.string().uuid().optional().nullable(),
});

// ─── Device Detection ────────────────────────────────────────
export const DetectNewDeviceSchema = z.object({
device_fingerprint: z.string().min(1).max(500),
browser: z.string().max(200),
os: z.string().max(200),
device_name: z.string().max(200),
});

// ─── Scheduled Report ────────────────────────────────────────
export const ScheduledReportSchema = z.object({
reportId: z.string().uuid("reportId must be a valid UUID"),
});

// ─── Sicoob Bridge ───────────────────────────────────────────
export const SicoobBridgeNewMessageSchema = z.object({
action: z.literal('new_message'),
message_id: z.string().min(1).max(500),
sender_name: z.string().min(1).max(500),
sender_email: z.string().email().optional().nullable(),
sender_phone: z.string().max(50).optional().nullable(),
sender_id: z.string().max(500).optional(),
singular_name: z.string().max(500).optional(),
singular_id: z.string().min(1).max(500),
content: z.string().min(1).max(10000),
vendedor_user_id: z.string().min(1).max(500),
created_at: z.string().optional(),
});

export const SicoobBridgeMarkReadSchema = z.object({
action: z.literal('mark_read'),
external_ids: z.array(z.string()).min(1).max(500),
});

export const SicoobBridgeReplySchema = z.object({
contact_id: z.string().uuid("contact_id must be a valid UUID"),
content: z.string().min(1, "Content is required").max(10000),
message_id: z.string().optional(),
agent_id: z.string().uuid().optional().nullable(),
created_at: z.string().optional(),
});

// ─── Gmail Send ──────────────────────────────────────────────
export const GmailSendActionSchema = z.object({
action: z.enum(['send', 'reply', 'create-draft', 'modify-labels', 'mark-read', 'trash']),
account_id: z.string().uuid("account_id must be a valid UUID"),
to: z.union([z.string(), z.array(z.string())]).optional(),
cc: z.array(z.string()).optional(),
bcc: z.array(z.string()).optional(),
subject: z.string().max(1000).optional(),
text_body: z.string().max(100000).optional(),
html_body: z.string().max(500000).optional(),
thread_id: z.string().max(500).optional(),
message_id: z.string().max(500).optional(),
message_ids: z.array(z.string()).max(100).optional(),
add_labels: z.array(z.string()).max(50).optional(),
remove_labels: z.array(z.string()).max(50).optional(),
attachments: z.array(z.object({
filename: z.string().max(255),
mimeType: z.string().max(100),
content: z.string(), // base64
})).max(10).optional(),
});

// ─── Gmail OAuth ─────────────────────────────────────────────
export const GmailOAuthActionSchema = z.object({
action: z.enum(['get-auth-url', 'exchange-code', 'refresh-token', 'disconnect', 'list-accounts']),
code: z.string().max(2000).optional(),
account_id: z.string().uuid().optional(),
state: z.string().max(500).optional(),
});

// ─── WebAuthn ────────────────────────────────────────────────
export const WebAuthnActionSchema = z.object({
action: z.enum(['registration-options', 'verify-registration', 'authentication-options', 'verify-authentication']),
userId: z.string().uuid().optional(),
userEmail: z.string().email().optional(),
userName: z.string().max(200).optional(),
credential: z.record(z.unknown()).optional(),
friendlyName: z.string().max(200).optional(),
});

// ─── External DB Bridge ─────────────────────────────────────
export const ExternalDbBridgeSchema = z.object({
action: z.enum(['select', 'rpc', 'insert', 'update', 'delete']),
table: z.string().max(100).optional(),
rpc: z.string().max(100).optional(),
params: z.record(z.unknown()).optional(),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
countMode: z.string().max(20).optional(),
});
Comment on lines +256 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

As operações de DB externa não estão validadas por action.

Hoje payloads como { action: "delete" }, { action: "rpc" } ou { action: "update", table: "x" } passam na schema mesmo sem os campos mínimos da operação. Em proxy/bridge de banco isso troca erro de validação por erro em runtime — e, no caso destrutivo, deixa a segurança depender de defaults do handler.

Direção de ajuste
-export const ExternalDbBridgeSchema = z.object({
-  action: z.enum(['select', 'rpc', 'insert', 'update', 'delete']),
-  table: z.string().max(100).optional(),
-  rpc: z.string().max(100).optional(),
-  params: z.record(z.unknown()).optional(),
-  limit: z.number().int().min(1).max(1000).optional(),
-  offset: z.number().int().min(0).optional(),
-  countMode: z.string().max(20).optional(),
-});
+export const ExternalDbBridgeSchema = z.discriminatedUnion('action', [
+  z.object({
+    action: z.literal('select'),
+    table: z.string().max(100),
+    limit: z.number().int().min(1).max(1000).optional(),
+    offset: z.number().int().min(0).optional(),
+    countMode: z.string().max(20).optional(),
+  }),
+  z.object({
+    action: z.literal('rpc'),
+    rpc: z.string().max(100),
+    params: z.record(z.unknown()).optional(),
+  }),
+  z.object({
+    action: z.literal('insert'),
+    table: z.string().max(100),
+    params: z.record(z.unknown()),
+  }),
+  z.object({
+    action: z.literal('update'),
+    table: z.string().max(100),
+    params: z.record(z.unknown()),
+  }),
+  z.object({
+    action: z.literal('delete'),
+    table: z.string().max(100),
+    // exigir critério/filtro aqui
+  }),
+]);

A mesma ideia vale para ExternalDbProxySchema: tornar action obrigatório e exigir table/rpc/data/match|filters conforme cada variante.

Also applies to: 295-315

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/_shared/schemas.ts` around lines 256 - 264, A schema atual
aceita actions sem exigir os campos mínimos por operação; refatore
ExternalDbBridgeSchema para usar uma união discriminada baseada em action (por
exemplo via z.discriminatedUnion or z.union) e criar variantes específicas para
'select' (requere table plus opcional filters/limit/offset/countMode), 'rpc'
(requere rpc e params), 'insert' (requere table e params/data), 'update'
(requere table e params e/ou match) e 'delete' (requere table e match),
garantindo que cada variant valide os campos necessários (table, rpc, params,
match, etc.); aplique a mesma mudança para ExternalDbProxySchema (as variantes
nas linhas 295-315) para evitar validação permissiva que deixa erros aparecerem
só em runtime ou permite operações destrutivas sem campos obrigatórios.


// ─── Talk X Send ────────────────────────────────────────────
export const TalkXSendSchema = z.object({
campaignId: z.string().uuid("campaignId must be a valid UUID"),
action: z.enum(['send', 'pause', 'cancel']).optional().default('send'),
});

// ─── Voice Copilot Action ────────────────────────────────────
export const VoiceCopilotActionSchema = z.object({
action: z.enum([
'search_contacts', 'get_conversation_summary', 'get_dashboard_metrics',
'assign_conversation', 'create_note', 'list_agents', 'get_queue_status',
]),
params: z.record(z.unknown()).optional().default({}),
});

// ─── Evolution API ───────────────────────────────────────────
export const EvolutionApiSchema = z.object({
action: z.string().max(100).optional(),
instanceName: z.string().max(100).optional(),
instance: z.string().max(100).optional(),
number: z.string().max(50).optional(),
text: z.string().max(50000).optional(),
media: z.string().max(5000).optional(),
mediaUrl: z.string().max(5000).optional(),
audio: z.string().max(5000).optional(),
audioUrl: z.string().max(5000).optional(),
}).passthrough(); // Allow additional fields for the many action variants

// ─── External DB Proxy ──────────────────────────────────────
export const ExternalDbProxySchema = z.object({
action: z.enum(['rpc', 'insert', 'update', 'select']).optional(),
table: z.string().max(100).optional(),
select: z.string().max(2000).optional(),
rpc: z.string().max(100).optional(),
params: z.record(z.unknown()).optional(),
data: z.unknown().optional(),
match: z.record(z.string()).optional(),
filters: z.array(z.object({
column: z.string().max(100),
operator: z.string().max(20),
value: z.unknown(),
})).max(50).optional(),
order: z.object({
column: z.string().max(100),
ascending: z.boolean().optional(),
}).optional(),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
countMode: z.string().max(20).optional(),
});

// ─── Helper: parse body with schema ──────────────────────────
export function parseBody<T>(schema: z.ZodSchema<T>, data: unknown): { success: true; data: T } | { success: false; error: string } {
const result = schema.safeParse(data);
if (!result.success) {
return {
success: false,
error: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', '),
};
const errors = result.error.flatten();
const fieldErrors = Object.entries(errors.fieldErrors)
.map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`)
.join('; ');
const formErrors = errors.formErrors.join('; ');
return { success: false, error: [formErrors, fieldErrors].filter(Boolean).join('; ') };
}
return { success: true, data: result.data };
}
Loading