From 55d105777eda8c07946956d8c77f281a3fbafc4b Mon Sep 17 00:00:00 2001 From: Felipe Domingues <53182096+domfelipe@users.noreply.github.com> Date: Thu, 28 May 2026 19:21:11 -0300 Subject: [PATCH] fix: harden default skill quota (#5) --- supabase/functions/_shared/default-skills.ts | 48 ++++++++- ...60528190839_harden_default_skill_quota.sql | 100 ++++++++++++++++++ 2 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/20260528190839_harden_default_skill_quota.sql diff --git a/supabase/functions/_shared/default-skills.ts b/supabase/functions/_shared/default-skills.ts index cd30e10..1efc0ed 100644 --- a/supabase/functions/_shared/default-skills.ts +++ b/supabase/functions/_shared/default-skills.ts @@ -17,6 +17,20 @@ type GenericDatabase = { type SupabaseAdminClient = ReturnType>; +type ExistingSkillRow = { + id: string; + current_version_id: string | null; + is_default: boolean | null; +}; + +async function deleteSkillAfterSeedFailure( + supabase: SupabaseAdminClient, + skillId: string, +): Promise { + const { error } = await supabase.from("skills").delete().eq("id", skillId); + return error?.message ?? null; +} + export interface DefaultSkillsEnsureResult { agent_instance_id: string; created_count: number; @@ -218,13 +232,14 @@ export async function ensureDefaultSkillsForAgent( } for (const template of DEFAULT_HERMES_SKILLS) { - const { data: existing, error: existingErr } = await supabase + const { data: existingData, error: existingErr } = await supabase .from("skills") - .select("id") + .select("id, current_version_id, is_default") .eq("user_id", agent.user_id) .eq("name", template.name) .neq("status", "archived") .maybeSingle(); + const existing = existingData as ExistingSkillRow | null; if (existingErr) { result.errors.push( @@ -234,8 +249,21 @@ export async function ensureDefaultSkillsForAgent( } if (existing) { - result.skipped_count += 1; - continue; + if (existing.is_default && !existing.current_version_id) { + const { error: deleteErr } = await supabase.from("skills").delete().eq( + "id", + existing.id, + ); + if (deleteErr) { + result.errors.push( + `${template.name}: failed to replace incomplete default skill (${deleteErr.message})`, + ); + continue; + } + } else { + result.skipped_count += 1; + continue; + } } const { data: skillData, error: skillErr } = await supabase @@ -279,6 +307,12 @@ export async function ensureDefaultSkillsForAgent( result.errors.push( `${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`, ); + const cleanupErr = await deleteSkillAfterSeedFailure(supabase, skill.id); + if (cleanupErr) { + result.errors.push( + `${template.name}: failed to clean up skill after version failure (${cleanupErr})`, + ); + } continue; } @@ -293,6 +327,12 @@ export async function ensureDefaultSkillsForAgent( if (updateErr) { result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`); + const cleanupErr = await deleteSkillAfterSeedFailure(supabase, skill.id); + if (cleanupErr) { + result.errors.push( + `${template.name}: failed to clean up skill after publish failure (${cleanupErr})`, + ); + } continue; } diff --git a/supabase/migrations/20260528190839_harden_default_skill_quota.sql b/supabase/migrations/20260528190839_harden_default_skill_quota.sql new file mode 100644 index 0000000..917fbbd --- /dev/null +++ b/supabase/migrations/20260528190839_harden_default_skill_quota.sql @@ -0,0 +1,100 @@ +-- Harden default skills so platform-provided skills do not weaken quota/RLS guarantees. + +CREATE OR REPLACE VIEW public.user_skill_limits +WITH (security_invoker = true) +AS +SELECT + p.id AS user_id, + pl.slug AS plan_slug, + CASE + WHEN pl.slug IS NULL THEN NULL::integer + WHEN pl.slug = 'basic' THEN 5 + WHEN pl.slug = 'starter' THEN 15 + WHEN pl.slug = 'professional' THEN 50 + WHEN pl.slug = 'enterprise' THEN 999999 + ELSE NULL::integer + END AS max_skills, + ( + SELECT count(*)::integer + FROM public.skills sk + WHERE sk.user_id = p.id + AND sk.status <> 'archived' + AND COALESCE(sk.is_default, false) = false + ) AS current_skills_count +FROM public.profiles p +LEFT JOIN public.subscriptions s + ON s.user_id = p.id + AND s.status = ANY (ARRAY['active'::text, 'trialing'::text]) +LEFT JOIN public.plans pl ON pl.id = s.plan_id; + +GRANT SELECT ON public.user_skill_limits TO authenticated; +GRANT SELECT ON public.user_skill_limits TO service_role; + +CREATE OR REPLACE FUNCTION public.enforce_skill_limit() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_max int; + v_count int; + v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), ''); +BEGIN + IF COALESCE(NEW.is_default, false) THEN + IF v_request_role = 'service_role' THEN + RETURN NEW; + END IF; + + NEW.is_default := false; + END IF; + + SELECT max_skills, current_skills_count + INTO v_max, v_count + FROM public.user_skill_limits + WHERE user_id = NEW.user_id; + + IF v_max IS NULL THEN + RAISE EXCEPTION 'Sem assinatura ativa para criar skills' + USING ERRCODE = 'P0001'; + END IF; + + IF v_count >= v_max THEN + RAISE EXCEPTION 'Limite de skills atingido para o plano' + USING ERRCODE = 'P0002'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.protect_skill_default_flag() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), ''); +BEGIN + IF v_request_role = 'service_role' THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' THEN + NEW.is_default := false; + ELSIF TG_OP = 'UPDATE' THEN + NEW.is_default := OLD.is_default; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trigger_protect_skill_default_flag ON public.skills; +CREATE TRIGGER trigger_protect_skill_default_flag + BEFORE INSERT OR UPDATE ON public.skills + FOR EACH ROW EXECUTE FUNCTION public.protect_skill_default_flag(); + +REVOKE EXECUTE ON FUNCTION public.enforce_skill_limit() FROM anon, authenticated, public; +REVOKE EXECUTE ON FUNCTION public.protect_skill_default_flag() FROM anon, authenticated, public;