import { createClient } from 'npm:@supabase/supabase-js@2'; const ALLOWED_ORIGINS = [ 'https://supa.supersamsev.ru', 'https://dost.supersamsev.ru', 'http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000', 'https://supasevdev.mkn8n.ru', ]; export function createServiceClient() { const supabaseUrl = Deno.env.get('SUPABASE_URL') || ''; const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || ''; return createClient(supabaseUrl, serviceRoleKey); } export function getClientIp(request: Request): string { const xff = request.headers.get('x-forwarded-for'); if (xff) return xff.split(',')[0].trim(); return request.headers.get('x-real-ip') || 'unknown'; } export function getCorsHeaders(request: Request, _access: 'public' | 'private') { const origin = request.headers.get('origin') || ''; if (!origin) { return { 'Access-Control-Allow-Origin': ALLOWED_ORIGINS[0], 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', 'Access-Control-Max-Age': '86400', }; } const allowed = ALLOWED_ORIGINS.includes(origin); if (!allowed) return null; return { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', 'Access-Control-Max-Age': '86400', }; } export function preflightResponse(request: Request, access: 'public' | 'private') { const corsHeaders = getCorsHeaders(request, access); if (!corsHeaders) { return new Response('Origin not allowed', { status: 403 }); } return new Response(null, { status: 204, headers: corsHeaders }); } export function jsonResponse(body: unknown, status = 200, corsHeaders?: Record) { const headers: Record = { 'Content-Type': 'application/json' }; if (corsHeaders) Object.assign(headers, corsHeaders); return new Response(JSON.stringify(body), { status, headers }); } export async function hashText(text: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(text); const hashBuffer = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } interface JsonBodyResult { body: T; } export async function readJsonBody(request: Request, options?: { maxBytes?: number }): Promise> { const maxBytes = options?.maxBytes ?? 1024 * 1024; const reader = request.body?.getReader(); if (!reader) throw new Error('No body'); const chunks: Uint8Array[] = []; let totalBytes = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; totalBytes += value.length; if (totalBytes > maxBytes) { reader.cancel(); throw Object.assign(new Error('Request body too large'), { status: 413 }); } chunks.push(value); } const combined = new Uint8Array(totalBytes); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } const text = new TextDecoder().decode(combined); const body = JSON.parse(text) as T; return { body }; } interface RateLimitOptions { scope: string; key: string; maxCount: number; windowSeconds: number; blockSeconds: number; } class RateLimitError extends Error { status: number; constructor(message: string, status: number) { super(message); this.status = status; } } export async function requireRateLimit(supabase: ReturnType, options: RateLimitOptions) { const { scope, key, maxCount, windowSeconds, blockSeconds } = options; // Use atomic SQL function to avoid race conditions const { data, error } = await supabase.rpc('check_rate_limit', { p_scope: scope, p_key: key, p_max_count: maxCount, p_window_seconds: windowSeconds, p_block_seconds: blockSeconds, }); if (error) { console.error('Rate limit check error:', error); return; } if (data && !data.allowed) { throw new RateLimitError('Too many requests. Please try again later.', 429); } } // ── UUID validation ────────────────────────────────────────────────────── const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function isValidUuid(value: unknown): value is string { return typeof value === 'string' && UUID_REGEX.test(value.trim()); } export function requireUuid(value: unknown, fieldName = 'id'): string { if (!isValidUuid(value)) { throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 }); } return (value as string).trim(); } // ── PII masking ────────────────────────────────────────────────────────── export function maskCustomerName(name: string | null | undefined): string | null { if (!name || typeof name !== 'string') return null; const trimmed = name.trim(); if (!trimmed) return null; const firstChar = trimmed[0]; return `${firstChar}.`; } export function maskPhoneNumber(phone: string | null | undefined): string | null { if (!phone || typeof phone !== 'string') return null; const trimmed = phone.trim(); if (!trimmed) return null; if (trimmed.length < 4) return trimmed; return `+7 *** ***-${trimmed.slice(-2)}`; } // ── Same-origin / CSRF check ───────────────────────────────────────────── export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean { const origin = request.headers.get('origin') || ''; if (!origin) return false; return allowedOrigins.includes(origin); } // ── Internal request verification (HMAC or shared secret) ──────────────── interface VerifyInternalOptions { rawBody?: Uint8Array | string; secretEnvNames?: string[]; tokenEnvNames?: string[]; allowedClockSkewSeconds?: number; } export async function verifyInternalRequest( request: Request, _rawBody: Uint8Array | string | undefined, options: VerifyInternalOptions = {}, ): Promise<{ authenticated: boolean; authenticatedBy: string }> { const { secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'], tokenEnvNames = ['INTEGRATION_API_KEY'], } = options; // Check for shared secret via header const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || ''; for (const envName of secretEnvNames) { const secret = Deno.env.get(envName); if (secret && authHeader && authHeader === secret) { return { authenticated: true, authenticatedBy: envName }; } } // Check for API token via header const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || ''; for (const envName of tokenEnvNames) { const token = Deno.env.get(envName); if (token && tokenHeader && tokenHeader === token) { return { authenticated: true, authenticatedBy: envName }; } } // No valid credentials found throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 }); }