-
Notifications
You must be signed in to change notification settings - Fork 0
Harden Evolution webhook: HMAC, LOGOUT handler, groups persistence (S0) #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,70 +5,172 @@ import { log } from '@/lib/logger'; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface CallApiOptions { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method?: HttpMethod; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Max attempts including the first one. Default: 3 for idempotent verbs, 1 for POST unless `idempotencyKey` is set. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| retries?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Milliseconds for the first backoff. Default: 250ms; doubles each attempt. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| baseBackoffMs?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Overall per-request timeout. Default: 30s. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeoutMs?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** When present, POST requests become retriable and dedup'd by this key (recommended for sends). */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| idempotencyKey?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const IDEMPOTENT_METHODS = new Set<HttpMethod>(['GET']); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface EvolutionApiError extends Error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| details?: unknown; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apiStatus?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| retries?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| retryAfterMs?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function isRetriableStatus(status?: number): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (status == null) return true; // network / unknown: retry | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (status === 408 || status === 425 || status === 429) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (status >= 500 && status < 600) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function parseRetryAfter(raw: unknown): number | undefined { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof raw !== 'string' && typeof raw !== 'number') return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const n = Number(raw); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (Number.isFinite(n) && n >= 0) return Math.floor(n * 1000); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const date = Date.parse(String(raw)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isNaN(date)) return Math.max(0, date - Date.now()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function sleep(ms: number, signal?: AbortSignal): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new Promise((resolve, reject) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const timer = setTimeout(resolve, ms); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (signal) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const onAbort = () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (signal.aborted) return onAbort(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| signal.addEventListener('abort', onAbort, { once: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function useEvolutionApiCore() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [isLoading, setIsLoading] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const mountedRef = useRef(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inflightRef = useRef<Map<string, Promise<any>>>(new Map()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inflightRef = useRef<Map<string, Promise<unknown>>>(new Map()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mountedRef.current = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return () => { mountedRef.current = false; }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const callApi = useCallback(async (action: string, body?: object, method: HttpMethod = 'POST'): Promise<any> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dedupeKey = method === 'GET' ? `${action}:${JSON.stringify(body || {})}` : ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (dedupeKey && inflightRef.current.has(dedupeKey)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return inflightRef.current.get(dedupeKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const callApi = useCallback( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async <T = unknown>(action: string, body?: object, methodOrOptions: HttpMethod | CallApiOptions = 'POST'): Promise<T> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const opts: CallApiOptions = typeof methodOrOptions === 'string' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? { method: methodOrOptions } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : methodOrOptions; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const method: HttpMethod = opts.method ?? 'POST'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const baseBackoffMs = opts.baseBackoffMs ?? 250; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const timeoutMs = opts.timeoutMs ?? 30_000; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const canRetry = IDEMPOTENT_METHODS.has(method) || !!opts.idempotencyKey; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const retries = Math.max(1, opts.retries ?? (canRetry ? 3 : 1)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Dedupe identical in-flight requests for idempotent verbs OR any POST with an idempotencyKey. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dedupeKey = canRetry | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? `${method}:${action}:${opts.idempotencyKey ?? JSON.stringify(body ?? {})}` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (dedupeKey) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const existing = inflightRef.current.get(dedupeKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (existing) return existing as Promise<T>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (mountedRef.current) setIsLoading(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (mountedRef.current) setIsLoading(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const promise = (async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { data, error } = await supabase.functions.invoke(`evolution-api/${action}`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: body ?? {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (error) throw error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (data && typeof data === 'object' && data.error === true) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const apiError = Object.assign( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new Error(data.message || 'Erro na API Evolution'), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { details: data.details, apiStatus: data.status, retries: data.retries } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw apiError; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const run = (async (): Promise<T> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let attempt = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let lastError: EvolutionApiError | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while (attempt < retries) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attempt++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const controller = new AbortController(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const timeoutId = setTimeout(() => controller.abort(), timeoutMs); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const invokeOpts: { method: 'POST'; body: object; headers?: Record<string, string>; signal?: AbortSignal } = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: body ?? {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| signal: controller.signal, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (opts.idempotencyKey) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| invokeOpts.headers = { 'Idempotency-Key': opts.idempotencyKey }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { data, error } = await supabase.functions.invoke(`evolution-api/${action}`, invokeOpts); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+94
to
+106
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), timeoutMs); | |
| try { | |
| const invokeOpts: { method: 'POST'; body: object; headers?: Record<string, string>; signal?: AbortSignal } = { | |
| method: 'POST', | |
| body: body ?? {}, | |
| signal: controller.signal, | |
| }; | |
| if (opts.idempotencyKey) { | |
| invokeOpts.headers = { 'Idempotency-Key': opts.idempotencyKey }; | |
| } | |
| const { data, error } = await supabase.functions.invoke(`evolution-api/${action}`, invokeOpts); | |
| let timeoutId: ReturnType<typeof setTimeout> | undefined; | |
| try { | |
| const invokeOpts: { method: 'POST'; body: object; headers?: Record<string, string> } = { | |
| method: 'POST', | |
| body: body ?? {}, | |
| }; | |
| if (opts.idempotencyKey) { | |
| invokeOpts.headers = { 'Idempotency-Key': opts.idempotencyKey }; | |
| } | |
| const timeoutPromise = new Promise<never>((_, reject) => { | |
| timeoutId = setTimeout(() => { | |
| const timeoutError = Object.assign(new Error('Evolution API request timed out'), { | |
| apiStatus: 408, | |
| }) as EvolutionApiError; | |
| reject(timeoutError); | |
| }, timeoutMs); | |
| }); | |
| const { data, error } = await Promise.race([ | |
| supabase.functions.invoke(`evolution-api/${action}`, invokeOpts), | |
| timeoutPromise, | |
| ]); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,56 @@ export function normalizeEventName(event?: string): string { | |||||||||||||||||||||||||||||||||||||||||
| return (event || '').trim().toLowerCase().replace(/_/g, '.'); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Redacts phone/JID for logs: keeps country+area code, masks the rest. | ||||||||||||||||||||||||||||||||||||||||||
| // "5511998765432@s.whatsapp.net" -> "551199***" | ||||||||||||||||||||||||||||||||||||||||||
| export function redactJid(jid?: string | null): string { | ||||||||||||||||||||||||||||||||||||||||||
| if (!jid) return ''; | ||||||||||||||||||||||||||||||||||||||||||
| const raw = String(jid).split('@')[0].replace(/:\d+$/, ''); | ||||||||||||||||||||||||||||||||||||||||||
| if (raw.length <= 6) return raw.replace(/.(?=.{0})/g, '*'); | ||||||||||||||||||||||||||||||||||||||||||
| return `${raw.slice(0, 6)}***`; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export function generateRequestId(): string { | ||||||||||||||||||||||||||||||||||||||||||
| try { return crypto.randomUUID(); } catch { return `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+31
to
+32
|
||||||||||||||||||||||||||||||||||||||||||
| export function generateRequestId(): string { | |
| try { return crypto.randomUUID(); } catch { return `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; } | |
| function generateUuidV4Fallback(): string { | |
| const bytes = new Uint8Array(16); | |
| crypto.getRandomValues(bytes); | |
| // Set version (4) and variant (RFC4122) bits. | |
| bytes[6] = (bytes[6] & 0x0f) | 0x40; | |
| bytes[8] = (bytes[8] & 0x3f) | 0x80; | |
| const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); | |
| return `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-${hex[4]}${hex[5]}-${hex[6]}${hex[7]}-${hex[8]}${hex[9]}-${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`; | |
| } | |
| export function generateRequestId(): string { | |
| try { | |
| return crypto.randomUUID(); | |
| } catch { | |
| return generateUuidV4Fallback(); | |
| } |
Copilot
AI
Apr 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
auditWebhookEvent() ignores the PostgREST response and only logs on thrown exceptions. In supabase-js, most insert failures (e.g., constraint/type errors) are returned as { error } without throwing, so audit writes can fail silently. Consider capturing the { error } result and logging it when present to avoid losing observability.
| try { await supabase.from('webhook_audit_log').insert(row); } catch (e) { | |
| try { | |
| const { error } = await supabase.from('webhook_audit_log').insert(row); | |
| if (!error) return; | |
| console.warn('[audit] insert failed:', error.message ?? error.code ?? String(error)); | |
| } catch (e) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CI now runs
npm installwithout any committed npm lockfile (there is a bun.lock, but npm doesn’t use it). This makes dependency resolution non-deterministic across runs and can cause flaky CI results. Consider either committing a package-lock.json and usingnpm ci, or switching CI tobun install/bun testto align with bun.lock (and re-enabling dependency caching).