mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:16:42 +00:00
fix: harden default skill quota
This commit is contained in:
parent
2782e0d182
commit
bb71660864
2 changed files with 144 additions and 4 deletions
|
|
@ -17,6 +17,20 @@ type GenericDatabase = {
|
||||||
|
|
||||||
type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
|
type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
|
||||||
|
|
||||||
|
type ExistingSkillRow = {
|
||||||
|
id: string;
|
||||||
|
current_version_id: string | null;
|
||||||
|
is_default: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteSkillAfterSeedFailure(
|
||||||
|
supabase: SupabaseAdminClient,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { error } = await supabase.from("skills").delete().eq("id", skillId);
|
||||||
|
return error?.message ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DefaultSkillsEnsureResult {
|
export interface DefaultSkillsEnsureResult {
|
||||||
agent_instance_id: string;
|
agent_instance_id: string;
|
||||||
created_count: number;
|
created_count: number;
|
||||||
|
|
@ -218,13 +232,14 @@ export async function ensureDefaultSkillsForAgent(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const template of DEFAULT_HERMES_SKILLS) {
|
for (const template of DEFAULT_HERMES_SKILLS) {
|
||||||
const { data: existing, error: existingErr } = await supabase
|
const { data: existingData, error: existingErr } = await supabase
|
||||||
.from("skills")
|
.from("skills")
|
||||||
.select("id")
|
.select("id, current_version_id, is_default")
|
||||||
.eq("user_id", agent.user_id)
|
.eq("user_id", agent.user_id)
|
||||||
.eq("name", template.name)
|
.eq("name", template.name)
|
||||||
.neq("status", "archived")
|
.neq("status", "archived")
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
const existing = existingData as ExistingSkillRow | null;
|
||||||
|
|
||||||
if (existingErr) {
|
if (existingErr) {
|
||||||
result.errors.push(
|
result.errors.push(
|
||||||
|
|
@ -234,8 +249,21 @@ export async function ensureDefaultSkillsForAgent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
result.skipped_count += 1;
|
if (existing.is_default && !existing.current_version_id) {
|
||||||
continue;
|
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
|
const { data: skillData, error: skillErr } = await supabase
|
||||||
|
|
@ -279,6 +307,12 @@ export async function ensureDefaultSkillsForAgent(
|
||||||
result.errors.push(
|
result.errors.push(
|
||||||
`${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`,
|
`${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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,6 +327,12 @@ export async function ensureDefaultSkillsForAgent(
|
||||||
|
|
||||||
if (updateErr) {
|
if (updateErr) {
|
||||||
result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue