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:
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
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
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
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
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
Acceptance Criteria
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:
Currently the frontend calls the edge function directly (
POST /functions/v1/discordshwithcommand: "vote.cast"). This issue addsaxum-discordshas 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)
Proposed Flow (Rust First)
1. Rust API Route —
POST /api/servers/voteFile:
apps/discordsh/axum-discordsh/src/api/servers.rsAdd alongside the
submithandler from #7727.Request Schema
Validation (Rust Layer)
Lightweight — voting has fewer fields than submission:
^\d{17,20}$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
DashMap<IpAddr, Instant>tracking last vote timestamp per IPnow - last_vote < 6 hours, reject with429 Too Many RequestsRetry-Afterheader — Include seconds remaining until next allowed voteTasks
VoteRateLimitertoAppStateinstate.rsX-Forwarded-FororConnectInfo(respect reverse proxy)Rate Limit Layering
Each layer catches a different abuse vector — IP-level in Rust, user-level in the database.
3. JWT Forwarding
Logical Flow
Tasks
reqwest::ClientfromAppState(shared with server submission from [DISCORDSH] Rust-First Server Submission Pipeline — Belt & Suspenders Validation #7727)EDGE_FUNCTION_URLenv var (same as [DISCORDSH] Rust-First Server Submission Pipeline — Belt & Suspenders Validation #7727)4. Update Frontend
File:
apps/discordsh/astro-discordsh/src/lib/servers/discordshEdge.tsTasks
castVote()to call/api/servers/votecommand: "vote.cast"from body (Rust handler adds it when forwarding)Retry-Aftervalue from response headers as countdown or human-readable timeUX for Rate Limit
5. Validation Layer Comparison
verifyCaptcha()parseJwt()auth.uid()6. Files & Modifications
axum-discordsh/src/api/servers.rscast_votehandleraxum-discordsh/src/api/rate_limit.rsVoteRateLimiter(separate from submission limiter)axum-discordsh/src/transport/https.rsPOST /api/servers/voteaxum-discordsh/src/state.rsVoteRateLimitertoAppStateastro-discordsh/src/lib/servers/discordshEdge.tscastVote()to Rust APIastro-discordsh/src/components/servers/ReactServerGrid.tsxAll Rust paths relative to
apps/discordsh/.7. Testing
VoteRateLimiter::check()allows first vote, blocks second within 6h, allows after cooldownVoteRateLimiter::prune()removes stale entriesPOST /api/servers/votewith valid payload returns 200Retry-Afterserver_idreturns 400Acceptance Criteria
POST /api/servers/votevalidates snowflake and captcha tokenRetry-AftercastVote()calls the Rust endpoint