From 95faf58e757377b8bc62077b1a6bb1bbc7d408b1 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Tue, 7 Jul 2026 10:43:03 +0300 Subject: [PATCH] fix(ai): grant EXECUTE on claim_ai_reply_slot so auto-reply works (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 029 created claim_ai_reply_slot(uuid, integer) as SECURITY DEFINER but never GRANTed EXECUTE — the only function in the schema missing its grant (007/012/017/018/019/025/030 all grant theirs). SECURITY DEFINER sets the privileges a function runs *with*, not who may *call* it: the caller still needs EXECUTE. On instances where the default PUBLIC execute privilege on public-schema functions has been revoked (hardened / self-hosted Supabase), the service_role that PostgREST uses cannot invoke it. The AI auto-reply path runs entirely under the service-role client (the inbound webhook has no auth.uid()), so db.rpc('claim_ai_reply_slot') fails permission-denied, the dispatch bails before sending, and the bot silently never answers ANY inbound message. The Playground works because it never claims a slot — matching the report. - 031_ai_reply_slot_grant.sql: new migration granting EXECUTE to service_role (029 is already applied on existing installs, so it needs its own migration to reach them). service_role only — the sole caller — matching the 007/012 increment-counter precedent. - 029_ai_reply.sql: add the same grant next to the definition so fresh installs are correct. - auto-reply.ts: log a real claim RPC error instead of returning silently, so this class of deploy failure is diagnosable. Co-Authored-By: Claude Opus 4.8 --- src/lib/ai/auto-reply.ts | 10 ++++++- supabase/migrations/029_ai_reply.sql | 10 +++++++ .../migrations/031_ai_reply_slot_grant.sql | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/031_ai_reply_slot_grant.sql diff --git a/src/lib/ai/auto-reply.ts b/src/lib/ai/auto-reply.ts index cfbfa66..4b50f1c 100644 --- a/src/lib/ai/auto-reply.ts +++ b/src/lib/ai/auto-reply.ts @@ -122,7 +122,15 @@ export async function dispatchInboundToAiReply( max_replies: config.autoReplyMaxPerConversation, }, ) - if (claimErr || claimed !== true) return + if (claimErr) { + // A real error here (vs. losing the cap race) is almost always a + // deploy issue — e.g. `claim_ai_reply_slot` not EXECUTE-able by the + // service role, or the migration not applied. Log it loudly: a + // silent return makes "auto-reply never fires" undiagnosable. + console.error('[ai auto-reply] claim_ai_reply_slot failed:', claimErr) + return + } + if (claimed !== true) return // lost the per-conversation cap race await engineSendText({ accountId, diff --git a/supabase/migrations/029_ai_reply.sql b/supabase/migrations/029_ai_reply.sql index 168919a..9fef702 100644 --- a/supabase/migrations/029_ai_reply.sql +++ b/supabase/migrations/029_ai_reply.sql @@ -129,3 +129,13 @@ RETURNS boolean AS $$ ) SELECT EXISTS (SELECT 1 FROM claimed); $$ LANGUAGE sql SECURITY DEFINER SET search_path = public; + +-- The auto-reply bot claims slots under the service-role client (the +-- inbound webhook has no auth.uid()), so it needs EXECUTE. SECURITY +-- DEFINER alone is not enough — it sets the privileges the function runs +-- *with*, not who may call it. Without this grant the RPC fails with +-- permission-denied on instances where the default PUBLIC execute +-- privilege has been revoked (hardened / self-hosted Supabase), and the +-- bot silently never replies. Only the service role claims slots, so we +-- grant to it alone (mirrors 007 / 012). See migration 031 / issue #345. +GRANT EXECUTE ON FUNCTION public.claim_ai_reply_slot(uuid, integer) TO service_role; diff --git a/supabase/migrations/031_ai_reply_slot_grant.sql b/supabase/migrations/031_ai_reply_slot_grant.sql new file mode 100644 index 0000000..42fea6e --- /dev/null +++ b/supabase/migrations/031_ai_reply_slot_grant.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- 031_ai_reply_slot_grant.sql — fix: AI auto-reply never fires +-- +-- Migration 029 created `claim_ai_reply_slot(uuid, integer)` as a +-- SECURITY DEFINER function but never GRANTed EXECUTE on it — the only +-- function in the schema missing its grant (cf. 007, 012, 018, 019, +-- 025, 030, which all grant EXECUTE explicitly). +-- +-- SECURITY DEFINER changes the privileges a function runs *with*, not +-- who may *call* it: the caller still needs EXECUTE. On Postgres +-- instances where the default PUBLIC execute privilege on public-schema +-- functions has been revoked (standard on hardened / self-hosted +-- Supabase), `service_role` therefore cannot invoke it. The AI +-- auto-reply path runs entirely under the service-role client (the +-- inbound webhook has no auth.uid()), so `db.rpc('claim_ai_reply_slot')` +-- fails with permission-denied, the caller bails before sending, and the +-- bot silently never answers ANY inbound message — while the Playground +-- (which never claims a slot) keeps working. See issue #345. +-- +-- Only the service role ever claims a slot, so we grant to it alone — +-- matching the increment-counter precedent in 007 / 012, and never +-- exposing a counter-mutating function to end users. +-- +-- Idempotent — GRANT is a no-op when the privilege already exists. +-- ============================================================ + +GRANT EXECUTE ON FUNCTION public.claim_ai_reply_slot(uuid, integer) TO service_role;