BEGIN; -- These helpers are used by RLS policies with auth.uid(). Historically an -- authenticated caller could invoke them with somebody else's UUID and learn -- that account's team ids. Preserve the policy signature while binding every -- authenticated invocation to the caller. CREATE OR REPLACE FUNCTION public.user_team_ids(uid uuid) RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ SELECT member.team_id FROM public.team_members AS member WHERE uid = auth.uid() AND member.user_id = auth.uid(); $$; CREATE OR REPLACE FUNCTION public.user_admin_team_ids(uid uuid) RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ SELECT member.team_id FROM public.team_members AS member WHERE uid = auth.uid() AND member.user_id = auth.uid() AND member.role IN ('owner', 'admin'); $$; REVOKE ALL ON FUNCTION public.user_team_ids(uuid) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.user_admin_team_ids(uuid) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.user_team_ids(uuid) TO authenticated; GRANT EXECUTE ON FUNCTION public.user_admin_team_ids(uuid) TO authenticated; -- Push delivery is initiated by an authenticated user but performed with the -- service role. This service-only ledger makes deduplication and limits atomic -- across Edge Function instances. CREATE TABLE public.push_dispatch_attempts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), caller_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, event_type text NOT NULL CHECK (event_type IN ( 'transcription.completed', 'team.invite.created', 'billing.status.changed' )), resource_id uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_push_dispatch_attempts_caller_created ON public.push_dispatch_attempts(caller_id, created_at DESC); CREATE INDEX idx_push_dispatch_attempts_event_resource_created ON public.push_dispatch_attempts(caller_id, event_type, resource_id, created_at DESC); ALTER TABLE public.push_dispatch_attempts ENABLE ROW LEVEL SECURITY; CREATE OR REPLACE FUNCTION public.reserve_push_dispatch( push_event_type text, push_resource_id uuid ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); normalized_event_type text := lower(btrim(push_event_type)); existing_attempt_id uuid; created_attempt_id uuid; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF push_resource_id IS NULL OR normalized_event_type NOT IN ( 'transcription.completed', 'team.invite.created', 'billing.status.changed' ) THEN RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023'; END IF; -- A caller-scoped lock serializes both aggregate limits and per-event -- deduplication, including concurrent requests handled by different workers. PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73048)); SELECT attempt.id INTO existing_attempt_id FROM public.push_dispatch_attempts AS attempt WHERE attempt.caller_id = current_user_id AND attempt.event_type = normalized_event_type AND attempt.resource_id = push_resource_id AND attempt.created_at > now() - interval '30 seconds' ORDER BY attempt.created_at DESC LIMIT 1; IF existing_attempt_id IS NOT NULL THEN RETURN jsonb_build_object( 'reserved', false, 'duplicate', true, 'attempt_id', existing_attempt_id ); END IF; IF ( SELECT count(*) FROM public.push_dispatch_attempts AS attempt WHERE attempt.caller_id = current_user_id AND attempt.created_at > now() - interval '1 minute' ) >= 10 OR ( SELECT count(*) FROM public.push_dispatch_attempts AS attempt WHERE attempt.caller_id = current_user_id AND attempt.created_at > now() - interval '24 hours' ) >= 100 THEN RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000'; END IF; INSERT INTO public.push_dispatch_attempts (caller_id, event_type, resource_id) VALUES (current_user_id, normalized_event_type, push_resource_id) RETURNING id INTO created_attempt_id; RETURN jsonb_build_object( 'reserved', true, 'duplicate', false, 'attempt_id', created_attempt_id ); END; $$; -- Invite recipient resolution must cross the auth schema, which PostgREST -- clients cannot join directly. Only the inviter of an active invite may ask. CREATE OR REPLACE FUNCTION public.resolve_team_invite_recipient(invite_id uuid) RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); recipient_id uuid; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF invite_id IS NULL THEN RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023'; END IF; SELECT account.id INTO recipient_id FROM public.team_invites AS invite JOIN auth.users AS account ON lower(account.email) = lower(invite.email) WHERE invite.id = invite_id AND invite.invited_by = current_user_id AND invite.accepted_at IS NULL AND invite.expires_at > now() ORDER BY account.created_at, account.id LIMIT 1; RETURN recipient_id; END; $$; REVOKE ALL ON TABLE public.push_dispatch_attempts FROM PUBLIC, anon, authenticated; GRANT ALL ON TABLE public.push_dispatch_attempts TO service_role; REVOKE ALL ON FUNCTION public.reserve_push_dispatch(text, uuid) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.resolve_team_invite_recipient(uuid) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.reserve_push_dispatch(text, uuid) TO authenticated; GRANT EXECUTE ON FUNCTION public.resolve_team_invite_recipient(uuid) TO authenticated; COMMIT;