-
-
Notifications
You must be signed in to change notification settings - Fork 16
Description
Overview
Add a Rust validation layer to the Discord server voting pipeline, matching the belt-and-suspenders pattern from #7727 (server submission). Every vote flows through five layers:
Client → Rust (axum-discordsh) → Edge Function → ProxyRPC → ServiceRPC → SQL
Currently the frontend calls the edge function directly (POST /functions/v1/discordsh with command: "vote.cast"). This issue adds axum-discordsh as the entry point with 1 vote per IP per 6 hours rate limiting to keep things friendly and spam-free.
Current Flow (No Rust Layer)
ReactServerGrid.tsx → castVote(serverId, captchaToken)
→ POST https://supabase.kbve.com/functions/v1/discordsh
body: { command: "vote.cast", server_id, captcha_token }
header: Authorization: Bearer {jwt}
→ Edge Function (vote.ts)
→ validateSnowflake(), verifyCaptcha()
→ supabase.rpc("proxy_cast_vote", { p_server_id })
→ proxy_cast_vote() — derives user from auth.uid()
→ service_cast_vote() — advisory lock, rate limits, replace-model INSERT
→ trg_votes_counter trigger — increments/decrements vote_count
Proposed Flow (Rust First)
ReactServerGrid.tsx → castVote(serverId, captchaToken)
→ POST /api/servers/vote (axum-discordsh)
body: { server_id, captcha_token }
header: Authorization: Bearer {jwt}
→ Rust handler: validate snowflake, check IP rate limit (1 per 6 hours)
→ reqwest POST to Edge Function (forwarding payload + user JWT)
→ Edge Function: re-validates, verifies captcha
→ supabase.rpc("proxy_cast_vote", { p_server_id })
→ proxy → service → SQL (unchanged)
1. Rust API Route — POST /api/servers/vote
File: apps/discordsh/axum-discordsh/src/api/servers.rs
Add alongside the submit handler from #7727.
Request Schema
#[derive(Deserialize)]
pub struct CastVoteRequest {
pub server_id: String, // Discord snowflake (17-20 digits)
pub captcha_token: String, // hCaptcha response token
}Validation (Rust Layer)
Lightweight — voting has fewer fields than submission:
- Snowflake format — regex
^\d{17,20}$ - Captcha token — non-empty string (actual verification delegated to edge function)
- Authorization header — must be present (Bearer token forwarded downstream)
Error Response
{
"success": false,
"message": "Must wait 6 hours between votes"
}Voting errors are single-message (not field-level like submission) since there are only two input fields.
2. IP Rate Limiting — 1 Vote Per 6 Hours
Goal
Keep voting friendly and limit spam. One vote per IP address every 6 hours regardless of user account. This prevents vote manipulation via multiple accounts from the same source.
Implementation
- Rate limiter structure —
DashMap<IpAddr, Instant>tracking last vote timestamp per IP - Check — If
now - last_vote < 6 hours, reject with429 Too Many Requests -
Retry-Afterheader — Include seconds remaining until next allowed vote - Prune task — Background task every 30 minutes removes entries older than 6 hours to prevent memory growth
pub struct VoteRateLimiter {
votes: DashMap<IpAddr, Instant>,
}
impl VoteRateLimiter {
const COOLDOWN: Duration = Duration::from_secs(6 * 60 * 60); // 6 hours
pub fn check(&self, ip: IpAddr) -> Result<(), Duration> {
if let Some(last) = self.votes.get(&ip) {
let elapsed = last.elapsed();
if elapsed < Self::COOLDOWN {
return Err(Self::COOLDOWN - elapsed); // time remaining
}
}
self.votes.insert(ip, Instant::now());
Ok(())
}
pub fn prune(&self) {
self.votes.retain(|_, last| last.elapsed() < Self::COOLDOWN);
}
}Tasks
- Add
VoteRateLimitertoAppStateinstate.rs - Spawn background prune task in server startup
- Extract client IP from
X-Forwarded-FororConnectInfo(respect reverse proxy)
Rate Limit Layering
| Layer | Scope | Limit | Purpose |
|---|---|---|---|
| Rust | Per IP | 1 vote / 6 hours | Spam prevention, multi-account abuse |
| Proxy RPC | Per user per server | 12-hour cooldown | Prevents re-voting same server |
| Proxy RPC | Per user global | 50 votes / 24 hours | Daily cap across all servers |
| SQL | Per (server, user) | UNIQUE replace-model | Deduplication, atomic counter |
Each layer catches a different abuse vector — IP-level in Rust, user-level in the database.
3. JWT Forwarding
Logical Flow
Rust handler receives POST /api/servers/vote:
1. Extract Authorization header (Bearer token)
2. Validate server_id format (reject 400 if invalid)
3. Validate captcha_token is non-empty (reject 400 if missing)
4. Check IP rate limit (reject 429 if cooldown active)
5. Forward to edge function via reqwest:
POST {EDGE_FUNCTION_URL}/discordsh
Headers:
Authorization: Bearer {original_user_jwt}
Content-Type: application/json
Body: { command: "vote.cast", server_id, captcha_token }
6. Parse edge function response
7. Return to client:
- 200 with { success: true, vote_id, message }
- 4xx/5xx pass-through from edge function
- 502 if edge function unreachable
Tasks
- Reuse
reqwest::ClientfromAppState(shared with server submission from [DISCORDSH] Rust-First Server Submission Pipeline — Belt & Suspenders Validation #7727) - Reuse
EDGE_FUNCTION_URLenv var (same as [DISCORDSH] Rust-First Server Submission Pipeline — Belt & Suspenders Validation #7727) - Set 10-second timeout on edge function call
- Map edge function HTTP errors to appropriate client responses
4. Update Frontend
File: apps/discordsh/astro-discordsh/src/lib/servers/discordshEdge.ts
// Before:
export async function castVote(serverId: string, captchaToken: string) {
const session = authBridge.getSession();
const res = await fetch(EDGE_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token && {
Authorization: `Bearer ${session.access_token}`,
}),
},
body: JSON.stringify({
command: 'vote.cast',
server_id: serverId,
captcha_token: captchaToken,
}),
});
return res.json();
}
// After:
export async function castVote(serverId: string, captchaToken: string) {
const session = authBridge.getSession();
const res = await fetch('/api/servers/vote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token && {
Authorization: `Bearer ${session.access_token}`,
}),
},
body: JSON.stringify({
server_id: serverId,
captcha_token: captchaToken,
}),
});
return res.json();
}Tasks
- Update
castVote()to call/api/servers/vote - Remove
command: "vote.cast"from body (Rust handler adds it when forwarding) - Handle 429 response — show friendly "Please wait X hours before voting again" message
- Show
Retry-Aftervalue from response headers as countdown or human-readable time
UX for Rate Limit
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') || '0', 10);
const hours = Math.ceil(retryAfter / 3600);
setError(`Thanks for voting! You can vote again in ~${hours} hour${hours > 1 ? 's' : ''}.`);
return;
}5. Validation Layer Comparison
| Check | Rust | Edge Fn | Proxy RPC | Service RPC | SQL |
|---|---|---|---|---|---|
| Snowflake format | regex | regex | — | — | — |
| Captcha non-empty | check | verifyCaptcha() |
— | — | — |
| Auth (JWT) | forward | parseJwt() |
auth.uid() |
param | RLS |
| IP rate limit | 1/6h DashMap | — | — | — | — |
| Per-server cooldown | — | — | 12h query | — | — |
| Daily vote cap | — | — | 50/day query | — | — |
| Server exists + active | — | — | — | SELECT status=1 | FK |
| Vote dedup | — | — | — | DELETE+INSERT | UNIQUE |
| Counter sync | — | — | — | — | trigger |
| Advisory lock | — | — | — | dual-hash lock | — |
6. Files & Modifications
| File | Action | Description |
|---|---|---|
axum-discordsh/src/api/servers.rs |
Edit | Add cast_vote handler |
axum-discordsh/src/api/rate_limit.rs |
Edit | Add VoteRateLimiter (separate from submission limiter) |
axum-discordsh/src/transport/https.rs |
Edit | Mount POST /api/servers/vote |
axum-discordsh/src/state.rs |
Edit | Add VoteRateLimiter to AppState |
astro-discordsh/src/lib/servers/discordshEdge.ts |
Edit | Point castVote() to Rust API |
astro-discordsh/src/components/servers/ReactServerGrid.tsx |
Edit | Handle 429 with friendly retry message |
All Rust paths relative to apps/discordsh/.
7. Testing
- Unit test —
VoteRateLimiter::check()allows first vote, blocks second within 6h, allows after cooldown - Unit test —
VoteRateLimiter::prune()removes stale entries - Unit test — Snowflake validation accepts 17-20 digit strings, rejects others
- Integration test —
POST /api/servers/votewith valid payload returns 200 - Integration test — Second vote from same IP within 6h returns 429 with
Retry-After - Integration test — Missing/invalid
server_idreturns 400 - E2e — Existing smoke tests still pass
Acceptance Criteria
-
POST /api/servers/votevalidates snowflake and captcha token - Valid requests forwarded to edge function with user's JWT
- IP rate limit enforces 1 vote per 6 hours with friendly 429 +
Retry-After - Frontend
castVote()calls the Rust endpoint - Frontend displays human-readable wait time on 429
- Edge function still works if called directly (defense in depth unchanged)
- Background prune task prevents memory growth from stale rate limit entries
- All existing e2e tests pass
Metadata
Metadata
Assignees
Labels
Type
Projects
Status