diff --git a/src/test/smoke/fleety-discord-ratelimit.smoke.test.ts b/src/test/smoke/fleety-discord-ratelimit.smoke.test.ts new file mode 100644 index 00000000..368c1a04 --- /dev/null +++ b/src/test/smoke/fleety-discord-ratelimit.smoke.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(__dirname, "../../.."); +const read = (p: string) => readFileSync(resolve(root, p), "utf8"); + +/** + * Regression guard for the Discord /fleety outage (2026-08): the adapter's rate limiter called + * the AUTH-ONLY check_rate_limit, which rejects any identifier that isn't a 64-char SHA-256 hash + * and any action outside its login/signup/reset whitelist. Passing `discord:` + "fleety" made + * it return {allowed:false} on EVERY call, so /fleety only ever posted "you've asked a lot in the + * last hour" and never answered — for days. It must use the GENERIC check_edge_rate_limit. + */ +describe("discord /fleety uses the generic edge rate limiter", () => { + const src = read("supabase/functions/discord-interactions/index.ts"); + + it("calls check_edge_rate_limit (generic, accepts any identifier/action)", () => { + expect(src).toMatch(/rpc\(\s*["']check_edge_rate_limit["']/); + }); + + it("does NOT call the auth-only check_rate_limit (it would block every /fleety)", () => { + expect(src).not.toMatch(/rpc\(\s*["']check_rate_limit["']/); + }); + + it("still fails OPEN so a limiter error never blocks a real question", () => { + expect(src).toMatch(/return true; \/\/ fail open/); + }); +}); diff --git a/supabase/functions/discord-interactions/fleety-2.1-discord.feature b/supabase/functions/discord-interactions/fleety-2.1-discord.feature index 88462d23..2385da73 100644 --- a/supabase/functions/discord-interactions/fleety-2.1-discord.feature +++ b/supabase/functions/discord-interactions/fleety-2.1-discord.feature @@ -44,6 +44,15 @@ Feature: Discord /fleety on the unified 2.0 brain (Fleety 2.1) When they exceed the per-user hourly limit Then further calls are refused with a friendly notice before any 2.0 call + @security @regression + Scenario: A normal user under the limit is NOT falsely throttled + Given a Discord user asking their first /fleety question of the hour + When the adapter checks the rate limit + Then it uses the generic edge limiter (check_edge_rate_limit), which accepts a "discord:" + And the call is allowed through to the 2.0 brain + # Guards the 2026-08 outage: the auth-only check_rate_limit rejected the non-hashed identifier + # and the "fleety" action, returning allowed=false on EVERY call — /fleety answered nobody. + # ── Untrusted content / prompt injection / output (ai-llm-agent-security) ── @security Scenario: Discord message content is subject to the 2.0 injection + scope gates diff --git a/supabase/functions/discord-interactions/index.ts b/supabase/functions/discord-interactions/index.ts index 0917c605..de9fd085 100644 --- a/supabase/functions/discord-interactions/index.ts +++ b/supabase/functions/discord-interactions/index.ts @@ -65,17 +65,20 @@ const SB_ANON_KEY = async function underRateLimit(discordUserId: string): Promise { try { const sb = createClient(SB_URL, SB_SERVICE_ROLE_KEY); - const { data, error } = await sb.rpc("check_rate_limit", { + // Use the GENERIC edge limiter (check_edge_rate_limit), NOT the auth-only check_rate_limit. + // check_rate_limit is hardened to auth flows: it rejects any identifier that isn't a 64-char + // SHA-256 hash AND any action outside its login/signup/reset whitelist — so calling it with + // `discord:` + "fleety" returned {allowed:false} on EVERY call, silently killing /fleety + // (members only ever saw "you've asked a lot in the last hour"). check_edge_rate_limit hashes + // the identifier itself, accepts any action, and is a clean sliding window (no stuck block). + const { data, error } = await sb.rpc("check_edge_rate_limit", { p_identifier: `discord:${discordUserId}`, p_action: "fleety", - p_max_attempts: 10, + p_max: 20, p_window_minutes: 60, - p_block_minutes: 15, }); - if (error) return true; // fail open - return typeof data === "object" && data !== null - ? (data as { allowed?: boolean }).allowed !== false - : data !== false; + if (error) return true; // fail open — a limiter error must never block a real question + return (data as { allowed?: boolean } | null)?.allowed !== false; } catch { return true; }