Skip to content

[DISCORDSH] Rust-First Vote Process — Rate-Limited Server Voting Pipeline #7730

@h0lybyte

Description

@h0lybyte

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 structureDashMap<IpAddr, Instant> tracking last vote timestamp per IP
  • Check — If now - last_vote < 6 hours, reject with 429 Too Many Requests
  • Retry-After header — 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 VoteRateLimiter to AppState in state.rs
  • Spawn background prune task in server startup
  • Extract client IP from X-Forwarded-For or ConnectInfo (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


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-After value 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 testVoteRateLimiter::check() allows first vote, blocks second within 6h, allows after cooldown
  • Unit testVoteRateLimiter::prune() removes stale entries
  • Unit test — Snowflake validation accepts 17-20 digit strings, rejects others
  • Integration testPOST /api/servers/vote with valid payload returns 200
  • Integration test — Second vote from same IP within 6h returns 429 with Retry-After
  • Integration test — Missing/invalid server_id returns 400
  • E2e — Existing smoke tests still pass

Acceptance Criteria

  • POST /api/servers/vote validates 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

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status

    Theory

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions