Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/test/smoke/fleety-discord-ratelimit.smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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:<id>` + "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/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>"
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
Expand Down
17 changes: 10 additions & 7 deletions supabase/functions/discord-interactions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,20 @@ const SB_ANON_KEY =
async function underRateLimit(discordUserId: string): Promise<boolean> {
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:<id>` + "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;
}
Expand Down
Loading