-- Security hardening migration for OTP, public delivery invitations, and Edge Function rate limits. -- Run this file once in Supabase SQL Editor before deploying the updated Edge Functions. create extension if not exists pgcrypto; create table if not exists public.rate_limits ( id uuid primary key default gen_random_uuid(), scope text not null, rate_key text not null, window_start timestamptz not null, count integer not null default 1, blocked_until timestamptz, created_at timestamptz not null default timezone('utc', now()), updated_at timestamptz not null default timezone('utc', now()), unique (scope, rate_key, window_start) ); alter table public.delivery_invitations add column if not exists expires_at timestamptz; alter table public.delivery_invitations add column if not exists revoked_at timestamptz; alter table public.delivery_invitations add column if not exists access_count integer not null default 0; alter table public.delivery_invitations add column if not exists last_accessed_at timestamptz; create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at); create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc); create index if not exists idx_rate_limits_blocked_until on public.rate_limits (blocked_until); create or replace function public.check_rate_limit( p_scope text, p_key text, p_max_count integer, p_window_seconds integer, p_block_seconds integer default 0 ) returns table ( allowed boolean, current_count integer, limit_count integer, blocked_until timestamptz, window_start timestamptz ) language plpgsql security definer set search_path = public as $function$ declare v_now timestamptz := timezone('utc', now()); v_window_start timestamptz; v_count integer; v_blocked_until timestamptz; begin if p_max_count <= 0 then raise exception 'max_count must be positive'; end if; if p_window_seconds <= 0 then raise exception 'window_seconds must be positive'; end if; v_window_start := to_timestamp( floor(extract(epoch from v_now) / p_window_seconds) * p_window_seconds ); select rl.blocked_until into v_blocked_until from public.rate_limits rl where rl.scope = p_scope and rl.rate_key = p_key and rl.blocked_until is not null and rl.blocked_until > v_now order by rl.blocked_until desc limit 1; if v_blocked_until is not null then return query select false, 0, p_max_count, v_blocked_until, v_window_start; return; end if; insert into public.rate_limits (scope, rate_key, window_start, count, blocked_until) values (p_scope, p_key, v_window_start, 1, null) on conflict (scope, rate_key, window_start) do update set count = public.rate_limits.count + 1, blocked_until = case when public.rate_limits.count + 1 > p_max_count and p_block_seconds > 0 then greatest( coalesce(public.rate_limits.blocked_until, v_now), v_now + make_interval(secs => p_block_seconds) ) else public.rate_limits.blocked_until end, updated_at = v_now returning count, blocked_until into v_count, v_blocked_until; return query select v_count <= p_max_count and (v_blocked_until is null or v_blocked_until <= v_now), v_count, p_max_count, v_blocked_until, v_window_start; end; $function$; alter table public.rate_limits enable row level security; drop policy if exists "rate limits admin only" on public.rate_limits; create policy "rate limits admin only" on public.rate_limits for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); drop policy if exists "users self or admin" on public.users; create policy "users self or admin" on public.users for select using (public.current_role_name() = 'admin' or id = auth.uid());